@byollm/server 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.
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  > [!WARNING]
2
- > **Alpha (`0.1.0-alpha.9`) — under active development. Don't use this yet.**
2
+ > **Alpha (`0.1.0-alpha.91`) — under active development. Don't use this yet.**
3
3
  >
4
4
  > Install it deliberately: `npm install @byollm/server@alpha`.
5
5
  >
@@ -11,7 +11,67 @@
11
11
  > npm assigns `latest` on a first publish and won't let it be removed, so a
12
12
  > bare install resolves here too. This notice is the only guard — deliberately
13
13
  > not an npm deprecation, which would read as *abandoned* rather than *early*.
14
- > Ask for `@alpha` explicitly so your lockfile records that you meant to.
14
+ > Ask for `@alpha` explicitly so your lockfile records that you meant to.>
15
+ > **`alpha.15` is a breaking wire change, and it breaks daemons and relays —
16
+ > not app authors.** If you call `app.enqueue(...)` and read results, nothing
17
+ > in your code changes. If you run a daemon or an upstream, every package must
18
+ > move together: a mixed pair refuses on both sides, because both ends parse
19
+ > `.strict()`.
20
+ >
21
+ > What moved, all of it reconciling the frozen `byollm_009` with its code:
22
+ > `JobStub` gains `site` (the site's identity key id) and loses
23
+ > `audienceAllow`; `ResultRequest` gains `leaseId`; `HeartbeatResponse` loses
24
+ > `leases`, which nothing read; `WireErrorCode` gains `not-ready`,
25
+ > `clock-skew` and `forbidden`, and `403` is `forbidden` rather than
26
+ > `unauthorized`. `RESULT_PROVENANCE` is superseded by
27
+ > `PROVENANCE_NAMES_DEVICE`. See `byollm_009` Amendment A.>
28
+ > **`alpha.16` is a breaking wire change — daemons and relays again, not app
29
+ > authors.** `app.enqueue(...)` and reading results are unchanged. All five
30
+ > packages move together: both ends parse `.strict()`, so a mixed pair
31
+ > refuses.
32
+ >
33
+ > What moved, all of it Tier 2 of `cloud_008`: `model`, `backendClass` and
34
+ > `durationMs` come off `ResultRequest` and are sealed **inside** the result
35
+ > envelope as `SealedOutcome = { outcome, ran }` — so a daemon can no longer
36
+ > declare a model it did not sign, and a relay carries neither.
37
+ > `HeartbeatResponse` loses `leases` (nothing read it) and now reports real
38
+ > cancellations instead of an empty list. `WireErrorCode` gains `forbidden`
39
+ > for 403, leaving `unauthorized` at exactly 401. The relay gained a
40
+ > site-plane `cancel` endpoint, honours `stub.deadlineAt`, honours
41
+ > `stub.audience`, and remembers a refusal.>
42
+ > **`alpha.17` is additive** — no wire change. It exports `ReleaseReason`,
43
+ > which `RoutingStore.releaseLeases` names and the package did not export, so
44
+ > the interface was unimplementable outside this repo.>
45
+ > **`alpha.18` is a breaking wire change — daemons and relays, not app
46
+ > authors.** `app.enqueue(...)` and reading results are unchanged. All five
47
+ > packages move together.
48
+ >
49
+ > The **bearer token is gone**: off `PairPollResponse`, off the runner row,
50
+ > off the daemon's pairings file, out of the adapter's schema. It was minted,
51
+ > hashed and stored on two disks and never sent, looked up or compared —
52
+ > `REQUESTS_SIGNED_NOT_BEARER` was enforced by signatures the whole time. If
53
+ > you run the Supabase adapter, apply
54
+ > `20260819000000_drop_runner_token.sql`; `byollm_approve_pairing` now takes
55
+ > one argument. A pairings file written by an older daemon still loads.
56
+ >
57
+ > `model`, `backendClass` and `durationMs` moved **inside** the sealed result
58
+ > (`SealedOutcome = { outcome, ran }`), so a daemon cannot declare a model it
59
+ > did not sign and a relay carries none of them. Writing a `RoutingStore`?
60
+ > `releaseLeases` takes an optional `reason` and `complete` requires
61
+ > `leaseId`, and **an implementation that ignores either still typechecks** —
62
+ > run the store contract tests.>
63
+ > **`alpha.19` is additive on the wire and a behaviour change in every
64
+ > store.** `ResultResponse` gains an optional `duplicate`. Nothing is removed,
65
+ > so an older daemon keeps working — but the *order* two rules are checked in
66
+ > has changed, and a `RoutingStore` implementation must change with it.
67
+ >
68
+ > `complete` now checks **terminal state before holder**, scoped to the device
69
+ > that finished the job: a replay from that device is answered `duplicate:
70
+ > true` with a 2xx, and anyone else gets exactly the refusal they would get
71
+ > for a job that is not terminal. Previously `RESULT_IDEMPOTENT` held only
72
+ > because the lease is nulled on success, so the holder check tripped first —
73
+ > deleting the idempotency branch failed no test. Run the store contract
74
+ > tests; the compiler cannot see this.
15
75
  >
16
76
  > **`alpha.7` breaks nobody who is not implementing a relay.** `@byollm/relay`'s
17
77
  > projection gains `devices` — the device keys a control plane has approved —
@@ -39,6 +99,48 @@
39
99
  > Your store adapter is unaffected: payloads and results are sealed before they
40
100
  > reach it, and `JobStore` did not change.
41
101
 
102
+ <!-- release-note 0.1.0-alpha.21 -->
103
+ > [!NOTE]
104
+ > **`0.1.0-alpha.20` is not a complete release — do not pin it.** Four
105
+ > packages published and `@byollm/server` did not: a Sigstore
106
+ > transparency-log 409 on its provenance attestation. The workflow's
107
+ > "already published" guard correctly refuses to resume a partial publish,
108
+ > so `0.1.0-alpha.21` is that release, whole.
109
+ >
110
+ > If you run the Supabase adapter, `alpha.21` needs
111
+ > `20260819010000_completed_by_lease_id.sql`: alpha.19 shipped §3.6's
112
+ > ordering without the column it stores the grant in.
113
+
114
+ <!-- release-note 0.1.0-alpha.40 -->
115
+ **`byollm start` — stop keeping a terminal open.** The daemon can now run
116
+ under your computer's own supervisor and restart itself if it stops: a launchd
117
+ agent on macOS, a `systemd --user` unit on Linux, a logon task on Windows. All
118
+ user-level — no root, no system directories, and `byollm stop` takes it
119
+ away. `byollm status` gained a line saying whether it is actually supervised
120
+ right now, including the state that matters most: installed but not running,
121
+ which looks fine from an app's dashboard and serves nothing.
122
+
123
+ If you are running via `npx`, install properly first (`npm install -g
124
+ byollm@latest`) — `install` refuses to supervise a copy in npx's cache, because
125
+ npm deletes that directory and the service would fail at some later boot.
126
+
127
+ <!-- release-note 0.1.0-alpha.41 -->
128
+ **`onNoRunner` takes a string.** Your fallback answer is your own value, not
129
+ wire data, and handing back a whole result record for it was ceremony — the
130
+ README's own example got the shape wrong, which is how this was found.
131
+
132
+ ```ts
133
+ const { outcome, fallback } = await job.result({
134
+ onNoRunner: () => runOnHostedModel(transcript),
135
+ });
136
+ ```
137
+
138
+ Whatever you return, `result()` labels it `fallback: true` — the stamp is
139
+ applied by the wait, not taken from you, so an answer that did not run on
140
+ somebody's device cannot be reported as though it did (`FALLBACK_LABELED`).
141
+ Both delivery channels do it, polling and Supabase Realtime. Records still
142
+ work; they just get labelled too.
143
+
42
144
  # `@byollm/server`
43
145
 
44
146
  What app developers drop into their backend: framework-agnostic protocol
@@ -109,7 +211,7 @@ npx @byollm/server@alpha keygen # prints BYOLLM_SITE_KEYS=...
109
211
  ```
110
212
 
111
213
  Once, not per deploy and never at startup — a daemon pins this identity when
112
- its owner approves the pairing, and regenerating it means every paired machine
214
+ its owner approves the pairing, and regenerating it means every paired device
113
215
  must pair again. Generating at startup fails only under horizontal scale: each
114
216
  instance would have a different identity, and a daemon would be refused by
115
217
  whichever one it did not pair with.
@@ -119,21 +221,76 @@ whichever one it did not pair with.
119
221
  ```ts
120
222
  const job = await getApp().enqueue({
121
223
  kind: "llm.generate",
122
- audience: "self", // this user's own machine only — the default
123
- owner: userId,
224
+ audience: "private", // this user's own device only — the default
225
+ owner: ownerId, // who this is depends on your mode — see below
124
226
  payload: { prompt: `Summarize this transcript:\n\n${transcript}` },
125
227
  });
126
228
 
127
- const { outcome } = await job.result({
229
+ const { outcome, fallback } = await job.result({
128
230
  timeoutMs: 120_000,
231
+ // A string is enough — it is your own fallback answer, not wire data.
129
232
  onNoRunner: () => runOnHostedModel(transcript),
130
233
  });
234
+
235
+ // `fallback` is true when nobody's device ran it and this came from your
236
+ // own substitute. Say so wherever you show the answer: work that did not run
237
+ // on the user's compute must not be presented as though it did.
131
238
  ```
132
239
 
133
240
  `result()` is sugar over your delivery channel with a timeout and a
134
241
  `noRunnerAvailable` path — never a bare promise that hangs forever. If nobody
135
242
  is online to run the job, you find out and can fall back.
136
243
 
244
+ ## Who `owner` is — this differs by mode
245
+
246
+ `owner` names the person whose devices should do the work, and the two
247
+ connection modes do not use the same names for people. Getting this wrong is
248
+ the one integration mistake that produces no error: the job enqueues, returns
249
+ an id, and never routes.
250
+
251
+ **Direct** — daemons reach your own handlers, and you are the only party who
252
+ knows who anybody is. `owner` is your own user id, from your session, never
253
+ from the client. That is what the example above shows.
254
+
255
+ **Cloud** — daemons reach `hub.byollm.cloud`, which has its own identity
256
+ space: rosters, consents and budgets all speak **BYOLLM ids**. Your user id
257
+ means nothing there. `owner` must be the person's BYOLLM id.
258
+
259
+ Ask them for it. Every signed-in person can read it on their byollm.cloud
260
+ account page under **Your BYOLLM id**, with a copy button. Add a settings
261
+ field, have them paste it, store it against your own user record.
262
+
263
+ The id names them and authorises nothing — someone holding it can address work
264
+ to a person and cannot deliver it, because the consent row is what opens the
265
+ route. So a pasted id is not a credential you are being trusted with, and a
266
+ wrong one is harmless. Two things still matter: it must come from the person's
267
+ own account rather than being inferred by your app, and they must connect your
268
+ site on byollm.cloud before anything routes.
269
+
270
+ **A wrong id is silence, not an error.** The relay routes on a consented
271
+ `(site, owner)` pair, so a mistyped id — or one belonging to somebody who has
272
+ not connected your site — matches no route. The job waits, then expires. So
273
+ check ids when you receive them rather than when you enqueue:
274
+
275
+ ```ts
276
+ const { available } = await getApp().runnerAvailability({
277
+ kind: "llm.generate",
278
+ owner: pastedByollmId,
279
+ });
280
+
281
+ if (!available) {
282
+ // Existence-neutral, deliberately.
283
+ return "That id has no devices for you. Check it, or connect this site on byollm.cloud.";
284
+ }
285
+ ```
286
+
287
+ Keep that message vague on purpose. A typo'd id and an id belonging to somebody
288
+ who has not connected you give the same answer, and that is the system working
289
+ rather than a limitation to route around — telling them apart would make this
290
+ call an account-existence oracle: probe a guess, sort the answers, enumerate.
291
+ Never write "no such account". If you ever get an answer that *does*
292
+ distinguish them, that is a bug worth reporting.
293
+
137
294
  ## The approval page
138
295
 
139
296
  Pairing is a device-code exchange, so you need one page where a signed-in user
@@ -147,7 +304,7 @@ const runner = await getApp().approvePairing({
147
304
  });
148
305
  ```
149
306
 
150
- `app.pendingPairing(code)` tells you what they are about to approve — machine
307
+ `app.pendingPairing(code)` tells you what they are about to approve — device
151
308
  label, platform, and which models it is offering — so the page can show it.
152
309
 
153
310
  ## Stores
@@ -185,7 +342,7 @@ everything the browser does.
185
342
  ## Two things the API makes you confront
186
343
 
187
344
  **Community results are untrusted.** A `named`/`public` result came from
188
- someone else's machine and can be anything. Every result carries provenance,
345
+ someone else's device and can be anything. Every result carries provenance,
189
346
  and `untrusted` is derived from the audience — you cannot mark volunteer
190
347
  output as first-party:
191
348
 
@@ -200,7 +357,7 @@ if (provenance?.untrusted) {
200
357
  **Jobs can depend on each other.** `dependsOn: [jobId]` keeps a job
201
358
  unclaimable until its dependencies are `ok`. One field and one claim
202
359
  predicate, not a DAG engine — so the two halves of a piece of work can land on
203
- two different people's machines, in order, without your app orchestrating the
360
+ two different people's devices, in order, without your app orchestrating the
204
361
  wait.
205
362
 
206
363
  ## Certifying an adapter
@@ -213,3 +370,17 @@ A server is byollm-compatible when the kit passes. See
213
370
  [`@byollm/conformance`](../conformance).
214
371
 
215
372
  MIT
373
+
374
+ <!-- family:start -->
375
+
376
+ ## The rest of byollm
377
+
378
+ Six packages, and they are only interesting together:
379
+
380
+ - [`byollm`](https://www.npmjs.com/package/byollm) — the daemon — runs models on your own machine and answers for it
381
+ - [`@byollm/protocol`](https://www.npmjs.com/package/@byollm/protocol) — the wire: envelopes, signatures and the closed vocabularies both ends validate against
382
+ - [`@byollm/relay`](https://www.npmjs.com/package/@byollm/relay) — the broker that holds jobs between a site and a device, and can read neither
383
+ - [`@byollm/control-plane`](https://www.npmjs.com/package/@byollm/control-plane) — who may ask whom, and the policy store behind it
384
+ - [`@byollm/conformance`](https://www.npmjs.com/package/@byollm/conformance) — the kit that proves an implementation is one — including a posture audit that holds nothing but a URL
385
+
386
+ <!-- family:end -->
@@ -9,9 +9,6 @@ var USER_CODE_ALPHABET = "ABCDEFGHJKMNPQRTWXYZ2346789";
9
9
  function generateDeviceCode() {
10
10
  return randomBytes(32).toString("base64url");
11
11
  }
12
- function generateRunnerToken() {
13
- return randomBytes(32).toString("base64url");
14
- }
15
12
  function generateRunnerId() {
16
13
  return `runner_${randomUUID()}`;
17
14
  }
@@ -41,10 +38,31 @@ function secretsMatch(aHex, bHex) {
41
38
  }
42
39
  var generateLeaseId = () => randomUUID();
43
40
 
41
+ // src/sealed-outcome.ts
42
+ import { SealedOutcome } from "@byollm/protocol";
43
+ function openSealedOutcome(input) {
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(input.plaintext);
47
+ } catch {
48
+ return { ok: false, why: "the sealed result was not valid JSON" };
49
+ }
50
+ const sealed = SealedOutcome.safeParse(parsed);
51
+ if (!sealed.success) {
52
+ return { ok: false, why: "the sealed result was not an outcome" };
53
+ }
54
+ if (sealed.data.outcome.outcome !== input.disposition) {
55
+ return {
56
+ ok: false,
57
+ why: "the declared disposition is not the one that was sealed"
58
+ };
59
+ }
60
+ return { ok: true, value: sealed.data };
61
+ }
62
+
44
63
  // src/handlers.ts
45
64
  import {
46
65
  FetchRequest,
47
- JobOutcome,
48
66
  keyId as keyId2,
49
67
  open as open2,
50
68
  publicIdentityOf as publicIdentityOf2,
@@ -102,6 +120,11 @@ async function resealForDevice(input) {
102
120
  return { ok: true, envelope };
103
121
  }
104
122
 
123
+ // src/records.ts
124
+ function deadlineFor(job, now) {
125
+ return job.deadlineAt ?? (job.claimableAt ?? now) + job.ttlMs;
126
+ }
127
+
105
128
  // src/handlers.ts
106
129
  var DEFAULTS = {
107
130
  leaseMs: 6e4,
@@ -130,6 +153,8 @@ var ByollmHandlers = class {
130
153
  #pollIntervalMs;
131
154
  #now;
132
155
  #siteKeys;
156
+ /** This site's identity key id — Amendment A's `stub.site`. Derived once. */
157
+ #siteKeyId;
133
158
  constructor(config) {
134
159
  this.#store = config.store;
135
160
  if (!verifyPublicIdentity(publicIdentityOf2(config.siteKeys))) {
@@ -138,6 +163,7 @@ var ByollmHandlers = class {
138
163
  );
139
164
  }
140
165
  this.#siteKeys = config.siteKeys;
166
+ this.#siteKeyId = keyId2(publicIdentityOf2(config.siteKeys).identity);
141
167
  this.#verificationUrl = config.verificationUrl;
142
168
  this.#leaseMs = config.leaseMs ?? DEFAULTS.leaseMs;
143
169
  this.#pairingTtlMs = config.pairingTtlMs ?? DEFAULTS.pairingTtlMs;
@@ -162,8 +188,7 @@ var ByollmHandlers = class {
162
188
  auth,
163
189
  body,
164
190
  HeartbeatRequest,
165
- this.#heartbeat.bind(this),
166
- { allowRevoked: true }
191
+ this.#heartbeat.bind(this)
167
192
  );
168
193
  case "fetch":
169
194
  return this.#authed(auth, body, FetchRequest, this.#fetch.bind(this));
@@ -264,7 +289,7 @@ var ByollmHandlers = class {
264
289
  state: "pending",
265
290
  owner: null,
266
291
  runnerId: null,
267
- runnerTokenOnce: null,
292
+ collected: false,
268
293
  label: request.daemon.label,
269
294
  platform: request.daemon.platform,
270
295
  daemonVersion: request.daemon.version,
@@ -293,15 +318,19 @@ var ByollmHandlers = class {
293
318
  if (pairing.expiresAt <= now && pairing.state === "pending") {
294
319
  return ok({ status: "expired" });
295
320
  }
296
- if (pairing.state === "approved" && pairing.runnerTokenOnce !== null && pairing.runnerId !== null && pairing.owner !== null) {
321
+ if (pairing.state === "approved" && !pairing.collected && pairing.runnerId !== null && pairing.owner !== null) {
297
322
  const response = {
298
323
  status: "approved",
299
- runnerToken: pairing.runnerTokenOnce,
300
324
  runnerId: pairing.runnerId,
301
325
  owner: pairing.owner,
302
326
  // Only on approval: a pending or denied poll learns nothing, so an
303
327
  // unapproved code cannot be used to enumerate a site's keys.
304
- site: publicIdentityOf2(this.#siteKeys)
328
+ //
329
+ // One entry, because a direct site *is* one site — the same shape a
330
+ // hub answers with rather than a special case (cloud_009 §5). The
331
+ // daemon's lookup is one map read on every lane, which is what keeps
332
+ // the two lanes one protocol.
333
+ sites: { [this.#siteKeyId]: publicIdentityOf2(this.#siteKeys) }
305
334
  };
306
335
  await this.#store.consumePairingToken(pairing.deviceCodeHash);
307
336
  return ok(response);
@@ -331,6 +360,17 @@ var ByollmHandlers = class {
331
360
  kind: job.kind,
332
361
  audience: job.audience,
333
362
  owner: job.owner,
363
+ // This site, named by its identity key id — Amendment A §A.3. The
364
+ // daemon pinned this exact value at pairing, so it can check the stub
365
+ // against the envelope it later opens rather than taking our word for
366
+ // which site sent it. On this plane that is redundant, which is the
367
+ // point: the direct and relayed stubs are the same shape, and a daemon
368
+ // serving both cannot tell which upstream it is talking to.
369
+ site: this.#siteKeyId,
370
+ // byollm_016 Phase B. Present only when the site named one, and
371
+ // omitted rather than sent as undefined — the stub is `.strict()` and
372
+ // an explicit undefined is not the same as an absent key.
373
+ ...job.purpose === void 0 ? {} : { purpose: job.purpose },
334
374
  // Bucketed, not measured: an exact size is a stronger fingerprint
335
375
  // than routing needs (byollm_009 §6).
336
376
  sizeClass: job.sizeClass,
@@ -339,8 +379,19 @@ var ByollmHandlers = class {
339
379
  // The stub's deadline bounds how long a captured envelope is worth
340
380
  // keeping, so it is always present — falling back to the TTL window
341
381
  // when the app named no absolute one.
342
- deadlineAt: job.deadlineAt ?? (job.claimableAt ?? now) + job.ttlMs,
343
- ...job.audienceAllow === void 0 ? {} : { audienceAllow: [...job.audienceAllow] },
382
+ deadlineAt: deadlineFor(job, now),
383
+ // `audienceAllow` is not sent cloud_008 §0.2. The list stays on
384
+ // `JobRecord`, where `claim` already filtered candidates with it; the
385
+ // daemon's own allowlist is what decides `named` (byollm_001 Rev 1
386
+ // §B) and always was.
387
+ //
388
+ // Removing it from `JobStub` did **not** make this line a type error.
389
+ // A conditional spread is not excess-property-checked, so the field
390
+ // would have gone on being sent to a daemon whose `.strict()` parse
391
+ // now rejects the entire claim response — every daemon on the version
392
+ // pair, refusing all work, for a field nobody read. Worth stating
393
+ // where it happened: the schema is the contract, and the compiler
394
+ // does not enforce it through a spread.
344
395
  // No fallback. A job returned from `claim` holds a lease by
345
396
  // definition, and synthesising one here would hand the daemon a lease
346
397
  // id the store has never heard of — every later release naming it
@@ -358,18 +409,6 @@ var ByollmHandlers = class {
358
409
  return fail("unauthorized", "runner id does not match the signing key");
359
410
  }
360
411
  const now = this.#now();
361
- const revoked = runner.revokedAt !== null;
362
- if (revoked) {
363
- const held = await this.#store.listClaimedBy(runner.id);
364
- const response2 = {
365
- revoked: true,
366
- cancel: [],
367
- leases: [],
368
- lost: held.map((job) => job.id),
369
- serverTime: now
370
- };
371
- return ok(response2);
372
- }
373
412
  await this.#store.touchRunner({
374
413
  runnerId: runner.id,
375
414
  capabilities: request.capabilities,
@@ -377,7 +416,7 @@ var ByollmHandlers = class {
377
416
  paused: request.paused,
378
417
  now
379
418
  });
380
- const { renewed, lost } = await this.#store.renewLeases({
419
+ const { lost } = await this.#store.renewLeases({
381
420
  runnerId: runner.id,
382
421
  leases: request.activeLeases,
383
422
  leaseMs: this.#leaseMs,
@@ -385,9 +424,11 @@ var ByollmHandlers = class {
385
424
  });
386
425
  const cancel = await this.#store.listCancelRequests(runner.id);
387
426
  const response = {
388
- revoked: false,
389
- cancel,
390
- leases: renewed.map((r) => ({ jobId: r.jobId, expiresAt: r.expiresAt })),
427
+ sites: { [this.#siteKeyId]: publicIdentityOf2(this.#siteKeys) },
428
+ // A direct site has no disclosure of its own to go stale: consent to it
429
+ // *is* the pairing, and withdrawing it empties the set above.
430
+ awaitingConsent: [],
431
+ cancel: [...cancel],
391
432
  lost: [...lost],
392
433
  serverTime: now
393
434
  };
@@ -407,18 +448,35 @@ var ByollmHandlers = class {
407
448
  audience: job.audience,
408
449
  runnerId: runner.id,
409
450
  runnerOwner: runner.owner,
410
- backendClass: request.backendClass,
411
- model: request.model
451
+ // From the envelope the device signed, not from the request beside it
452
+ // — cloud_008 §2.5. A daemon can no longer seal one answer and declare
453
+ // it came from a different model.
454
+ backendClass: outcome.value.ran.backendClass,
455
+ model: outcome.value.ran.model
412
456
  });
413
- const { accepted, job: updated } = await this.#store.complete({
457
+ const {
458
+ accepted,
459
+ duplicate,
460
+ job: updated
461
+ } = await this.#store.complete({
414
462
  jobId: request.jobId,
415
- holder: { by: "runner", runnerId: runner.id },
416
- outcome: outcome.value,
463
+ // Who is asking, for the duplicate answer only — §3.6. Authorisation
464
+ // is `holder`, below, and still is.
465
+ runnerId: runner.id,
466
+ // The grant, not the runner — cloud_008 §1.4a. `CompleteHolder`'s own
467
+ // docstring already called the lease "the more exact check anyway";
468
+ // this plane simply had no lease id to give it until now.
469
+ holder: { by: "lease", leaseId: request.leaseId },
470
+ outcome: outcome.value.outcome,
417
471
  provenance,
418
472
  now
419
473
  });
420
474
  const response = {
421
475
  accepted,
476
+ // Only when true — cloud_008 §3.6. Absent means "not a duplicate", and
477
+ // an optional field that is always present is a required one wearing a
478
+ // question mark.
479
+ ...duplicate === true ? { duplicate: true } : {},
422
480
  state: updated?.state ?? job.state
423
481
  };
424
482
  return ok(response);
@@ -453,18 +511,12 @@ var ByollmHandlers = class {
453
511
  if (!opened.ok) {
454
512
  return refuse("the result did not verify as coming from this device");
455
513
  }
456
- let parsed;
457
- try {
458
- parsed = JSON.parse(opened.plaintext);
459
- } catch {
460
- return refuse("the sealed result was not valid JSON");
461
- }
462
- const outcome = JobOutcome.safeParse(parsed);
463
- if (!outcome.success) return refuse("the sealed result was not an outcome");
464
- if (outcome.data.outcome !== request.disposition) {
465
- return refuse("the declared disposition is not the one that was sealed");
466
- }
467
- return { ok: true, value: outcome.data };
514
+ const outcome = openSealedOutcome({
515
+ plaintext: opened.plaintext,
516
+ disposition: request.disposition
517
+ });
518
+ if (!outcome.ok) return refuse(outcome.why);
519
+ return { ok: true, value: outcome.value };
468
520
  }
469
521
  // -- 5. release -----------------------------------------------------------
470
522
  async #release(request, runner) {
@@ -495,10 +547,13 @@ function leaseOf(job) {
495
547
  import {
496
548
  ENDPOINTS,
497
549
  ERROR_STATUS as ERROR_STATUS2,
550
+ MAX_ENVELOPE_BYTES,
551
+ tooLargeMessage,
498
552
  PROTOCOL_PREFIX,
499
553
  checkProtocolVersion
500
554
  } from "@byollm/protocol";
501
- var MAX_BODY_BYTES = 8 * 1024 * 1024;
555
+ var MAX_BODY_BYTES = MAX_ENVELOPE_BYTES + 512 * 1024;
556
+ var tooLarge = (bytes) => tooLargeMessage({ bytes, limit: MAX_BODY_BYTES });
502
557
  function normalizeBasePath(basePath) {
503
558
  const trimmed = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
504
559
  if (!trimmed.startsWith("/")) {
@@ -548,7 +603,7 @@ function createFetchHandler(config) {
548
603
  if (declared !== null && Number(declared) > MAX_BODY_BYTES) {
549
604
  return json(400, {
550
605
  error: "bad-request",
551
- message: "request body too large"
606
+ message: tooLarge(Number(declared))
552
607
  });
553
608
  }
554
609
  let body;
@@ -559,7 +614,7 @@ function createFetchHandler(config) {
559
614
  if (text.length > MAX_BODY_BYTES) {
560
615
  return json(400, {
561
616
  error: "bad-request",
562
- message: "request body too large"
617
+ message: tooLarge(text.length)
563
618
  });
564
619
  }
565
620
  body = JSON.parse(text);
@@ -605,13 +660,14 @@ function json(status, body) {
605
660
 
606
661
  export {
607
662
  generateDeviceCode,
608
- generateRunnerToken,
609
663
  generateRunnerId,
610
664
  generateJobId,
611
665
  generateUserCode,
612
666
  hashSecret,
613
667
  secretsMatch,
614
668
  generateLeaseId,
669
+ openSealedOutcome,
670
+ deadlineFor,
615
671
  resealForDevice,
616
672
  ByollmHandlers,
617
673
  SERVED_PROTOCOL_VERSION,
@@ -619,4 +675,4 @@ export {
619
675
  signatureFrom,
620
676
  createFetchHandler
621
677
  };
622
- //# sourceMappingURL=chunk-4NIHWQAT.js.map
678
+ //# sourceMappingURL=chunk-36Y77FUD.js.map