@byollm/relay 0.1.0-alpha.23 → 0.1.0-alpha.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  > [!WARNING]
2
- > **Alpha (`0.1.0-alpha.23`) — under active development. Don't use this yet.**
2
+ > **Alpha (`0.1.0-alpha.25`) — under active development. Don't use this yet.**
3
3
  >
4
4
  > This is a walking skeleton. It routes real jobs between real daemons and real
5
5
  > sites, and it is the fixture byollm_009 freezes against — but it keeps its
@@ -72,7 +72,7 @@
72
72
  > packages published and `@byollm/server` did not: a Sigstore
73
73
  > transparency-log 409 on its provenance attestation. The workflow's
74
74
  > "already published" guard correctly refuses to resume a partial publish,
75
- > so `0.1.0-alpha.23` is that release, whole.
75
+ > so `0.1.0-alpha.25` is that release, whole.
76
76
  >
77
77
  > If you run the Supabase adapter, `alpha.21` needs
78
78
  > `20260819010000_completed_by_lease_id.sql`: alpha.19 shipped §3.6's
@@ -153,7 +153,7 @@ daemons pin at pairing, verified against the `sites` half of the projection.
153
153
  Nothing here trusts a `siteId` in a body or a query string.
154
154
 
155
155
  That is newer than the rest of this package. The site plane took the caller's
156
- word for who it was until `0.1.0-alpha.23`, which on a relay reachable from the
156
+ word for who it was until `0.1.0-alpha.25`, which on a relay reachable from the
157
157
  internet is an open enqueue endpoint into consenting users' machines and an
158
158
  open read of who is online. It was blind the whole time — nothing could open a
159
159
  payload — and blind is not the same as safe.
@@ -161,7 +161,7 @@ payload — and blind is not the same as safe.
161
161
  If you are running this: the site plane is authenticated but this is still a
162
162
  single-tenant relay with in-memory state. One site, one replica.
163
163
 
164
- ## Breaking in `0.1.0-alpha.23`: `RelayState` is async
164
+ ## Breaking in `0.1.0-alpha.25`: `RelayState` is async
165
165
 
166
166
  Every method on `RelayState` now returns a `Promise`, and `Relay.sweep()` and
167
167
  `debugPage()` with it. `RelayState.requeue` is private — it was only ever a
@@ -0,0 +1,364 @@
1
+ // src/state.ts
2
+ import { randomUUID } from "crypto";
3
+ var AWAITING_PAYLOAD_MS = 1e4;
4
+ var routeKey = (siteId, owner) => `${siteId}\0${owner}`;
5
+ var keyOf = (siteId, jobId) => `${siteId}\0${jobId}`;
6
+ var RelayState = class {
7
+ /**
8
+ * Jobs by **(site, id)** — cloud_009 §3.
9
+ *
10
+ * A job id is a site's to choose, so two sites can choose the same one.
11
+ * Keyed by the bare id, the second site's enqueue returned the first
12
+ * site's job (cloud_008 finding 58), and the refusal that fixed it was a
13
+ * cross-tenant existence oracle. Keyed by the pair, the collision does not
14
+ * exist and there is nothing to refuse.
15
+ *
16
+ * `\u0000` as the separator, because a site id is a uuid and a job id is
17
+ * whatever a site chose — including, one day, a string with a colon in it.
18
+ * A separator that cannot appear in either half is the difference between
19
+ * a key and a parser.
20
+ */
21
+ #jobs = /* @__PURE__ */ new Map();
22
+ /**
23
+ * Grants by lease id, so a holder-scoped call needs no site — §3.
24
+ *
25
+ * `takePayload`, `complete`, `releaseLeases` and `renewLeases` carry a
26
+ * `leaseId` the relay minted, which is unique across every site. That is
27
+ * what lets those four signatures stay as they are: the caller names the
28
+ * grant, and the grant names the job. A daemon never has to know a site id
29
+ * to answer for work it holds.
30
+ */
31
+ #byLease = /* @__PURE__ */ new Map();
32
+ /**
33
+ * Jobs by bare id, across sites — the refusal path.
34
+ *
35
+ * The lease index alone answers the happy case and gets the refusals
36
+ * wrong: a **stale** lease finds nothing, so `LEASE_HONORED`'s "your grant
37
+ * ended" becomes "no such job", and a daemon that was slow is told
38
+ * something untrue about the work it was doing. Distinguishing
39
+ * `not-found`, `not-holder` and `stale-lease` needs the job even when the
40
+ * lease named is over, and that is what this is for.
41
+ *
42
+ * A list rather than a single value: two sites may choose one id, which is
43
+ * the whole reason `#jobs` is keyed by the pair.
44
+ */
45
+ #byJobId = /* @__PURE__ */ new Map();
46
+ /**
47
+ * The job a holder-scoped call is about, without a site id.
48
+ *
49
+ * The exact grant first, and **checked against the job the caller named**:
50
+ * a lease id belonging to another job would otherwise hand over that job's
51
+ * payload to somebody holding a valid-looking grant. Then the same job held
52
+ * by this runner under an older grant, which is what `stale-lease` is. Then
53
+ * any job with that id, which is what `not-holder` is.
54
+ */
55
+ #grantFor(jobId, runnerId, leaseId) {
56
+ const exact = this.#byLease.get(leaseId);
57
+ if (exact?.id === jobId) return exact;
58
+ const candidates = this.#byJobId.get(jobId) ?? [];
59
+ return candidates.find((job) => job.claimedBy?.runnerId === runnerId) ?? candidates[0];
60
+ }
61
+ #index(job) {
62
+ const bare = this.#byJobId.get(job.id);
63
+ if (bare) {
64
+ if (!bare.includes(job)) bare.push(job);
65
+ } else {
66
+ this.#byJobId.set(job.id, [job]);
67
+ }
68
+ }
69
+ #forget(job) {
70
+ this.#jobs.delete(keyOf(job.siteId, job.id));
71
+ const bare = (this.#byJobId.get(job.id) ?? []).filter((it) => it !== job);
72
+ if (bare.length === 0) this.#byJobId.delete(job.id);
73
+ else this.#byJobId.set(job.id, bare);
74
+ if (job.claimedBy) this.#byLease.delete(job.claimedBy.leaseId);
75
+ }
76
+ #presence = /* @__PURE__ */ new Map();
77
+ #now;
78
+ constructor(options = {}) {
79
+ this.#now = options.now ?? Date.now;
80
+ }
81
+ /** The one clock every deadline in this store is stamped from. */
82
+ async now() {
83
+ return this.#now();
84
+ }
85
+ /**
86
+ * Take a stub for routing. The payload is not here and will not be.
87
+ *
88
+ * **Idempotent by job id, and that is a security property rather than a
89
+ * convenience.** Site-plane calls are authenticated by signature, and
90
+ * byollm_009 §4.2's argument for signing the request instead of a
91
+ * server-issued nonce rests entirely on every write being idempotent per the
92
+ * instance it names. This one was not: re-enqueueing a known id built a
93
+ * fresh `queued` job over the top of the old one, discarding a live claim,
94
+ * its lease and any payload the site had already sealed to a device. A
95
+ * replayed enqueue inside the two-minute freshness window was therefore a
96
+ * way to yank a job back from the machine running it — the `release` bug of
97
+ * §4.2, rediscovered on the other plane.
98
+ *
99
+ * So a known id returns what is already routing, unchanged. A site that
100
+ * restarts and republishes its queue is the normal case, and it must not
101
+ * disturb work in flight.
102
+ */
103
+ enqueue(input) {
104
+ const existing = this.#jobs.get(keyOf(input.siteId, input.id));
105
+ if (existing) return Promise.resolve(existing);
106
+ const job = {
107
+ id: input.id,
108
+ siteId: input.siteId,
109
+ stub: input.stub,
110
+ state: "queued",
111
+ refusedBy: []
112
+ };
113
+ this.#jobs.set(keyOf(job.siteId, job.id), job);
114
+ this.#index(job);
115
+ return Promise.resolve(job);
116
+ }
117
+ job(siteId, jobId) {
118
+ return Promise.resolve(this.#jobs.get(keyOf(siteId, jobId)));
119
+ }
120
+ jobs() {
121
+ return Promise.resolve([...this.#jobs.values()]);
122
+ }
123
+ /** Jobs a site must seal for, right now. */
124
+ async awaiting(siteId) {
125
+ return (await this.jobs()).filter(
126
+ (j) => j.siteId === siteId && j.state === "awaiting-payload"
127
+ );
128
+ }
129
+ /** Sealed results waiting to go home. */
130
+ async finished(siteId) {
131
+ return (await this.jobs()).filter(
132
+ (j) => j.siteId === siteId && j.state === "done" && j.result !== void 0
133
+ );
134
+ }
135
+ /**
136
+ * Claim work — one operation, because it has to be.
137
+ *
138
+ * Moved here wholesale from `DaemonPlane`, where it was a scan followed by
139
+ * per-job mutation. Nothing about the *decision* changed; what changed is
140
+ * that a store can now implement it, because the filter and the write are
141
+ * one call rather than a loop the caller drives.
142
+ *
143
+ * The order of the guards is worth preserving as-is when this becomes a Lua
144
+ * script: cheapest first, and `owners` last because it is the only one that
145
+ * needed the projection.
146
+ */
147
+ async claim(input) {
148
+ const now = await this.now();
149
+ await this.sweep();
150
+ const granted = [];
151
+ for (const job of this.#jobs.values()) {
152
+ if (granted.length >= input.max) break;
153
+ if (job.state !== "queued") continue;
154
+ if (!input.routes.has(routeKey(job.siteId, job.stub.owner))) continue;
155
+ if (!input.kinds.has(job.stub.kind)) continue;
156
+ if (job.refusedBy.includes(input.runnerId)) continue;
157
+ if (job.cancelled) continue;
158
+ if (job.stub.audience === "self" && job.stub.owner !== input.owner) {
159
+ continue;
160
+ }
161
+ const leaseId = randomUUID();
162
+ job.state = "awaiting-payload";
163
+ job.claimedBy = {
164
+ runnerId: input.runnerId,
165
+ owner: input.owner,
166
+ device: input.device,
167
+ leaseId,
168
+ leaseExpiresAt: now + input.leaseMs
169
+ };
170
+ job.awaitingUntil = now + AWAITING_PAYLOAD_MS;
171
+ this.#byLease.set(leaseId, job);
172
+ granted.push({
173
+ ...job.stub,
174
+ lease: {
175
+ id: leaseId,
176
+ runnerId: input.runnerId,
177
+ expiresAt: job.claimedBy.leaseExpiresAt
178
+ }
179
+ });
180
+ }
181
+ return granted;
182
+ }
183
+ /**
184
+ * Hand over the sealed payload to the device that holds the lease.
185
+ *
186
+ * The read and the state transition are one operation for the same reason
187
+ * `claim` is: `running` must be set by whoever was told the envelope, or two
188
+ * replicas can both hand out the same work and both believe they were first.
189
+ */
190
+ takePayload(input) {
191
+ const job = this.#grantFor(input.jobId, input.runnerId, input.leaseId);
192
+ if (!job) return Promise.resolve({ refused: "not-found" });
193
+ if (job.claimedBy?.runnerId !== input.runnerId) {
194
+ return Promise.resolve({ refused: "not-holder" });
195
+ }
196
+ if (job.claimedBy.leaseId !== input.leaseId) {
197
+ return Promise.resolve({ refused: "stale-lease" });
198
+ }
199
+ if (!job.payload) return Promise.resolve({ refused: "not-ready" });
200
+ job.state = "running";
201
+ return Promise.resolve({ envelope: job.payload });
202
+ }
203
+ /**
204
+ * Record a finished job.
205
+ *
206
+ * `RESULT_IDEMPOTENT` lives here rather than in the caller: a replayed
207
+ * result must be a no-op decided by the same operation that would have
208
+ * written it, or two replicas can both decide they were the first.
209
+ */
210
+ complete(input) {
211
+ const job = this.#grantFor(input.jobId, input.runnerId, input.leaseId);
212
+ if (!job) return Promise.resolve({ refused: "not-found" });
213
+ if (job.claimedBy?.runnerId !== input.runnerId) {
214
+ return Promise.resolve({ refused: "not-holder" });
215
+ }
216
+ if (job.state === "done") {
217
+ const sameGrant = job.claimedBy.leaseId === input.leaseId;
218
+ return Promise.resolve(
219
+ sameGrant ? { accepted: false, duplicate: true, state: job.state } : { refused: "stale-lease" }
220
+ );
221
+ }
222
+ if (job.claimedBy.leaseId !== input.leaseId) {
223
+ return Promise.resolve({ refused: "stale-lease" });
224
+ }
225
+ job.result = input.envelope;
226
+ job.disposition = input.disposition;
227
+ job.state = "done";
228
+ return Promise.resolve({ accepted: true, state: job.state });
229
+ }
230
+ /** Give back leases this runner holds, naming each grant it means. */
231
+ releaseLeases(input) {
232
+ const released = [];
233
+ for (const { jobId, leaseId } of input.leases) {
234
+ const job = this.#grantFor(jobId, input.runnerId, leaseId);
235
+ if (!job || job.claimedBy?.runnerId !== input.runnerId) continue;
236
+ if (job.claimedBy.leaseId !== leaseId) continue;
237
+ if (input.reason === "refused" && !job.refusedBy.includes(input.runnerId)) {
238
+ job.refusedBy.push(input.runnerId);
239
+ }
240
+ this.#requeue(job);
241
+ released.push(jobId);
242
+ }
243
+ return Promise.resolve(released);
244
+ }
245
+ /**
246
+ * Take a site's sealed payload for a claimed job.
247
+ *
248
+ * Refuses anything not `awaiting-payload`, which is what makes the timeout
249
+ * mean something: a late seal must not land on a claim that has moved.
250
+ */
251
+ seal(input) {
252
+ const job = this.#jobs.get(keyOf(input.siteId, input.jobId));
253
+ if (job?.siteId !== input.siteId) {
254
+ return Promise.resolve({ refused: "not-found" });
255
+ }
256
+ if (job.state !== "awaiting-payload") {
257
+ return Promise.resolve({ refused: "too-late", was: job.state });
258
+ }
259
+ job.payload = input.envelope;
260
+ job.state = "ready";
261
+ delete job.awaitingUntil;
262
+ return Promise.resolve({ state: job.state });
263
+ }
264
+ /** {@link RoutingStore.cancel} — the site withdraws a job. */
265
+ cancel(input) {
266
+ const job = this.#jobs.get(keyOf(input.siteId, input.jobId));
267
+ if (job?.siteId !== input.siteId) return Promise.resolve(false);
268
+ job.cancelled = true;
269
+ return Promise.resolve(true);
270
+ }
271
+ /** {@link RoutingStore.cancelRequests} — cancelled jobs this runner holds. */
272
+ cancelRequests(runnerId) {
273
+ return Promise.resolve(
274
+ [...this.#jobs.values()].filter(
275
+ (job) => job.cancelled === true && job.claimedBy?.runnerId === runnerId
276
+ ).map((job) => job.id)
277
+ );
278
+ }
279
+ /** {@link RoutingStore.renewLeases} — extend what is still held, name what is not. */
280
+ async renewLeases(input) {
281
+ const now = await this.now();
282
+ const renewed = [];
283
+ const lost = [];
284
+ for (const { jobId, leaseId } of input.leases) {
285
+ const job = this.#grantFor(jobId, input.runnerId, leaseId);
286
+ const held = job?.claimedBy;
287
+ if (!job || held?.leaseId !== leaseId || held.runnerId !== input.runnerId) {
288
+ lost.push(jobId);
289
+ continue;
290
+ }
291
+ const expiresAt = now + input.leaseMs;
292
+ job.claimedBy = { ...held, leaseExpiresAt: expiresAt };
293
+ renewed.push({ jobId, expiresAt });
294
+ }
295
+ return { renewed, lost };
296
+ }
297
+ async seen(presence) {
298
+ const lastSeenAt = await this.now();
299
+ const existing = this.#presence.get(presence.runnerId);
300
+ if (existing) {
301
+ existing.lastSeenAt = lastSeenAt;
302
+ return existing;
303
+ }
304
+ const fresh = { ...presence, lastSeenAt };
305
+ this.#presence.set(presence.runnerId, fresh);
306
+ return fresh;
307
+ }
308
+ presence(runnerId) {
309
+ return Promise.resolve(this.#presence.get(runnerId));
310
+ }
311
+ everyone() {
312
+ return Promise.resolve([...this.#presence.values()]);
313
+ }
314
+ /**
315
+ * Return a job to the queue, forgetting the claim.
316
+ *
317
+ * The stub survives; nothing is lost. That is `LEASE_RECLAIMABLE` and it is
318
+ * why the awaiting-payload timeout is cheap to fire: the worst case is that
319
+ * a device did nothing for ten seconds and another one gets a turn.
320
+ */
321
+ #requeue(job) {
322
+ job.state = "queued";
323
+ if (job.claimedBy) this.#byLease.delete(job.claimedBy.leaseId);
324
+ delete job.claimedBy;
325
+ delete job.awaitingUntil;
326
+ delete job.payload;
327
+ }
328
+ /**
329
+ * Fire whatever the clock says is due, and report it.
330
+ *
331
+ * Returns the jobs it requeued so a caller can log or surface them — a
332
+ * timeout that fires invisibly is indistinguishable from a job that was
333
+ * never claimed, and those want very different debugging.
334
+ */
335
+ async sweep() {
336
+ const now = await this.now();
337
+ const requeued = [];
338
+ const expired = [];
339
+ for (const job of this.#jobs.values()) {
340
+ if (job.stub.deadlineAt <= now) {
341
+ this.#forget(job);
342
+ expired.push(job);
343
+ continue;
344
+ }
345
+ if (job.state === "awaiting-payload" && (job.awaitingUntil ?? 0) <= now) {
346
+ this.#requeue(job);
347
+ requeued.push(job);
348
+ }
349
+ const lease = job.claimedBy;
350
+ if (lease && (job.state === "ready" || job.state === "running") && lease.leaseExpiresAt <= now) {
351
+ this.#requeue(job);
352
+ requeued.push(job);
353
+ }
354
+ }
355
+ return [...requeued, ...expired];
356
+ }
357
+ };
358
+
359
+ export {
360
+ AWAITING_PAYLOAD_MS,
361
+ routeKey,
362
+ RelayState
363
+ };
364
+ //# sourceMappingURL=chunk-ZH2GB6FY.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/state.ts"],"sourcesContent":["import type {\n ClaimedStub,\n JobStub,\n PublicIdentity,\n SealedEnvelope,\n} from \"@byollm/protocol\";\nimport { randomUUID } from \"node:crypto\";\nimport type { RoutingStore } from \"./store.js\";\n\n/**\n * The relay's routing state — byollm_009 §7, reachable at last.\n *\n * §7 described a state machine the direct plane could not produce. There, the\n * site and the upstream are the same party: it seals when it likes, and a job\n * is never claimed-but-unsealed. Here they are different parties, and the gap\n * between them is a state:\n *\n * ```\n * queued ──claim──▶ awaiting-payload ──sealed──▶ ready ──fetch──▶ running\n * ▲ │ │\n * └────────────────────┘ ▼\n * site never seals, or seals too late ok | error | canceled\n * ```\n *\n * The relay cannot seal, so it cannot shortcut this. A payload is encrypted\n * to *the device that claimed it*, and nobody knows which device that is until\n * the claim happens — which is precisely why claim-then-fetch makes a blind\n * relay possible at all. The window is the price.\n *\n * ## What the relay holds, and what it cannot\n *\n * Stubs (metadata the site chose to publish), sealed envelopes it cannot open,\n * and public keys. There is no field on any type in this file that could hold\n * a private key or a plaintext, which is `RELAY_BLIND` expressed as a data\n * model rather than as a policy.\n */\n\n/** Where a routed job is. */\nexport type RoutedState =\n \"queued\" | \"awaiting-payload\" | \"ready\" | \"running\" | \"done\";\n\n/**\n * How long a site has to seal after one of its jobs is claimed.\n *\n * **Distinct from the lease, and distinct from the job's TTL** — byollm_009\n * §7.1. Three clocks, three different questions:\n *\n * - the **TTL** asks how long the work is worth doing at all;\n * - the **lease** asks how long this device gets to run it;\n * - this asks how long we wait for a site that has gone away.\n *\n * Collapsing any pair of them looks harmless until a site restarts during a\n * deploy: with only a lease, the device sits politely holding a job whose\n * payload will never arrive, and the lease's whole minute is spent waiting on\n * a party that is not coming back. Short, because a site that is up answers in\n * milliseconds and a site that is down will not answer sooner for waiting.\n */\nexport const AWAITING_PAYLOAD_MS = 10_000;\n\n/** A job the relay is routing. Metadata and ciphertext, nothing else. */\n/** Why a daemon gave a job back. Only `refused` means \"not me, ever\". */\nexport type ReleaseReason =\n \"shutdown\" | \"pause\" | \"revoked\" | \"backend-down\" | \"refused\";\n\nexport interface RoutedJob {\n readonly id: string;\n /** Which site enqueued it — the party that will be asked to seal. */\n readonly siteId: string;\n /**\n * Everything the relay knows about the work, which is everything the site\n * chose to publish and not one field more (byollm_009 §6).\n */\n readonly stub: JobStub;\n state: RoutedState;\n /** Set from the claim; the site seals to these keys. */\n claimedBy?: {\n readonly runnerId: string;\n readonly owner: string;\n readonly device: PublicIdentity;\n readonly leaseId: string;\n readonly leaseExpiresAt: number;\n };\n /** When {@link AWAITING_PAYLOAD_MS} runs out for this claim. */\n awaitingUntil?: number;\n /**\n * Runners that released this job with reason `refused` — cloud_008 §2.1.\n *\n * `REFUSAL_NOT_REOFFERED`, which the relay did not implement: it dropped\n * `ReleaseRequest.reason` on the floor. The field's own docstring says why\n * that is not cosmetic — an upstream cannot evaluate a daemon's *local*\n * `named` allowlist, so it may legitimately offer work the daemon then\n * declines, and without a record the two spin between claim and release\n * forever. The direct plane has always kept this list.\n */\n refusedBy: string[];\n /**\n * The site withdrew this job — cloud_008 §2.2.\n *\n * A flag rather than a state, because a cancelled job that a device is\n * *running* is not finished: the daemon has to be told, abort its backend\n * call and report `canceled`, and the ordinary `complete` path then closes\n * it. Making it a state would strand the in-flight case between two\n * machines' ideas of what happened.\n */\n cancelled?: boolean;\n /** Sealed to the claiming device by the site. Opaque here. */\n payload?: SealedEnvelope;\n /** Sealed to the site by the device. Opaque here. */\n result?: SealedEnvelope;\n /**\n * The result's clear-text discriminator — byollm_009 §6.1.\n *\n * The one outcome fact the relay is given, and the reason it is given:\n * without it the relay cannot stop dispatching a finished job. A routing\n * hint and never a fact — the *site* verifies it against the sealed\n * outcome, because only the site can open the envelope. The relay acts on\n * it and is entitled to be wrong; a lying daemon costs it a dispatch\n * decision, not a security property.\n */\n disposition?: \"ok\" | \"error\" | \"canceled\";\n}\n\n/** A device the relay has seen recently. */\nexport interface Presence {\n readonly runnerId: string;\n readonly owner: string;\n readonly device: PublicIdentity;\n lastSeenAt: number;\n // `revoked` is deliberately absent — cloud_008 §2.3.\n //\n // It was a boolean on the *runner*, written at heartbeat and read by\n // nothing but the debug page. Enforcement had already moved to the\n // projection, because enforcing on this flag made revocation depend on the\n // client calling an endpoint: a daemon that simply never heartbeat went on\n // claiming forever. What survived was the cache, which is a stored copy of\n // a derived fact — this project's most-repeated bug, kept alive here as a\n // display value.\n //\n // It is also a concept multi-tenancy cannot express. Revocation is a fact\n // about an (owner, site) pair; a daemon serving two sites and revoked at\n // one is not \"a revoked runner\", and a boolean on the device has nowhere to\n // put the difference. Deleting it now is what stops cloud_009 inheriting a\n // field it would have to contradict.\n}\n\n/**\n * What a routing store must do, expressed as operations — cloud_006 §3.2.\n *\n * Every method below is a **decision plus its write**, never a read the caller\n * follows with a mutation. That is the whole point, and it is the difference\n * between an interface a shared store can implement and one it cannot.\n *\n * `claim` is the specimen. It used to live in `DaemonPlane` as\n * `jobs()` → filter → mutate, which is atomic for exactly one reason: Node is\n * single-threaded and these Maps are local, so nothing runs between the read\n * and the write. Neither survives a store on a network, and\n * `packages/relay/test/two-replicas.test.ts` holds the resulting race as a\n * failing assertion.\n *\n * So the rule for anything added here: **if a caller has to read, decide, and\n * write back, the operation is in the wrong place.** Move the decision in.\n *\n * ## Why the projection does not come with it\n *\n * `claim` takes `owners: string[]` rather than a projection or a predicate.\n * A closure cannot travel to Valkey, and the projection replicates for free\n * from the control plane — so the caller collapses it with\n * `Projection.ownersRunnableBy` and hands over data the store can match on.\n * That keeps the store ignorant of consent, which is also what keeps it\n * replaceable.\n */\nexport interface ClaimInput {\n readonly runnerId: string;\n readonly owner: string;\n readonly device: PublicIdentity;\n /**\n * The (site, owner) pairs this device may run work for — cloud_009 §3.\n *\n * **One set of pairs, not a set of sites and a set of owners.** Consent\n * binds a user to a site, so the two cannot travel separately: a device\n * whose owner consented to site A, serving a roster member who consented\n * to site B, would have every element of both sets and no consented route\n * between them. Two sets multiply; consent does not.\n *\n * Built by {@link Projection.routesFor} and matched with {@link routeKey},\n * so the relay and a store in another repository agree on the encoding by\n * calling the same function rather than by both spelling it out.\n *\n * This is the collapse that lets a claim stay one operation: a predicate\n * cannot travel to a store over a network, and a set can.\n */\n readonly routes: ReadonlySet<string>;\n readonly kinds: ReadonlySet<string>;\n readonly max: number;\n readonly leaseMs: number;\n}\n\n/**\n * How a (site, owner) route is written, so two implementations agree.\n *\n * The same `\\u0000` the job key uses, for the same reason: it cannot appear\n * in a site id or an owner id, so this is a key rather than a parser.\n */\nexport const routeKey = (siteId: string, owner: string): string =>\n `${siteId}\\u0000${owner}`;\n\n/**\n * Where the store's sense of time comes from — cloud_006 §3.4.\n *\n * **The store owns its clock; callers do not pass one.** Every deadline the\n * relay decides — a lease's expiry, the `awaiting-payload` window, what a\n * sweep considers due — is now stamped by one source, and it is the same\n * source that will later stamp them for every replica.\n *\n * It used to be a parameter. `claim` took `now`, `sweep` took `now`, and each\n * plane called its own `now()` before calling in — which is fine in one\n * process and is the recurring bug the moment there are two. A lease granted\n * by a pod whose clock runs fast is short; the same lease swept by a pod whose\n * clock runs slow outlives it. Nobody is wrong and the lease has no length.\n *\n * A Valkey-backed store returns `TIME` here, so the deadline and the sweep\n * that enforces it are read from the same server. The injected clock stays for\n * tests, which is what lets them move time instead of sleeping.\n *\n * **What deliberately does not use this**: request-signature freshness. That\n * is checked against the *local* clock on purpose — it is a question about the\n * caller's clock versus this process's, `MAX_CLOCK_SKEW_MS` already tolerates\n * two minutes of disagreement, and a network round trip to timestamp every\n * inbound request would be a cost with no property behind it.\n */\nexport interface RelayStateOptions {\n readonly now?: () => number | Promise<number>;\n}\n\n/** Why a lease-scoped operation was refused, in the caller's vocabulary. */\nexport type HolderRefusal =\n \"not-found\" | \"not-holder\" | \"stale-lease\" | \"not-ready\";\n\n/**\n * In-memory routing state.\n *\n * Deliberately not durable. The skeleton proves the protocol, and the\n * production hub replaces this with the closed multi-tenant router behind the\n * same shape (cloud_004 §9). Anything a restart loses here is a job that\n * returns to its site's queue — which is the behaviour a lapsed lease already\n * has to produce, so nothing new needs to be true for this to be safe.\n */\n/**\n * A job's key: the site that published it, and the id that site chose.\n *\n * `\\u0000` cannot appear in either half, so this is a key rather than a\n * parser — cloud_009 §3, and the reason the Valkey layout uses a distinct\n * prefix rather than a suffix on the old one.\n */\nconst keyOf = (siteId: string, jobId: string): string =>\n `${siteId}\\u0000${jobId}`;\n\nexport class RelayState implements RoutingStore {\n /**\n * Jobs by **(site, id)** — cloud_009 §3.\n *\n * A job id is a site's to choose, so two sites can choose the same one.\n * Keyed by the bare id, the second site's enqueue returned the first\n * site's job (cloud_008 finding 58), and the refusal that fixed it was a\n * cross-tenant existence oracle. Keyed by the pair, the collision does not\n * exist and there is nothing to refuse.\n *\n * `\\u0000` as the separator, because a site id is a uuid and a job id is\n * whatever a site chose — including, one day, a string with a colon in it.\n * A separator that cannot appear in either half is the difference between\n * a key and a parser.\n */\n readonly #jobs = new Map<string, RoutedJob>();\n\n /**\n * Grants by lease id, so a holder-scoped call needs no site — §3.\n *\n * `takePayload`, `complete`, `releaseLeases` and `renewLeases` carry a\n * `leaseId` the relay minted, which is unique across every site. That is\n * what lets those four signatures stay as they are: the caller names the\n * grant, and the grant names the job. A daemon never has to know a site id\n * to answer for work it holds.\n */\n readonly #byLease = new Map<string, RoutedJob>();\n\n /**\n * Jobs by bare id, across sites — the refusal path.\n *\n * The lease index alone answers the happy case and gets the refusals\n * wrong: a **stale** lease finds nothing, so `LEASE_HONORED`'s \"your grant\n * ended\" becomes \"no such job\", and a daemon that was slow is told\n * something untrue about the work it was doing. Distinguishing\n * `not-found`, `not-holder` and `stale-lease` needs the job even when the\n * lease named is over, and that is what this is for.\n *\n * A list rather than a single value: two sites may choose one id, which is\n * the whole reason `#jobs` is keyed by the pair.\n */\n readonly #byJobId = new Map<string, RoutedJob[]>();\n\n /**\n * The job a holder-scoped call is about, without a site id.\n *\n * The exact grant first, and **checked against the job the caller named**:\n * a lease id belonging to another job would otherwise hand over that job's\n * payload to somebody holding a valid-looking grant. Then the same job held\n * by this runner under an older grant, which is what `stale-lease` is. Then\n * any job with that id, which is what `not-holder` is.\n */\n #grantFor(\n jobId: string,\n runnerId: string,\n leaseId: string,\n ): RoutedJob | undefined {\n const exact = this.#byLease.get(leaseId);\n if (exact?.id === jobId) return exact;\n const candidates = this.#byJobId.get(jobId) ?? [];\n return (\n candidates.find((job) => job.claimedBy?.runnerId === runnerId) ??\n candidates[0]\n );\n }\n\n #index(job: RoutedJob): void {\n const bare = this.#byJobId.get(job.id);\n if (bare) {\n if (!bare.includes(job)) bare.push(job);\n } else {\n this.#byJobId.set(job.id, [job]);\n }\n }\n\n #forget(job: RoutedJob): void {\n this.#jobs.delete(keyOf(job.siteId, job.id));\n const bare = (this.#byJobId.get(job.id) ?? []).filter((it) => it !== job);\n if (bare.length === 0) this.#byJobId.delete(job.id);\n else this.#byJobId.set(job.id, bare);\n if (job.claimedBy) this.#byLease.delete(job.claimedBy.leaseId);\n }\n readonly #presence = new Map<string, Presence>();\n readonly #now: () => number | Promise<number>;\n\n constructor(options: RelayStateOptions = {}) {\n this.#now = options.now ?? Date.now;\n }\n\n /** The one clock every deadline in this store is stamped from. */\n async now(): Promise<number> {\n return this.#now();\n }\n\n /**\n * Take a stub for routing. The payload is not here and will not be.\n *\n * **Idempotent by job id, and that is a security property rather than a\n * convenience.** Site-plane calls are authenticated by signature, and\n * byollm_009 §4.2's argument for signing the request instead of a\n * server-issued nonce rests entirely on every write being idempotent per the\n * instance it names. This one was not: re-enqueueing a known id built a\n * fresh `queued` job over the top of the old one, discarding a live claim,\n * its lease and any payload the site had already sealed to a device. A\n * replayed enqueue inside the two-minute freshness window was therefore a\n * way to yank a job back from the machine running it — the `release` bug of\n * §4.2, rediscovered on the other plane.\n *\n * So a known id returns what is already routing, unchanged. A site that\n * restarts and republishes its queue is the normal case, and it must not\n * disturb work in flight.\n */\n enqueue(input: {\n id: string;\n siteId: string;\n stub: JobStub;\n }): Promise<RoutedJob> {\n // Idempotent by (site, id). The refusal that used to live here went with\n // the collision it refused — cloud_009 §3.\n const existing = this.#jobs.get(keyOf(input.siteId, input.id));\n if (existing) return Promise.resolve(existing);\n const job: RoutedJob = {\n id: input.id,\n siteId: input.siteId,\n stub: input.stub,\n state: \"queued\",\n refusedBy: [],\n };\n this.#jobs.set(keyOf(job.siteId, job.id), job);\n this.#index(job);\n return Promise.resolve(job);\n }\n\n job(siteId: string, jobId: string): Promise<RoutedJob | undefined> {\n return Promise.resolve(this.#jobs.get(keyOf(siteId, jobId)));\n }\n\n jobs(): Promise<RoutedJob[]> {\n return Promise.resolve([...this.#jobs.values()]);\n }\n\n /** Jobs a site must seal for, right now. */\n async awaiting(siteId: string): Promise<RoutedJob[]> {\n return (await this.jobs()).filter(\n (j) => j.siteId === siteId && j.state === \"awaiting-payload\",\n );\n }\n\n /** Sealed results waiting to go home. */\n async finished(siteId: string): Promise<RoutedJob[]> {\n return (await this.jobs()).filter(\n (j) =>\n j.siteId === siteId && j.state === \"done\" && j.result !== undefined,\n );\n }\n\n /**\n * Claim work — one operation, because it has to be.\n *\n * Moved here wholesale from `DaemonPlane`, where it was a scan followed by\n * per-job mutation. Nothing about the *decision* changed; what changed is\n * that a store can now implement it, because the filter and the write are\n * one call rather than a loop the caller drives.\n *\n * The order of the guards is worth preserving as-is when this becomes a Lua\n * script: cheapest first, and `owners` last because it is the only one that\n * needed the projection.\n */\n async claim(input: ClaimInput): Promise<ClaimedStub[]> {\n const now = await this.now();\n await this.sweep();\n\n const granted: ClaimedStub[] = [];\n for (const job of this.#jobs.values()) {\n if (granted.length >= input.max) break;\n if (job.state !== \"queued\") continue;\n // The route, as one lookup, because consent is about the pair — a\n // device whose owner consented to site A, serving a roster member who\n // consented to site B, is in both a set of sites and a set of owners\n // and has no consented route between them.\n if (!input.routes.has(routeKey(job.siteId, job.stub.owner))) continue;\n if (!input.kinds.has(job.stub.kind)) continue;\n // Already declined by this device — `REFUSAL_NOT_REOFFERED`, §2.1.\n if (job.refusedBy.includes(input.runnerId)) continue;\n // Withdrawn by the site — §2.2. Cheap, and before every other check.\n if (job.cancelled) continue;\n // The relay's half of AUDIENCE_BOTH_SIDES. The daemon re-checks its own\n // allowlist and may still refuse — this only ever narrows.\n\n // `self` means the owner's own machines, and the route set cannot\n // express that — cloud_008 §2.1.\n //\n // The routes are every (site, owner) this device may run for, which for\n // a Team owner's machine includes every roster member. Correct for\n // `public` and `named`, and wrong for\n // `self`: a roster member's private job was offered to the owner's\n // daemon, which refused it locally and released it, and the relay\n // offered it straight back. The ping-pong was the visible symptom; the\n // invisible one is that `self` — the audience a user picks *because*\n // they want their own machine — was the audience the relay ignored.\n if (job.stub.audience === \"self\" && job.stub.owner !== input.owner) {\n continue;\n }\n\n // A UUID, not a readable composite. The direct plane's lease ids are\n // UUIDs and the Supabase adapter's `lease_id` column is typed `uuid`, so\n // a relay minting `lease_<job>_<time>` would route perfectly against a\n // memory store and fail the moment a real site adopted the lease.\n const leaseId = randomUUID();\n job.state = \"awaiting-payload\";\n job.claimedBy = {\n runnerId: input.runnerId,\n owner: input.owner,\n device: input.device,\n leaseId,\n leaseExpiresAt: now + input.leaseMs,\n };\n // Not the lease: this bounds how long we wait for a *site*, not how long\n // the device may work. byollm_009 §7.1's third clock.\n job.awaitingUntil = now + AWAITING_PAYLOAD_MS;\n // The grant is findable by its own id, which is how a holder-scoped\n // call needs no site — cloud_009 §3.\n this.#byLease.set(leaseId, job);\n\n granted.push({\n ...job.stub,\n lease: {\n id: leaseId,\n runnerId: input.runnerId,\n expiresAt: job.claimedBy.leaseExpiresAt,\n },\n });\n }\n return granted;\n }\n\n /**\n * Hand over the sealed payload to the device that holds the lease.\n *\n * The read and the state transition are one operation for the same reason\n * `claim` is: `running` must be set by whoever was told the envelope, or two\n * replicas can both hand out the same work and both believe they were first.\n */\n takePayload(input: {\n jobId: string;\n runnerId: string;\n leaseId: string;\n }): Promise<{ envelope: SealedEnvelope } | { refused: HolderRefusal }> {\n const job = this.#grantFor(input.jobId, input.runnerId, input.leaseId);\n if (!job) return Promise.resolve({ refused: \"not-found\" });\n if (job.claimedBy?.runnerId !== input.runnerId) {\n return Promise.resolve({ refused: \"not-holder\" });\n }\n // LEASE_HONORED per *instance*: a stale lease id names a grant that is\n // over, and answering it would hand work to a previous holder.\n if (job.claimedBy.leaseId !== input.leaseId) {\n return Promise.resolve({ refused: \"stale-lease\" });\n }\n if (!job.payload) return Promise.resolve({ refused: \"not-ready\" });\n job.state = \"running\";\n return Promise.resolve({ envelope: job.payload });\n }\n\n /**\n * Record a finished job.\n *\n * `RESULT_IDEMPOTENT` lives here rather than in the caller: a replayed\n * result must be a no-op decided by the same operation that would have\n * written it, or two replicas can both decide they were the first.\n */\n complete(input: {\n jobId: string;\n runnerId: string;\n leaseId: string;\n envelope: SealedEnvelope;\n disposition: \"ok\" | \"error\" | \"canceled\";\n }): Promise<\n | { accepted: boolean; duplicate?: boolean; state: RoutedState }\n | { refused: HolderRefusal }\n > {\n const job = this.#grantFor(input.jobId, input.runnerId, input.leaseId);\n if (!job) return Promise.resolve({ refused: \"not-found\" });\n if (job.claimedBy?.runnerId !== input.runnerId) {\n return Promise.resolve({ refused: \"not-holder\" });\n }\n // Terminal before holder — cloud_008 §3.6, and the same order in all four\n // stores.\n //\n // This file argued the opposite two days ago: that a result under a grant\n // that ended is a different device's work arriving late rather than a\n // replay, so the lease check should win. That is right for a job which is\n // **not** terminal, and the two orders only ever disagree about a *done*\n // job asked about under a stale grant — where \"already recorded\" is the\n // more useful of two true statements, and \"your lease is stale\" invents a\n // worry about an answer that is safely stored.\n //\n // The deciding argument is not comfort. byollm_009 §4's case for signing\n // requests rather than issuing nonces rests on every write being\n // idempotent per the instance it names, and on the direct plane\n // `RESULT_IDEMPOTENT` was holding only because `complete` nulls the lease\n // and the holder check tripped first. A MUST another MUST's security\n // argument leans on cannot hold by coincidence.\n //\n // Scoped to the device that finished it: anyone else falls through to the\n // holder check and gets the refusal they would get for a job that is not\n // terminal, so a job id is not a terminality probe.\n if (job.state === \"done\") {\n const sameGrant = job.claimedBy.leaseId === input.leaseId;\n return Promise.resolve(\n sameGrant\n ? { accepted: false, duplicate: true, state: job.state }\n : { refused: \"stale-lease\" },\n );\n }\n // LEASE_HONORED per instance — cloud_008 §1.4a.\n if (job.claimedBy.leaseId !== input.leaseId) {\n return Promise.resolve({ refused: \"stale-lease\" });\n }\n job.result = input.envelope;\n job.disposition = input.disposition;\n job.state = \"done\";\n return Promise.resolve({ accepted: true, state: job.state });\n }\n\n /** Give back leases this runner holds, naming each grant it means. */\n releaseLeases(input: {\n runnerId: string;\n leases: readonly { jobId: string; leaseId: string }[];\n reason?: ReleaseReason;\n }): Promise<string[]> {\n const released: string[] = [];\n for (const { jobId, leaseId } of input.leases) {\n // Through the grant, like every other holder-scoped operation — the\n // caller names a lease and this store no longer keys jobs by a bare id.\n const job = this.#grantFor(jobId, input.runnerId, leaseId);\n if (!job || job.claimedBy?.runnerId !== input.runnerId) continue;\n if (job.claimedBy.leaseId !== leaseId) continue;\n // Recorded before the requeue, so the job goes back to the queue\n // already knowing not to come back here. A daemon releasing for\n // `shutdown` or `backend-down` is saying \"not now\"; `refused` is the\n // only one that means \"not me, ever\" — the others must stay claimable\n // by the same device or a restart would strand its own work.\n if (\n input.reason === \"refused\" &&\n !job.refusedBy.includes(input.runnerId)\n ) {\n job.refusedBy.push(input.runnerId);\n }\n this.#requeue(job);\n released.push(jobId);\n }\n return Promise.resolve(released);\n }\n\n /**\n * Take a site's sealed payload for a claimed job.\n *\n * Refuses anything not `awaiting-payload`, which is what makes the timeout\n * mean something: a late seal must not land on a claim that has moved.\n */\n seal(input: {\n jobId: string;\n siteId: string;\n envelope: SealedEnvelope;\n }): Promise<\n | { state: RoutedState }\n | { refused: \"not-found\" | \"too-late\"; was?: RoutedState }\n > {\n const job = this.#jobs.get(keyOf(input.siteId, input.jobId));\n if (job?.siteId !== input.siteId) {\n return Promise.resolve({ refused: \"not-found\" });\n }\n if (job.state !== \"awaiting-payload\") {\n return Promise.resolve({ refused: \"too-late\", was: job.state });\n }\n job.payload = input.envelope;\n job.state = \"ready\";\n delete job.awaitingUntil;\n return Promise.resolve({ state: job.state });\n }\n\n /** {@link RoutingStore.cancel} — the site withdraws a job. */\n cancel(input: { jobId: string; siteId: string }): Promise<boolean> {\n const job = this.#jobs.get(keyOf(input.siteId, input.jobId));\n // Scoped to the caller's site for the same reason every other site-plane\n // operation is: a site must not be able to cancel somebody else's work by\n // guessing an id.\n if (job?.siteId !== input.siteId) return Promise.resolve(false);\n job.cancelled = true;\n // Not deleted, and not requeued. If a device holds it, that device has to\n // hear about it — which is what `cancelRequests` below is for.\n return Promise.resolve(true);\n }\n\n /** {@link RoutingStore.cancelRequests} — cancelled jobs this runner holds. */\n cancelRequests(runnerId: string): Promise<string[]> {\n return Promise.resolve(\n [...this.#jobs.values()]\n .filter(\n (job) =>\n job.cancelled === true && job.claimedBy?.runnerId === runnerId,\n )\n .map((job) => job.id),\n );\n }\n\n /** {@link RoutingStore.renewLeases} — extend what is still held, name what is not. */\n async renewLeases(input: {\n runnerId: string;\n leases: readonly { jobId: string; leaseId: string }[];\n leaseMs: number;\n }): Promise<{\n renewed: { jobId: string; expiresAt: number }[];\n lost: string[];\n }> {\n // The store's clock, not the caller's — cloud_006 §3.4. A lease extended\n // against one replica's `Date.now()` and swept against another's is a\n // lease with no length.\n const now = await this.now();\n const renewed: { jobId: string; expiresAt: number }[] = [];\n const lost: string[] = [];\n\n for (const { jobId, leaseId } of input.leases) {\n const job = this.#grantFor(jobId, input.runnerId, leaseId);\n const held = job?.claimedBy;\n // The lease id *and* the runner. A lease id is a UUID so the runner\n // check is belt and braces, but \"this grant, held by you\" is the\n // sentence every other operation in this file checks, and a renewal is\n // the one that extends a hold rather than ending it.\n if (\n !job ||\n held?.leaseId !== leaseId ||\n held.runnerId !== input.runnerId\n ) {\n lost.push(jobId);\n continue;\n }\n // Replaced rather than mutated: the grant is a readonly record, which\n // is what stops any other operation here from quietly extending it.\n const expiresAt = now + input.leaseMs;\n job.claimedBy = { ...held, leaseExpiresAt: expiresAt };\n renewed.push({ jobId, expiresAt });\n }\n\n // `awaitingUntil` is deliberately untouched. That clock bounds how long we\n // wait for a *site* to seal, and a busy device has no bearing on it —\n // byollm_009 §7.1's third clock stays third.\n return { renewed, lost };\n }\n\n async seen(presence: Omit<Presence, \"lastSeenAt\">): Promise<Presence> {\n const lastSeenAt = await this.now();\n const existing = this.#presence.get(presence.runnerId);\n if (existing) {\n existing.lastSeenAt = lastSeenAt;\n return existing;\n }\n const fresh: Presence = { ...presence, lastSeenAt };\n this.#presence.set(presence.runnerId, fresh);\n return fresh;\n }\n\n presence(runnerId: string): Promise<Presence | undefined> {\n return Promise.resolve(this.#presence.get(runnerId));\n }\n\n everyone(): Promise<Presence[]> {\n return Promise.resolve([...this.#presence.values()]);\n }\n\n /**\n * Return a job to the queue, forgetting the claim.\n *\n * The stub survives; nothing is lost. That is `LEASE_RECLAIMABLE` and it is\n * why the awaiting-payload timeout is cheap to fire: the worst case is that\n * a device did nothing for ten seconds and another one gets a turn.\n */\n #requeue(job: RoutedJob): void {\n job.state = \"queued\";\n // The grant ended; the index that names it must end with it, or a stale\n // lease id resolves to a job it no longer holds.\n if (job.claimedBy) this.#byLease.delete(job.claimedBy.leaseId);\n delete job.claimedBy;\n delete job.awaitingUntil;\n delete job.payload;\n }\n\n /**\n * Fire whatever the clock says is due, and report it.\n *\n * Returns the jobs it requeued so a caller can log or surface them — a\n * timeout that fires invisibly is indistinguishable from a job that was\n * never claimed, and those want very different debugging.\n */\n async sweep(): Promise<RoutedJob[]> {\n const now = await this.now();\n const requeued: RoutedJob[] = [];\n const expired: RoutedJob[] = [];\n for (const job of this.#jobs.values()) {\n // Past its deadline — cloud_008 §2.2, and `TTL_EXPIRY` on this plane.\n //\n // The relay never read `stub.deadlineAt`. Not \"read it and got the\n // arithmetic wrong\": the field travelled on every stub, byollm_009 §6\n // describes it as the bound on how long a ciphertext is worth carrying,\n // and nothing here ever looked at it. A job whose deadline passed went\n // on being offered to devices forever, and its sealed payload sat in\n // the relay for as long as the process lived.\n //\n // Dropped rather than marked terminal. The relay is a router and the\n // site holds the authoritative record; a stub nobody may run is not\n // routing state, and keeping a tombstone would be keeping the ciphertext\n // with it. A daemon mid-flight learns through `renewLeases`, which\n // reports a job the store no longer holds as `lost` — the path that\n // already exists for a lease that ended.\n if (job.stub.deadlineAt <= now) {\n this.#forget(job);\n expired.push(job);\n continue;\n }\n if (job.state === \"awaiting-payload\" && (job.awaitingUntil ?? 0) <= now) {\n this.#requeue(job);\n requeued.push(job);\n }\n const lease = job.claimedBy;\n if (\n lease &&\n (job.state === \"ready\" || job.state === \"running\") &&\n lease.leaseExpiresAt <= now\n ) {\n this.#requeue(job);\n requeued.push(job);\n }\n }\n // Both, because a caller that logs \"requeued\" and never mentions expiry\n // would report a shrinking queue with no reason for it.\n return [...requeued, ...expired];\n }\n}\n"],"mappings":"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","names":[]}
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { R as RoutingStore } from './store-C_NtPAvT.js';
2
- export { A as AWAITING_PAYLOAD_MS, C as ClaimInput, H as HolderRefusal, P as Presence, a as RelayState, b as ReleaseReason, c as RoutedJob, d as RoutedState } from './store-C_NtPAvT.js';
1
+ import { R as RoutingStore } from './store-Dh5ukn3Y.js';
2
+ export { A as AWAITING_PAYLOAD_MS, C as ClaimInput, H as HolderRefusal, P as Presence, a as RelayState, b as ReleaseReason, c as RoutedJob, d as RoutedState } from './store-Dh5ukn3Y.js';
3
3
  import { z } from 'zod';
4
4
  import '@byollm/protocol';
5
5
 
@@ -206,6 +206,33 @@ declare class Projection {
206
206
  * paused user would quietly start routing again.
207
207
  */
208
208
  consentFor(owner: string, siteId: string): ConsentRecord | null;
209
+ /**
210
+ * Every site this owner may route with — cloud_009 §3.
211
+ *
212
+ * The set a pairing covers, and the set a claim will filter on. Consent
213
+ * decides it, which is the sentence the whole design rests on: a site
214
+ * appears here because a human clicked, never because a site asked to be
215
+ * here and never because a daemon named it.
216
+ *
217
+ * **Paused sites are here, and that is deliberate** — cloud_008 finding 48
218
+ * as ratified. A paused consent routes nothing and keeps its pin: the
219
+ * relationship stands, the key the daemon compared a fingerprint of stays
220
+ * pinned, and re-consenting never costs a re-pair. Written the other way
221
+ * round first, and three of the paused tests failed by refusing to pair at
222
+ * all — which is the trap the finding is about, arriving through the door
223
+ * marked "be stricter".
224
+ *
225
+ * So this is the *pairing* set and `mayRouteFor` is the *routing* set. Two
226
+ * questions with different answers, kept apart for the same reason
227
+ * `consentFor` and `mayRouteFor` are: one method answering both is how a
228
+ * paused user quietly starts routing again, or quietly loses their machine.
229
+ *
230
+ * Sorted by site id so two calls with the same projection produce the same
231
+ * answer: this ends up in a pairings file and in a fingerprint list a human
232
+ * compares by eye, and an order that drifts between polls is a diff nobody
233
+ * can read.
234
+ */
235
+ sitesFor(owner: string): SiteRecord[];
209
236
  /**
210
237
  * May this owner's work move for this site, right now?
211
238
  *
@@ -217,25 +244,29 @@ declare class Projection {
217
244
  /** Whether this pair is consented and paused — what heartbeat reports. */
218
245
  pausedFor(owner: string, siteId: string): boolean;
219
246
  /**
220
- * Whose work this device may run for this site the set a claim carries.
247
+ * Every (site, owner) route this device may run — cloud_009 §3.
221
248
  *
222
- * {@link Projection.ownersRunnableBy} collapsed and then filtered by
223
- * consent, which is two things this relay was doing in one place and one
224
- * place respectively:
249
+ * The claim filter, collapsed to data a store can match on. `routableOwners`
250
+ * was this for one site; the hub needs it for the set, and the shape had to
251
+ * change rather than repeat, because **a set of sites and a set of owners
252
+ * multiply**. A device whose owner consented to site A, serving a roster
253
+ * member who consented to site B, appears in both sets and has no consented
254
+ * route between them. Pairs cannot express a route nobody agreed to.
225
255
  *
226
- * - **The device's own owner is checked first**, and an unroutable one
227
- * empties the whole set. A machine whose owner has not agreed to this
228
- * site's current terms runs nothing for it, including a roster member's
229
- * work: the roster says whose jobs may land here, and consent says
230
- * whether this machine is available to the site at all.
231
- * - **Every job owner is checked too.** That check did not exist. Consent
232
- * was enforced by the daemon plane's blanket revoked guard, which asks
233
- * only about the *claiming* device's owner so a roster member who never
234
- * consented to a site could have their work claimed by their admin's
235
- * machine, which is `CONSENT_BEFORE_ROUTE` read the other way round. The
236
- * site plane does not check consent at enqueue either, so nothing did.
256
+ * Both halves of the rule are here, and neither was enforced before finding
257
+ * 48's work:
258
+ *
259
+ * - **This machine's owner** must have a live consent for the site, or
260
+ * nothing of that site's runs here at all including a roster member's
261
+ * work. The roster says whose jobs may land on this machine; consent says
262
+ * whether this machine is available to that site.
263
+ * - **Each job's owner** must have one too. That check did not exist:
264
+ * consent was enforced by the daemon plane's blanket revoked guard, which
265
+ * asks only about the claiming device's owner, so a roster member who
266
+ * never consented to a site could have their work claimed by their admin's
267
+ * machine — `CONSENT_BEFORE_ROUTE` read the other way round.
237
268
  */
238
- routableOwners(deviceOwner: string, siteId: string): string[];
269
+ routesFor(deviceOwner: string): Set<string>;
239
270
  /**
240
271
  * Every owner whose work this device's owner may run, as a list.
241
272
  *
@@ -304,7 +335,6 @@ interface RelayOptions {
304
335
  * One, in the skeleton. Multi-tenant routing is the closed piece
305
336
  * (cloud_004 §9), and it replaces this field rather than extending it.
306
337
  */
307
- readonly siteId: string;
308
338
  /** Consent and rosters, projected from the control plane. */
309
339
  readonly fixture?: RelayFixture;
310
340
  /** How long a claim is good for. */
@@ -313,6 +343,27 @@ interface RelayOptions {
313
343
  readonly now?: () => number;
314
344
  /** Where the daemon plane is mounted. */
315
345
  readonly basePath?: string;
346
+ /**
347
+ * Serve `/debug`, which is off unless somebody asks for it.
348
+ *
349
+ * The page shows every routed job for a site, its state, who claimed it and
350
+ * how long its timers have left. It shows no prompt or result text — the
351
+ * relay does not have them — and it is genuinely useful when a route is
352
+ * behaving strangely.
353
+ *
354
+ * It is also, on anything reachable from the internet, an anonymous read of
355
+ * exactly the metadata the site plane exists to protect. That was finding
356
+ * eleven, found by curling a deployed hub. The hub refuses the route
357
+ * outright; this package used to serve it by default and leave `D005` to
358
+ * warn whoever deployed it, which is a default that fails safe only if
359
+ * somebody reads the audit.
360
+ *
361
+ * So: off, and per-site when on (cloud_009 §3 — the debug page is per-site
362
+ * or it is nothing). `D005` still fails for a relay that turned it on,
363
+ * which is the audit doing its job for an operator who made a choice rather
364
+ * than warning everybody about a default.
365
+ */
366
+ readonly debug?: boolean;
316
367
  /**
317
368
  * Where routing state lives — cloud_006.
318
369
  *