@byollm/relay 0.1.0-alpha.9 → 0.1.0-alpha.91

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.
@@ -0,0 +1,771 @@
1
+ import { PublicIdentity, CapabilityMatrix, WithheldKind, JobStub, SealedEnvelope, ClaimedStub } from '@byollm/protocol';
2
+
3
+ /**
4
+ * The relay's routing state — byollm_009 §7, reachable at last.
5
+ *
6
+ * §7 described a state machine the direct plane could not produce. There, the
7
+ * site and the upstream are the same party: it seals when it likes, and a job
8
+ * is never claimed-but-unsealed. Here they are different parties, and the gap
9
+ * between them is a state:
10
+ *
11
+ * ```
12
+ * queued ──claim──▶ awaiting-payload ──sealed──▶ ready ──fetch──▶ running
13
+ * ▲ │ │
14
+ * └────────────────────┘ ▼
15
+ * site never seals, or seals too late ok | error | canceled
16
+ * ```
17
+ *
18
+ * The relay cannot seal, so it cannot shortcut this. A payload is encrypted
19
+ * to *the device that claimed it*, and nobody knows which device that is until
20
+ * the claim happens — which is precisely why claim-then-fetch makes a blind
21
+ * relay possible at all. The window is the price.
22
+ *
23
+ * ## What the relay holds, and what it cannot
24
+ *
25
+ * Stubs (metadata the site chose to publish), sealed envelopes it cannot open,
26
+ * and public keys. There is no field on any type in this file that could hold
27
+ * a private key or a plaintext, which is `RELAY_BLIND` expressed as a data
28
+ * model rather than as a policy.
29
+ */
30
+ /** Where a routed job is. */
31
+ type RoutedState = "queued" | "awaiting-payload" | "ready" | "running" | "done";
32
+ /**
33
+ * How long a site has to seal after one of its jobs is claimed.
34
+ *
35
+ * **Distinct from the lease, and distinct from the job's TTL** — byollm_009
36
+ * §7.1. Three clocks, three different questions:
37
+ *
38
+ * - the **TTL** asks how long the work is worth doing at all;
39
+ * - the **lease** asks how long this device gets to run it;
40
+ * - this asks how long we wait for a site that has gone away.
41
+ *
42
+ * Collapsing any pair of them looks harmless until a site restarts during a
43
+ * deploy: with only a lease, the device sits politely holding a job whose
44
+ * payload will never arrive, and the lease's whole minute is spent waiting on
45
+ * a party that is not coming back. Short, because a site that is up answers in
46
+ * milliseconds and a site that is down will not answer sooner for waiting.
47
+ */
48
+ declare const AWAITING_PAYLOAD_MS = 10000;
49
+ /**
50
+ * How many devices wait for a payload before the hub gives up on the job —
51
+ * B042.
52
+ *
53
+ * Three, matching the daemon's own patience, and for the same reason: a site
54
+ * that is slow, restarting, or briefly unreachable deserves more than one
55
+ * chance, and a site that is gone should not cost the whole fleet a turn
56
+ * each. At {@link AWAITING_PAYLOAD_MS} apiece this is thirty seconds of
57
+ * waiting before a job that nobody can complete is dropped.
58
+ *
59
+ * Dropped rather than marked terminal, exactly as a passed deadline is: the
60
+ * relay is a router, the site holds the authoritative record, and a stub
61
+ * nobody may run is not routing state. A daemon mid-flight learns through
62
+ * `renewLeases`, which reports a job the store no longer holds as `lost`.
63
+ */
64
+ /**
65
+ * How many grants one (site, owner) pair may hold UNSEALED at once — B187,
66
+ * ruled 09-16.
67
+ *
68
+ * A site must be told which device claimed its job, because it seals the
69
+ * payload to that device's public key — that is what lets a blind relay
70
+ * exist at all. **The leak is the ratio, not the key**: nothing required a
71
+ * site to complete a single job, so one that enqueued a thousand and sealed
72
+ * none read a thousand device identities inside one
73
+ * {@link AWAITING_PAYLOAD_MS} window.
74
+ *
75
+ * Keyed on the PAIR rather than on the site, and that is load-bearing twice
76
+ * over. A flat per-site cap would throttle a real product to two in-flight
77
+ * jobs across all its users. And the pair is already how consent is modelled
78
+ * here — `routeKey(siteId, owner)` — so the cap is bounded by the same unit
79
+ * the permission is.
80
+ *
81
+ * **It buys time, not secrecy.** Which device claims is deliberately random
82
+ * (B194: ties are luck), so a patient site still samples a small fleet over
83
+ * minutes. That is the point: minutes leave a signature, and
84
+ * {@link RelayStateOptions.onUnsealedChurn} is what reads it.
85
+ */
86
+ declare const UNSEALED_PER_PAIR = 2;
87
+ declare const SEAL_ATTEMPTS_BEFORE_EVICTION = 3;
88
+ /** A job the relay is routing. Metadata and ciphertext, nothing else. */
89
+ /** Why a daemon gave a job back. Only `refused` means "not me, ever". */
90
+ type ReleaseReason = "shutdown" | "pause" | "revoked" | "backend-down" | "refused";
91
+ interface RoutedJob {
92
+ readonly id: string;
93
+ /** Which site enqueued it — the party that will be asked to seal. */
94
+ readonly siteId: string;
95
+ /**
96
+ * Everything the relay knows about the work, which is everything the site
97
+ * chose to publish and not one field more (byollm_009 §6).
98
+ */
99
+ readonly stub: JobStub;
100
+ state: RoutedState;
101
+ /** Set from the claim; the site seals to these keys. */
102
+ claimedBy?: {
103
+ readonly runnerId: string;
104
+ readonly owner: string;
105
+ readonly device: PublicIdentity;
106
+ readonly leaseId: string;
107
+ readonly leaseExpiresAt: number;
108
+ };
109
+ /** When {@link AWAITING_PAYLOAD_MS} runs out for this claim. */
110
+ awaitingUntil?: number;
111
+ /**
112
+ * How many devices have waited for this payload and not received it — B042.
113
+ *
114
+ * A job whose site never seals is offered, waited on, requeued, and offered
115
+ * again — to a different device each time. Every one of them does the same
116
+ * ten seconds of nothing, and none of them can tell that the last one
117
+ * already tried: the daemon's own patience is per-device by construction,
118
+ * so the fleet works through itself one machine at a time until the job's
119
+ * deadline, which can be an hour away.
120
+ *
121
+ * That is the poison at its source. **The daemon-side breaker (B041) stops
122
+ * one device looping; only the hub can stop the job.** Counted here rather
123
+ * than inferred from `awaitingUntil`, because a requeue clears that clock
124
+ * and the count has to survive it.
125
+ */
126
+ sealAttempts?: number;
127
+ /**
128
+ * The device an ABANDONED grant goes back to — B187's sticky re-offer,
129
+ * ruled 09-16.
130
+ *
131
+ * Set when an unsealed grant lapses, not at claim: a job nobody abandoned
132
+ * is free work for whoever asks, and that is the routing this product
133
+ * wants. Once a site has taken a grant and let it rot, the job is offered
134
+ * only to the device that already answered for it.
135
+ *
136
+ * **This is what turns "slower and visible" into "closed".** The cap bounds
137
+ * the rate; the churn report makes a walk noticeable; this removes the walk
138
+ * itself, because abandoning the same job repeatedly now reveals ONE
139
+ * identity instead of a fresh sample each window.
140
+ *
141
+ * Survives `#requeue` for the same reason `sealAttempts` does — the requeue
142
+ * is the thing it has to outlive.
143
+ *
144
+ * **The accepted edge (Todd, 09-16): a device that never comes back takes
145
+ * the job with it.** It is still evicted after
146
+ * {@link SEAL_ATTEMPTS_BEFORE_EVICTION}, so the queue does not fill with
147
+ * work nobody can reach; the site simply has to enqueue it again.
148
+ */
149
+ reofferTo?: string;
150
+ /**
151
+ * Runners that released this job with reason `refused` — cloud_008 §2.1.
152
+ *
153
+ * `REFUSAL_NOT_REOFFERED`, which the relay did not implement: it dropped
154
+ * `ReleaseRequest.reason` on the floor. The field's own docstring says why
155
+ * that is not cosmetic — an upstream cannot evaluate a daemon's *local*
156
+ * `named` allowlist, so it may legitimately offer work the daemon then
157
+ * declines, and without a record the two spin between claim and release
158
+ * forever. The direct plane has always kept this list.
159
+ */
160
+ refusedBy: string[];
161
+ /**
162
+ * Runners that may not be offered this job again *yet*, and from when.
163
+ *
164
+ * The middle ground {@link RoutingStore.releaseLeases} had no way to say.
165
+ * `refusedBy` is forever and a bare release is immediate; a control plane
166
+ * declining a job for a reason the world can change — an unfilled mapping
167
+ * slot, a resolution that named another machine, a store that was briefly
168
+ * unreachable — means neither. It means "ask again later", and later needs
169
+ * a number.
170
+ *
171
+ * Keyed by runner because it is a fact about a pairing, not about the job:
172
+ * the same job goes to another device immediately, which is the whole
173
+ * point of not marking it refused.
174
+ */
175
+ retryAfter?: Record<string, number>;
176
+ /**
177
+ * The site withdrew this job — cloud_008 §2.2.
178
+ *
179
+ * A flag rather than a state, because a cancelled job that a device is
180
+ * *running* is not finished: the daemon has to be told, abort its backend
181
+ * call and report `canceled`, and the ordinary `complete` path then closes
182
+ * it. Making it a state would strand the in-flight case between two
183
+ * machines' ideas of what happened.
184
+ */
185
+ cancelled?: boolean;
186
+ /** Sealed to the claiming device by the site. Opaque here. */
187
+ payload?: SealedEnvelope;
188
+ /** Sealed to the site by the device. Opaque here. */
189
+ result?: SealedEnvelope;
190
+ /**
191
+ * The result's clear-text discriminator — byollm_009 §6.1.
192
+ *
193
+ * The one outcome fact the relay is given, and the reason it is given:
194
+ * without it the relay cannot stop dispatching a finished job. A routing
195
+ * hint and never a fact — the *site* verifies it against the sealed
196
+ * outcome, because only the site can open the envelope. The relay acts on
197
+ * it and is entitled to be wrong; a lying daemon costs it a dispatch
198
+ * decision, not a security property.
199
+ */
200
+ disposition?: "ok" | "error" | "canceled";
201
+ }
202
+ /** A device the relay has seen recently. */
203
+ interface Presence {
204
+ readonly runnerId: string;
205
+ readonly owner: string;
206
+ readonly device: PublicIdentity;
207
+ lastSeenAt: number;
208
+ /**
209
+ * What this machine last said it can run — cloud_009, 2026-08-24.
210
+ *
211
+ * **Capabilities are presence data.** They arrive on the same heartbeat as
212
+ * everything else here, they go stale at the same moment and for the same
213
+ * reason, and a machine that stops heartbeating has not stopped being able
214
+ * to run Llama — it has stopped being somewhere we can ask. Keeping them
215
+ * anywhere else would put capability truth outside the interface that owns
216
+ * presence, and every next consumer (a member's usable set, a dashboard's
217
+ * pulse, a degraded-state banner) would re-derive it from a different
218
+ * place. That is the two-owners shape this codebase keeps paying for.
219
+ *
220
+ * Empty is a real answer, not a missing one: it is a paired machine with no
221
+ * healthy backend, which is a legal state the whole connect-first ruling
222
+ * exists to make visible rather than refuse.
223
+ */
224
+ capabilities: CapabilityMatrix;
225
+ /**
226
+ * Kinds this machine is deliberately *not* advertising — byollm_016.
227
+ *
228
+ * Presence data for the same reason capabilities are, and stored beside
229
+ * them rather than derived: two services answering one kind with no
230
+ * `defaults` entry is a state only the daemon can see, and the hub cannot
231
+ * reconstruct it from the matrix — an absent kind and a withheld kind look
232
+ * identical there. Without this field the owner's page can say "nothing
233
+ * serves llm.generate", which is true and useless, instead of "two services
234
+ * answer it and you have not chosen", which is the sentence that ends with
235
+ * the owner doing something.
236
+ *
237
+ * Empty is the normal answer, and means every kind resolved.
238
+ */
239
+ withheld: readonly WithheldKind[];
240
+ }
241
+ /**
242
+ * What a routing store must do, expressed as operations — cloud_006 §3.2.
243
+ *
244
+ * Every method below is a **decision plus its write**, never a read the caller
245
+ * follows with a mutation. That is the whole point, and it is the difference
246
+ * between an interface a shared store can implement and one it cannot.
247
+ *
248
+ * `claim` is the specimen. It used to live in `DaemonPlane` as
249
+ * `jobs()` → filter → mutate, which is atomic for exactly one reason: Node is
250
+ * single-threaded and these Maps are local, so nothing runs between the read
251
+ * and the write. Neither survives a store on a network, and
252
+ * `packages/relay/test/two-replicas.test.ts` holds the resulting race as a
253
+ * failing assertion.
254
+ *
255
+ * So the rule for anything added here: **if a caller has to read, decide, and
256
+ * write back, the operation is in the wrong place.** Move the decision in.
257
+ *
258
+ * ## Why the projection does not come with it
259
+ *
260
+ * `claim` takes `owners: string[]` rather than a projection or a predicate.
261
+ * A closure cannot travel to Valkey, and the projection replicates for free
262
+ * from the control plane — so the caller collapses it with
263
+ * `Projection.ownersRunnableBy` and hands over data the store can match on.
264
+ * That keeps the store ignorant of consent, which is also what keeps it
265
+ * replaceable.
266
+ */
267
+ interface ClaimInput {
268
+ readonly runnerId: string;
269
+ readonly owner: string;
270
+ readonly device: PublicIdentity;
271
+ /**
272
+ * The (site, owner) pairs this device may run work for — cloud_009 §3.
273
+ *
274
+ * **One set of pairs, not a set of sites and a set of owners.** Consent
275
+ * binds a user to a site, so the two cannot travel separately: a device
276
+ * whose owner consented to site A, serving a roster member who consented
277
+ * to site B, would have every element of both sets and no consented route
278
+ * between them. Two sets multiply; consent does not.
279
+ *
280
+ * Built by {@link Projection.routesFor} and matched with {@link routeKey},
281
+ * so the relay and a store in another repository agree on the encoding by
282
+ * calling the same function rather than by both spelling it out.
283
+ *
284
+ * This is the collapse that lets a claim stay one operation: a predicate
285
+ * cannot travel to a store over a network, and a set can.
286
+ */
287
+ readonly routes: ReadonlySet<string>;
288
+ /**
289
+ * Kinds this device can run — and that is now the whole of the match.
290
+ *
291
+ * A relay routes by kind and by consent; **which service answers is not its
292
+ * question**. A job names a purpose, a person's mapping names a service,
293
+ * and a control plane joins them at claim. A relay that filtered on the
294
+ * service would need the mapping, which is the one thing it is not supposed
295
+ * to hold.
296
+ *
297
+ * This carried a companion, `serves`, holding the (kind, service) pairs a
298
+ * device advertised, so a job naming a service reached only a device with
299
+ * it. Sites stopped naming services (Amendment L) and the field became wire
300
+ * nothing read — so it is gone rather than left for a reader to infer
301
+ * meaning from.
302
+ *
303
+ * The cost is stated plainly: a job may be offered to a device whose owner
304
+ * admits the person but whose machine their mapping did not name. The
305
+ * control plane declines it as not-here and it goes back with a
306
+ * {@link RETRY_AFTER_MS} wait, which is what that mechanism is for.
307
+ */
308
+ readonly kinds: ReadonlySet<string>;
309
+ readonly max: number;
310
+ readonly leaseMs: number;
311
+ }
312
+ /**
313
+ * How a (site, owner) route is written, so two implementations agree.
314
+ *
315
+ * The same `\u0000` the job key uses, for the same reason: it cannot appear
316
+ * in a site id or an owner id, so this is a key rather than a parser.
317
+ */
318
+ declare const routeKey: (siteId: string, owner: string) => string;
319
+ /**
320
+ * Where the store's sense of time comes from — cloud_006 §3.4.
321
+ *
322
+ * **The store owns its clock; callers do not pass one.** Every deadline the
323
+ * relay decides — a lease's expiry, the `awaiting-payload` window, what a
324
+ * sweep considers due — is now stamped by one source, and it is the same
325
+ * source that will later stamp them for every replica.
326
+ *
327
+ * It used to be a parameter. `claim` took `now`, `sweep` took `now`, and each
328
+ * plane called its own `now()` before calling in — which is fine in one
329
+ * process and is the recurring bug the moment there are two. A lease granted
330
+ * by a pod whose clock runs fast is short; the same lease swept by a pod whose
331
+ * clock runs slow outlives it. Nobody is wrong and the lease has no length.
332
+ *
333
+ * A Valkey-backed store returns `TIME` here, so the deadline and the sweep
334
+ * that enforces it are read from the same server. The injected clock stays for
335
+ * tests, which is what lets them move time instead of sleeping.
336
+ *
337
+ * **What deliberately does not use this**: request-signature freshness. That
338
+ * is checked against the *local* clock on purpose — it is a question about the
339
+ * caller's clock versus this process's, `MAX_CLOCK_SKEW_MS` already tolerates
340
+ * two minutes of disagreement, and a network round trip to timestamp every
341
+ * inbound request would be a cost with no property behind it.
342
+ */
343
+ /**
344
+ * How often a pair may burn unsealed grants before it is worth a human's
345
+ * attention — B187's other half, ruled 09-16.
346
+ *
347
+ * **The cap buys time; it does not buy secrecy.** Which device claims is
348
+ * deliberately random (B194: ties are luck), so a patient site still samples a
349
+ * small fleet over minutes. You cannot cap your way out of a slow walk — but
350
+ * you can notice one, and this is the shape to notice: **an honest site never
351
+ * gets here at all**, because it seals in about a round trip and the grant
352
+ * never reaches {@link AWAITING_PAYLOAD_MS}.
353
+ *
354
+ * Six in five minutes is comfortably above the noise a flaky network makes and
355
+ * far below what a walk produces — a pair burning its {@link
356
+ * UNSEALED_PER_PAIR} slots every ten seconds passes this inside a minute.
357
+ */
358
+ declare const CHURN_EXPIRIES = 6;
359
+ declare const CHURN_WINDOW_MS: number;
360
+ /** One report per pair per hour. An alert that repeats is an alert nobody reads. */
361
+ declare const CHURN_QUIET_MS: number;
362
+ /** A pair burning grants without sealing them — B187. */
363
+ interface UnsealedChurn {
364
+ readonly siteId: string;
365
+ readonly owner: string;
366
+ /** How many unsealed grants expired inside the window. */
367
+ readonly expiries: number;
368
+ readonly windowMs: number;
369
+ }
370
+ interface RelayStateOptions {
371
+ readonly now?: () => number | Promise<number>;
372
+ /**
373
+ * Told when a pair keeps claiming and not sealing — B187.
374
+ *
375
+ * A callback rather than an email, because this package is the open-source
376
+ * relay: where a report GOES is a deployment's business, and `support@` is
377
+ * one deployment's address. The hub wires this to mail; a self-hoster wires
378
+ * it to whatever they read.
379
+ *
380
+ * Deliberately minimal — scaffolding until the signal system replaces it.
381
+ */
382
+ readonly onUnsealedChurn?: (churn: UnsealedChurn) => void;
383
+ }
384
+ /** Why a lease-scoped operation was refused, in the caller's vocabulary. */
385
+ type HolderRefusal = "not-found" | "not-holder" | "stale-lease" | "not-ready"
386
+ /** The job already ended — V1-6. A replay must not reopen it. */
387
+ | "terminal";
388
+ declare class RelayState implements RoutingStore {
389
+ #private;
390
+ constructor(options?: RelayStateOptions);
391
+ /** The one clock every deadline in this store is stamped from. */
392
+ now(): Promise<number>;
393
+ /**
394
+ * Take a stub for routing. The payload is not here and will not be.
395
+ *
396
+ * **Idempotent by job id, and that is a security property rather than a
397
+ * convenience.** Site-plane calls are authenticated by signature, and
398
+ * byollm_009 §4.2's argument for signing the request instead of a
399
+ * server-issued nonce rests entirely on every write being idempotent per the
400
+ * instance it names. This one was not: re-enqueueing a known id built a
401
+ * fresh `queued` job over the top of the old one, discarding a live claim,
402
+ * its lease and any payload the site had already sealed to a device. A
403
+ * replayed enqueue inside the two-minute freshness window was therefore a
404
+ * way to yank a job back from the machine running it — the `release` bug of
405
+ * §4.2, rediscovered on the other plane.
406
+ *
407
+ * So a known id returns what is already routing, unchanged. A site that
408
+ * restarts and republishes its queue is the normal case, and it must not
409
+ * disturb work in flight.
410
+ */
411
+ enqueue(input: {
412
+ id: string;
413
+ siteId: string;
414
+ stub: JobStub;
415
+ }): Promise<RoutedJob>;
416
+ job(siteId: string, jobId: string): Promise<RoutedJob | undefined>;
417
+ jobs(): Promise<RoutedJob[]>;
418
+ /** Jobs a site must seal for, right now. */
419
+ awaiting(siteId: string): Promise<RoutedJob[]>;
420
+ /** Sealed results waiting to go home. */
421
+ finished(siteId: string): Promise<RoutedJob[]>;
422
+ /**
423
+ * Claim work — one operation, because it has to be.
424
+ *
425
+ * Moved here wholesale from `DaemonPlane`, where it was a scan followed by
426
+ * per-job mutation. Nothing about the *decision* changed; what changed is
427
+ * that a store can now implement it, because the filter and the write are
428
+ * one call rather than a loop the caller drives.
429
+ *
430
+ * The order of the guards is worth preserving as-is when this becomes a Lua
431
+ * script: cheapest first, and `owners` last because it is the only one that
432
+ * needed the projection.
433
+ */
434
+ claim(input: ClaimInput): Promise<ClaimedStub[]>;
435
+ /**
436
+ * Hand over the sealed payload to the device that holds the lease.
437
+ *
438
+ * The read and the state transition are one operation for the same reason
439
+ * `claim` is: `running` must be set by whoever was told the envelope, or two
440
+ * replicas can both hand out the same work and both believe they were first.
441
+ */
442
+ takePayload(input: {
443
+ jobId: string;
444
+ runnerId: string;
445
+ leaseId: string;
446
+ }): Promise<{
447
+ envelope: SealedEnvelope;
448
+ } | {
449
+ refused: HolderRefusal;
450
+ }>;
451
+ /**
452
+ * Record a finished job.
453
+ *
454
+ * `RESULT_IDEMPOTENT` lives here rather than in the caller: a replayed
455
+ * result must be a no-op decided by the same operation that would have
456
+ * written it, or two replicas can both decide they were the first.
457
+ */
458
+ complete(input: {
459
+ jobId: string;
460
+ runnerId: string;
461
+ leaseId: string;
462
+ envelope: SealedEnvelope;
463
+ disposition: "ok" | "error" | "canceled";
464
+ }): Promise<{
465
+ accepted: boolean;
466
+ duplicate?: boolean;
467
+ state: RoutedState;
468
+ } | {
469
+ refused: HolderRefusal;
470
+ }>;
471
+ /** Give back leases this runner holds, naming each grant it means. */
472
+ releaseLeases(input: {
473
+ runnerId: string;
474
+ leases: readonly {
475
+ jobId: string;
476
+ leaseId: string;
477
+ }[];
478
+ reason?: ReleaseReason;
479
+ retryAfter?: number;
480
+ }): Promise<string[]>;
481
+ /**
482
+ * Take a site's sealed payload for a claimed job.
483
+ *
484
+ * Refuses anything not `awaiting-payload`, which is what makes the timeout
485
+ * mean something: a late seal must not land on a claim that has moved.
486
+ */
487
+ seal(input: {
488
+ jobId: string;
489
+ siteId: string;
490
+ envelope: SealedEnvelope;
491
+ }): Promise<{
492
+ state: RoutedState;
493
+ } | {
494
+ refused: "not-found" | "too-late";
495
+ was?: RoutedState;
496
+ }>;
497
+ /** {@link RoutingStore.cancel} — the site withdraws a job. */
498
+ cancel(input: {
499
+ jobId: string;
500
+ siteId: string;
501
+ }): Promise<boolean>;
502
+ /** {@link RoutingStore.cancelRequests} — cancelled jobs this runner holds. */
503
+ cancelRequests(runnerId: string): Promise<Grant[]>;
504
+ /** {@link RoutingStore.renewLeases} — extend what is still held, name what is not. */
505
+ renewLeases(input: {
506
+ runnerId: string;
507
+ leases: readonly {
508
+ jobId: string;
509
+ leaseId: string;
510
+ }[];
511
+ leaseMs: number;
512
+ }): Promise<{
513
+ renewed: {
514
+ jobId: string;
515
+ expiresAt: number;
516
+ }[];
517
+ lost: Grant[];
518
+ }>;
519
+ seen(presence: Omit<Presence, "lastSeenAt">): Promise<Presence>;
520
+ presence(runnerId: string): Promise<Presence | undefined>;
521
+ /**
522
+ * Lose a record, the way a real store does.
523
+ *
524
+ * A shared store drops presence for reasons this one never will — a TTL, a
525
+ * reschedule, a restart — and the interesting behaviour is what the relay
526
+ * does next. `ValkeyRoutingStore` has carried the same helper since
527
+ * finding 52; this is its memory twin, so the case can be written once
528
+ * against the implementation that is easy to reason about.
529
+ */
530
+ dropPresenceForTests(runnerId: string): Promise<void>;
531
+ everyone(): Promise<Presence[]>;
532
+ /**
533
+ * Fire whatever the clock says is due, and report it.
534
+ *
535
+ * Returns the jobs it requeued so a caller can log or surface them — a
536
+ * timeout that fires invisibly is indistinguishable from a job that was
537
+ * never claimed, and those want very different debugging.
538
+ */
539
+ sweep(): Promise<RoutedJob[]>;
540
+ }
541
+
542
+ /**
543
+ * What the relay needs from a place to keep routing state — cloud_006 §3.2.
544
+ *
545
+ * `RelayState` implements this in memory and is the reference; a Valkey-backed
546
+ * store implements the same thing across replicas. The interface exists so the
547
+ * relay depends on the *contract* rather than on either, and so the properties
548
+ * below are stated once rather than rediscovered per implementation.
549
+ *
550
+ * ## Every method is a decision plus its write
551
+ *
552
+ * Not one is a read the caller follows with a mutation. That is the whole
553
+ * design, and it is not a style preference: `claim` used to be
554
+ * `jobs()` → filter → mutate in the plane, which is atomic for exactly one
555
+ * reason — Node is single-threaded and the Maps are local. Neither survives a
556
+ * store on a network, and `packages/relay/test/two-replicas.test.ts` holds the
557
+ * resulting race as a failing assertion.
558
+ *
559
+ * **The rule for anything added here:** if a caller has to read, decide, and
560
+ * write back, the operation is in the wrong place. Move the decision in.
561
+ *
562
+ * ## What an implementation must guarantee
563
+ *
564
+ * 1. **`claim` is atomic.** Two callers claiming concurrently must not both
565
+ * receive the same job. `CLAIM_ATOMIC` is a protocol MUST.
566
+ * 2. **`enqueue` is idempotent by job id.** A known id returns what is already
567
+ * routing rather than rebuilding it — byollm_009 §4.2's replay argument
568
+ * rests on every write being idempotent per the instance it names.
569
+ * 3. **`complete` is idempotent.** A replayed result changes nothing, and the
570
+ * decision is made by the same operation that would have written it.
571
+ * 4. **Lease-scoped operations name the grant.** `takePayload`, `complete` and
572
+ * `releaseLeases` check the lease *id*, not just the runner — a runner
573
+ * survives a claim-release-reclaim cycle and a grant does not.
574
+ * 5. **`now()` is the only clock.** Every deadline the store stamps and every
575
+ * deadline it enforces come from here (§3.4). An implementation backed by a
576
+ * server returns that server's time, so two replicas cannot disagree about
577
+ * how long a lease is.
578
+ *
579
+ * ## What it must not do
580
+ *
581
+ * Hold a key, or learn about consent. `claim` takes `owners` as data because a
582
+ * predicate cannot travel to Valkey — and the effect is that the store cannot
583
+ * express an opinion about who may route, only about what it was told. That is
584
+ * what keeps `RELAY_BLIND` a property of the shape rather than of the code.
585
+ */
586
+ /**
587
+ * One grant, named by both halves — V1-3.
588
+ *
589
+ * A job id is chosen per site, so two sites can pick the same one; the lease
590
+ * id is the unique thing an upstream and a daemon can both point at. Anywhere
591
+ * this store says "this piece of work, held by you", it says it with both.
592
+ */
593
+ interface Grant {
594
+ readonly jobId: string;
595
+ readonly leaseId: string;
596
+ }
597
+ interface RoutingStore {
598
+ /** The one clock every deadline in this store is stamped from. */
599
+ now(): Promise<number>;
600
+ /**
601
+ * Take a stub for routing. Idempotent by id, **within a site**.
602
+ *
603
+ * A republished id belonging to the same site returns what is already
604
+ * routing, unchanged: a site that restarts and republishes its queue must
605
+ * not disturb work in flight (byollm_009 §4.2's replay argument rests on
606
+ * it).
607
+ *
608
+ * A republished id belonging to a **different** site is a different job —
609
+ * cloud_009 §3. Keys are `(site, id)`, so two sites choosing one id collide
610
+ * nowhere and there is nothing to refuse.
611
+ *
612
+ * cloud_008 finding 58 closed this by refusing the collision, which was as
613
+ * far as a single-site relay could go and left a cross-tenant existence
614
+ * oracle: a site knows its own stub is well-formed, so any answer it can
615
+ * tell apart from success confirms that somebody else holds that id. The
616
+ * refusal is gone with the collision.
617
+ */
618
+ enqueue(input: {
619
+ id: string;
620
+ siteId: string;
621
+ stub: JobStub;
622
+ }): Promise<RoutedJob>;
623
+ /**
624
+ * One job, named by the site that published it — cloud_009 §3.
625
+ *
626
+ * The site id is a parameter rather than a scan, and the difference is
627
+ * finding eleven wearing a different hat: a reader that finds a job by id
628
+ * alone is a reader that can see every tenant's state through one door,
629
+ * which is exactly the anonymous read the debug page had and lost. The
630
+ * debug page is per-site or it is nothing.
631
+ */
632
+ job(siteId: string, jobId: string): Promise<RoutedJob | undefined>;
633
+ jobs(): Promise<RoutedJob[]>;
634
+ /** Jobs a site must seal for, right now. */
635
+ awaiting(siteId: string): Promise<RoutedJob[]>;
636
+ /** Sealed results waiting to go home. */
637
+ finished(siteId: string): Promise<RoutedJob[]>;
638
+ /** Grant work to a device — one operation, because it has to be. */
639
+ claim(input: ClaimInput): Promise<ClaimedStub[]>;
640
+ /** Hand the sealed payload to the device that holds the lease. */
641
+ takePayload(input: {
642
+ jobId: string;
643
+ runnerId: string;
644
+ leaseId: string;
645
+ }): Promise<{
646
+ envelope: SealedEnvelope;
647
+ } | {
648
+ refused: HolderRefusal;
649
+ }>;
650
+ /** Record a finished job. Idempotent. */
651
+ complete(input: {
652
+ jobId: string;
653
+ runnerId: string;
654
+ /**
655
+ * The grant the result was produced under — cloud_008 §1.4a.
656
+ *
657
+ * `takePayload` and `releaseLeases` have always checked this and said why;
658
+ * the operation that *writes* checked only the runner, which survives a
659
+ * claim-release-reclaim cycle.
660
+ */
661
+ leaseId: string;
662
+ envelope: SealedEnvelope;
663
+ disposition: "ok" | "error" | "canceled";
664
+ }): Promise<{
665
+ accepted: boolean;
666
+ duplicate?: boolean;
667
+ state: RoutedState;
668
+ } | {
669
+ refused: HolderRefusal;
670
+ }>;
671
+ /** Give back the grants this runner names. */
672
+ releaseLeases(input: {
673
+ runnerId: string;
674
+ leases: readonly {
675
+ jobId: string;
676
+ leaseId: string;
677
+ }[];
678
+ /**
679
+ * Why — cloud_008 §2.1. `refused` MUST be remembered and that job MUST
680
+ * NOT be offered to that runner again (`REFUSAL_NOT_REOFFERED`); every
681
+ * other reason means "not now" and must leave the job claimable by the
682
+ * same device, or a restart would strand its own work.
683
+ */
684
+ reason?: ReleaseReason;
685
+ /**
686
+ * Epoch ms before which this job MUST NOT be offered to this runner
687
+ * again — the rate a transient refusal needs.
688
+ *
689
+ * "Not now" and "not ever" were the only two things a release could say,
690
+ * and a control plane declining a job for something the world can change
691
+ * means neither. Left immediate, the device re-claims at once and is
692
+ * declined again: a spin, and in a deployment one database read per turn
693
+ * of it.
694
+ *
695
+ * A store MUST NOT offer the job to that runner before this moment, and
696
+ * MUST go on offering it to every other device immediately — the whole
697
+ * reason it is not a refusal is that another machine may be the right
698
+ * one.
699
+ */
700
+ retryAfter?: number;
701
+ }): Promise<string[]>;
702
+ /** Take a site's sealed payload for a claimed job. */
703
+ seal(input: {
704
+ jobId: string;
705
+ siteId: string;
706
+ envelope: SealedEnvelope;
707
+ }): Promise<{
708
+ state: RoutedState;
709
+ } | {
710
+ refused: "not-found" | "too-late";
711
+ was?: RoutedState;
712
+ }>;
713
+ /**
714
+ * Extend the leases this runner still holds, and name the ones it does not.
715
+ *
716
+ * One call because it is one question — cloud_008 §0.6. This was
717
+ * `lostLeases`, which answered only the second half, and the relay's
718
+ * heartbeat answered the first half with the literal `leases: []`. A daemon
719
+ * was therefore told, every few seconds, that none of its work had been
720
+ * renewed; the sweep requeued at `leaseMs` regardless of how alive the
721
+ * device was, and any job that took longer than a lease was handed to
722
+ * somebody else while the first device was still running it. The direct
723
+ * plane has always renewed here (`handlers.ts` §3), so this was also the two
724
+ * upstreams disagreeing about a rule the daemon cannot see.
725
+ *
726
+ * Renewal and loss come from one read of one state: asked separately they
727
+ * are two answers to "who holds this now", and under two replicas they can
728
+ * differ.
729
+ */
730
+ renewLeases(input: {
731
+ runnerId: string;
732
+ leases: readonly {
733
+ jobId: string;
734
+ leaseId: string;
735
+ }[];
736
+ leaseMs: number;
737
+ }): Promise<{
738
+ renewed: readonly {
739
+ jobId: string;
740
+ expiresAt: number;
741
+ }[];
742
+ lost: readonly Grant[];
743
+ }>;
744
+ /**
745
+ * The site withdraws a job — cloud_008 §2.2.
746
+ *
747
+ * Returns false when the job is not this site's, which is the same scoping
748
+ * every other site-plane operation carries: a site must not cancel
749
+ * somebody else's work by guessing an id.
750
+ */
751
+ cancel(input: {
752
+ jobId: string;
753
+ siteId: string;
754
+ }): Promise<boolean>;
755
+ /**
756
+ * Cancelled jobs this runner is holding, for the heartbeat to report.
757
+ *
758
+ * The relay answered `cancel: []` unconditionally, so a site could not stop
759
+ * a job it had already withdrawn — a device went on running work whose
760
+ * result nobody would accept, on somebody's own machine, at their expense.
761
+ */
762
+ cancelRequests(runnerId: string): Promise<Grant[]>;
763
+ /** Record a device as present. The store stamps when. */
764
+ seen(presence: Omit<Presence, "lastSeenAt">): Promise<Presence>;
765
+ presence(runnerId: string): Promise<Presence | undefined>;
766
+ everyone(): Promise<Presence[]>;
767
+ /** Fire whatever the clock says is due, and report it. */
768
+ sweep(): Promise<RoutedJob[]>;
769
+ }
770
+
771
+ export { AWAITING_PAYLOAD_MS as A, CHURN_EXPIRIES as C, type Grant as G, type HolderRefusal as H, type Presence as P, type RoutingStore as R, SEAL_ATTEMPTS_BEFORE_EVICTION as S, type UnsealedChurn as U, CHURN_QUIET_MS as a, CHURN_WINDOW_MS as b, type ClaimInput as c, RelayState as d, type ReleaseReason as e, type RoutedJob as f, type RoutedState as g, UNSEALED_PER_PAIR as h, routeKey as r };