@openlfcp/client 0.1.5 → 0.2.0-beta.10

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.
@@ -1,14 +1,52 @@
1
- import { actorSequence, dataEpoch, hash32, LfcpError, toHex, } from "@openlfcp/core";
2
- import { exportSecretKeyBytes, sha256 } from "@openlfcp/crypto";
1
+ import { actorSequence, bytesEqual, controlRecordId, dataEpoch, dataUnitId, fromHex, hash32, LfcpError, toHex, } from "@openlfcp/core";
2
+ import { exportSecretKeyBytes, sha256, } from "@openlfcp/crypto";
3
3
  import { dekSecretRef, } from "@openlfcp/storage";
4
- import { addRange, addSequence, batchDataRanges, canonicalFrontierToCbor, createMessage, ERROR_CODE, hasSequence, haveDifference, localControlOf, MESSAGE_TYPE, missingFrom, normalizeLiveHaves, parseControlRecord, parseDataUnit, parseKeyPackage, planControlSync, receiveKeyPackage, receiveSnapshot, validateControlChain, } from "@openlfcp/wire";
4
+ import { addRange, addSequence, batchDataRanges, canonicalFrontierToCbor, createMessage, decodeControlRecord, ERROR_CODE, hasSequence, haveDifference, localControlOf, MESSAGE_TYPE, missingFrom, normalizeLiveHaves, parseControlRecord, parseDataUnit, parseKeyPackage, planControlSync, receiveKeyPackage, receiveSnapshot, validateControlChain, } from "@openlfcp/wire";
5
5
  import { encode } from "@openlfcp/wire/cbor";
6
- import { LfcpConnection } from "./connection.js";
6
+ import { afterAccepted, afterMismatch, RECOVERY_TRANSIENT_ATTEMPTS, RECOVERY_TRANSIENT_CODES, startRecovery, } from "./access-recovery.js";
7
+ import { ACCEPTANCE_DURABILITY, batchStatus, batchStatuses, recordEvidence, } from "./batch-status.js";
8
+ import { readClaimJournal } from "./claim-journal.js";
9
+ import { ConnectionNotReady, LfcpConnection } from "./connection.js";
7
10
  import { EngineGuard, isEngineTrap, snapshotItem } from "./engine-guard.js";
8
11
  import { resourceSyncState, snapshotFrontier } from "./outbound.js";
9
- import { createQueuedSnapshot } from "./queue.js";
12
+ import { controlRecordWrites, createQueuedSnapshot, keyPackageWrites } from "./queue.js";
13
+ import { intentsHash, OperationIdReusedError, receiptOf } from "./receipts.js";
14
+ import { reproposeControlRecords } from "./repropose.js";
10
15
  import { resourcePhaseTransition, } from "./resource-state.js";
11
- import { adoptStoredDeks, dekResolver, loadControlChain, saveControlChain, saveControlConflict, } from "./storage.js";
16
+ import { planRevocation, RevocationRefused, } from "./revoke.js";
17
+ import { receivedState, } from "./section-status.js";
18
+ import { adoptStoredDeks, commitOperation, dekResolver, loadControlChain, saveControlChain, saveControlConflict, } from "./storage.js";
19
+ import { accessState, NotWritableError, writeAccess, } from "./write-access.js";
20
+ /** Every status of a stored unit: any of them means the Resource has content. */
21
+ const STORED_STATUSES = [
22
+ "seen",
23
+ "merged",
24
+ "profile-pending",
25
+ "held",
26
+ "quarantined",
27
+ "equivocation",
28
+ "local-failure",
29
+ "profile-held",
30
+ "profile-rejected",
31
+ "profile-unsupported",
32
+ ];
33
+ /**
34
+ * SDK-SECTIONS-INTEGRATION-01 §3.6: a batch the SDK refuses before the
35
+ * profile sees it, with the same codes and fields as the profile's
36
+ * refusals. Nothing is written.
37
+ */
38
+ export class CommitRefusedError extends Error {
39
+ code;
40
+ intentIndex;
41
+ constructor(code, message, intentIndex) {
42
+ super(message);
43
+ this.code = code;
44
+ this.intentIndex = intentIndex;
45
+ this.name = "CommitRefusedError";
46
+ }
47
+ }
48
+ /** Access as a comparable key, to report it only when it changed (verifiedAt aside). */
49
+ const accessKey = (a) => JSON.stringify({ ...a, verifiedAt: null }, (_, v) => typeof v === "bigint" ? `${v}` : v instanceof Uint8Array ? toHex(v) : v);
12
50
  /** 1 s, doubling, at most 60 s, forever. */
13
51
  export const defaultReconnect = (attempt) => Math.min(60_000, 1000 * 2 ** (attempt - 1));
14
52
  /**
@@ -65,6 +103,8 @@ export class SyncClient {
65
103
  #sentAt = new Map();
66
104
  #requestTimeoutMs;
67
105
  #listeners = new Set();
106
+ /** Re-proposal attempts per Control Record (item ID hex), bounded; cleared on ACK (repropose.ts). */
107
+ #reproposeAttempts = new Map();
68
108
  #antiEntropyMs;
69
109
  /** The crash-loop breaker for Snapshot loads (EngineGuard). */
70
110
  #snapshots;
@@ -77,6 +117,10 @@ export class SyncClient {
77
117
  #maxMessageBytes = 8 * 1024 * 1024;
78
118
  /** Serializes message handling: each message is handled after the previous one finished. */
79
119
  #queue = Promise.resolve();
120
+ /** READY's durability in this session; null without one (§37). */
121
+ #readyDurability = null;
122
+ /** The status stream's revision per Resource (hex), in this session (§5). */
123
+ #statusRevision = new Map();
80
124
  constructor(options) {
81
125
  this.#o = options;
82
126
  this.#snapshots = new EngineGuard(options.storage, "snapshots");
@@ -105,10 +149,211 @@ export class SyncClient {
105
149
  for (const l of this.#listeners)
106
150
  l(event);
107
151
  }
152
+ // -------------------------------------------------------------------------
153
+ // Status (SDK-SECTIONS-INTEGRATION-01 §4, §5; LFCP-02-026)
154
+ #emitStatus(resource, body) {
155
+ const key = toHex(resource);
156
+ const revision = (this.#statusRevision.get(key) ?? 0) + 1;
157
+ this.#statusRevision.set(key, revision);
158
+ this.#emit({
159
+ type: "status",
160
+ resourceId: resource,
161
+ event: Object.freeze({ ...body, revision }),
162
+ });
163
+ }
164
+ /**
165
+ * §5: the complete status of `resource` at the stream's current revision:
166
+ * every unreleased batch, the received units that wait or were refused,
167
+ * the section state and write access. Events at or below its revision
168
+ * are already in it.
169
+ */
170
+ async statusSnapshot(resource) {
171
+ const ctx = this.#resources.get(toHex(resource));
172
+ const batches = await batchStatuses(this.#o.storage, resource, this.#readyDurability, (ctx?.refusal ?? null) !== null);
173
+ const received = await receivedState(this.#o.storage, resource);
174
+ const access = await this.accessState(resource);
175
+ const section = ctx?.binding.commit?.section?.() ?? "unknown";
176
+ if (ctx !== undefined) {
177
+ for (const b of batches)
178
+ ctx.reported.batches.set(b.operationId, b.status);
179
+ ctx.reported.access = accessKey(access);
180
+ ctx.reported.section = section === "unknown" ? null : section;
181
+ ctx.reported.catchUp = this.#catchUp(ctx);
182
+ }
183
+ return Object.freeze({
184
+ revision: this.#statusRevision.get(toHex(resource)) ?? 0,
185
+ batches: Object.freeze(batches),
186
+ received,
187
+ section,
188
+ access,
189
+ catchUp: Object.freeze({
190
+ state: ctx === undefined ? "not-started" : this.#catchUp(ctx),
191
+ checkedAt: ctx?.liveAt ?? null,
192
+ }),
193
+ });
194
+ }
195
+ /** The batches of `operations`, reported as they are now. */
196
+ async #reportBatches(resource, operations, finals = new Map()) {
197
+ const ctx = this.#resources.get(toHex(resource));
198
+ for (const op of operations) {
199
+ // A released batch forgotten at its final status: reported with that status.
200
+ const b = finals.get(op) ??
201
+ (await batchStatus(this.#o.storage, resource, op, this.#readyDurability, (ctx?.refusal ?? null) !== null));
202
+ if (b === undefined)
203
+ continue;
204
+ ctx?.reported.batches.set(op, b.status);
205
+ this.#emitStatus(resource, { kind: "batch", ...b });
206
+ }
207
+ }
208
+ /** §4.3: a received unit that is held, waits for a dependency, or was refused. */
209
+ #reportReceived(resource, outcome) {
210
+ switch (outcome.kind) {
211
+ case "profile-held":
212
+ this.#emitStatus(resource, { kind: "received", fact: "held", unitIds: [outcome.unitId] });
213
+ return;
214
+ case "profile-pending":
215
+ case "held":
216
+ this.#emitStatus(resource, {
217
+ kind: "received",
218
+ fact: "waiting",
219
+ unitIds: [outcome.unitId],
220
+ });
221
+ return;
222
+ case "profile-rejected":
223
+ this.#emitStatus(resource, {
224
+ kind: "received",
225
+ fact: "refused",
226
+ unitIds: [outcome.unitId],
227
+ diagnostic: outcome.message,
228
+ });
229
+ return;
230
+ }
231
+ }
232
+ /** Batches already reported whose status the session's durability changed (§4.1). */
233
+ async #refreshBatches() {
234
+ for (const ctx of this.#resources.values()) {
235
+ if (ctx.reported.batches.size === 0)
236
+ continue;
237
+ const R = ctx.binding.resourceId;
238
+ const changed = [];
239
+ for (const b of await batchStatuses(this.#o.storage, R, this.#readyDurability, ctx.refusal !== null)) {
240
+ const old = ctx.reported.batches.get(b.operationId);
241
+ if (old !== undefined && old !== b.status)
242
+ changed.push(b.operationId);
243
+ }
244
+ await this.#reportBatches(R, changed);
245
+ }
246
+ }
247
+ /**
248
+ * LFCP-02-115: the server refuses this client the Resource in this
249
+ * session (AUTHORIZATION_FAILED on RESOURCE_OPEN, the recovery of 106
250
+ * not run or not enough). Null otherwise.
251
+ */
252
+ #serverRefusal(ctx) {
253
+ if (ctx?.refusal?.code !== "AUTHORIZATION_FAILED")
254
+ return null;
255
+ return Object.freeze({ code: ctx.refusal.code, recovery: ctx.recoveryEnd });
256
+ }
257
+ /**
258
+ * After a refusal of the Resource, or an open that clears one: reports
259
+ * access, catch-up, and every batch whose status that changes (blocked
260
+ * while refused, pending again after).
261
+ */
262
+ async #reportRefusal(ctx) {
263
+ const R = ctx.binding.resourceId;
264
+ await this.#refreshAccess(ctx);
265
+ this.#reportCatchUp(ctx);
266
+ const changed = [];
267
+ for (const b of await batchStatuses(this.#o.storage, R, this.#readyDurability, ctx.refusal !== null)) {
268
+ const old = ctx.reported.batches.get(b.operationId);
269
+ if (old !== b.status && (old !== undefined || b.status === "blocked"))
270
+ changed.push(b.operationId);
271
+ }
272
+ await this.#reportBatches(R, changed);
273
+ }
274
+ /** Reports write access when it changed (§5 `access`). */
275
+ async #refreshAccess(ctx) {
276
+ const R = ctx.binding.resourceId;
277
+ const access = await this.accessState(R);
278
+ const key = accessKey(access);
279
+ if (key === ctx.reported.access)
280
+ return;
281
+ ctx.reported.access = key;
282
+ this.#emitStatus(R, { kind: "access", access });
283
+ }
284
+ /** LFCP-02-028: the catch-up fact of a Resource from its phase. */
285
+ #catchUp(ctx) {
286
+ // Refused by the server in this session: how current we are is unknown.
287
+ if (ctx.refusal !== null)
288
+ return "unknown";
289
+ switch (ctx.state) {
290
+ case "LIVE":
291
+ return "current-at-checkpoint";
292
+ case "OPENING":
293
+ case "CONTROL_SYNC":
294
+ case "KEY_SYNC":
295
+ case "DATA_SYNC":
296
+ return "receiving";
297
+ case "KEY_BLOCKED":
298
+ case "CONTROL_CONFLICT":
299
+ return "unknown";
300
+ case "CLOSED":
301
+ return ctx.liveAt === null ? "not-started" : "current-at-checkpoint";
302
+ }
303
+ }
304
+ /** Reports the catch-up fact when it changed. */
305
+ #reportCatchUp(ctx) {
306
+ const state = this.#catchUp(ctx);
307
+ if (state === ctx.reported.catchUp)
308
+ return;
309
+ ctx.reported.catchUp = state;
310
+ this.#emitStatus(ctx.binding.resourceId, {
311
+ kind: "catch-up",
312
+ state,
313
+ checkedAt: ctx.liveAt,
314
+ });
315
+ }
316
+ /** Reports the section state when it changed (§5 `section-state`). */
317
+ #refreshSection(ctx) {
318
+ const state = ctx.binding.commit?.section?.();
319
+ if (state === undefined || state === ctx.reported.section)
320
+ return;
321
+ ctx.reported.section = state;
322
+ this.#emitStatus(ctx.binding.resourceId, { kind: "section-state", state });
323
+ }
324
+ /**
325
+ * §4.1: what an ACK establishes for units of `resource`: acceptance when
326
+ * it answers our message (correlated), says durable at a READY level of
327
+ * 2 or more, and comes from a route of the Resource's current route set.
328
+ */
329
+ #ackEvidence(resource, durability, correlated) {
330
+ const route = this.#resources.get(toHex(resource))?.view?.state.route;
331
+ const onRoute = route !== undefined &&
332
+ (route.endpoints.some((e) => e.url === this.#o.url) || route.coordinatorUrl === this.#o.url);
333
+ return correlated && onRoute && durability >= ACCEPTANCE_DURABILITY
334
+ ? { kind: "accepted" }
335
+ : { kind: "unconfirmed" };
336
+ }
337
+ /** Records `evidence` for units (hex) of `resource` and reports the batches it changed. */
338
+ async #evidence(resource, units, evidence) {
339
+ const { writes, changed, finals } = await recordEvidence(this.#o.storage, resource, units, evidence);
340
+ if (writes.length === 0)
341
+ return changed;
342
+ const r = await this.#o.storage.commit(writes);
343
+ if (!r.ok)
344
+ throw new Error(`the batch status was not stored: ${r.reason}`);
345
+ await this.#reportBatches(resource, changed.keys(), finals);
346
+ return changed;
347
+ }
108
348
  #serial(fn) {
109
349
  this.#queue = this.#queue
110
350
  .then(() => (this.#trapped ? undefined : fn()))
111
351
  .catch((e) => {
352
+ // The connection closed while a handler worked: nothing was sent, and
353
+ // its closing (queued here) starts over at the next READY. An
354
+ // ordinary outcome, not an INTERNAL error.
355
+ if (e instanceof ConnectionNotReady)
356
+ return;
112
357
  if (!isEngineTrap(e)) {
113
358
  this.#emit({
114
359
  type: "error",
@@ -171,6 +416,11 @@ export class SyncClient {
171
416
  state: "CLOSED",
172
417
  wanted: true,
173
418
  view: null,
419
+ verified: null,
420
+ serverControlSeq: null,
421
+ liveAt: null,
422
+ lossReason: null,
423
+ reported: { catchUp: null, access: null, section: null, batches: new Map() },
174
424
  fetched: [],
175
425
  controlTarget: null,
176
426
  remoteHave: [],
@@ -193,12 +443,19 @@ export class SyncClient {
193
443
  offering: new Set(),
194
444
  keysOffered: false,
195
445
  rehosted: false,
446
+ recoveryTried: false,
447
+ recoveryAttempts: 0,
448
+ recoveryReopened: false,
449
+ recoveryEnd: null,
196
450
  };
197
451
  this.#resources.set(key, ctx);
198
452
  }
199
453
  ctx.wanted = true;
200
454
  // An explicit open asks the server again after a refusal.
455
+ const wasRefused = ctx.refusal !== null;
201
456
  ctx.refusal = null;
457
+ if (wasRefused)
458
+ this.#serial(() => this.#reportRefusal(ctx));
202
459
  ctx.refusedAttempt = 0;
203
460
  ctx.reopenAt = null;
204
461
  if (this.#connection.state === "READY")
@@ -261,8 +518,13 @@ export class SyncClient {
261
518
  ctx.wanted &&
262
519
  this.#connection.state === "READY")
263
520
  await this.#sendOpen(ctx);
264
- if (ctx.state === "LIVE" && now - ctx.lastHave >= this.#antiEntropyMs)
521
+ // Periodic anti-entropy (§68.1), both planes: the server's answer
522
+ // to CONTROL_HAVE also re-offers Control Records it still lacks,
523
+ // e.g. after a refused or lost re-supply.
524
+ if (ctx.state === "LIVE" && now - ctx.lastHave >= this.#antiEntropyMs) {
265
525
  this.#sendHave(ctx, now);
526
+ this.#refreshControl(ctx);
527
+ }
266
528
  if (ctx.state === "KEY_BLOCKED" && now - ctx.lastKeyRequest >= this.#antiEntropyMs)
267
529
  this.#requestKeys(ctx, now);
268
530
  await this.#flush(ctx);
@@ -277,10 +539,19 @@ export class SyncClient {
277
539
  // -------------------------------------------------------------------------
278
540
  // plumbing
279
541
  #request(request, message) {
542
+ try {
543
+ this.#connection.send(message);
544
+ }
545
+ catch (e) {
546
+ // Never waiting for an answer to what was not sent (the connection's
547
+ // closing already failed the requests it saw).
548
+ if (e instanceof ConnectionNotReady && request.kind === "host")
549
+ request.reject(new Error("the connection closed before RESOURCE_HOSTED: not READY"));
550
+ throw e;
551
+ }
280
552
  const key = toHex(message.messageId);
281
553
  this.#requests.set(key, request);
282
554
  this.#sentAt.set(key, this.#o.now());
283
- this.#connection.send(message);
284
555
  }
285
556
  /**
286
557
  * Requests unanswered for requestTimeoutMs on a live connection (§70: a
@@ -310,7 +581,9 @@ export class SyncClient {
310
581
  ctx?.offering.delete(u.id);
311
582
  continue;
312
583
  }
313
- if (request.kind === "offer-control" || request.kind === "offer-keys")
584
+ if (request.kind === "offer-control" ||
585
+ request.kind === "offer-keys" ||
586
+ request.kind === "recover-control")
314
587
  continue;
315
588
  if ("resource" in request)
316
589
  resend.set(`${request.kind}:${request.resource}`, request);
@@ -355,8 +628,13 @@ export class SyncClient {
355
628
  if (next === "LIVE") {
356
629
  ctx.refusedAttempt = 0;
357
630
  ctx.rehosted = false;
631
+ ctx.recoveryTried = false;
632
+ ctx.recoveryAttempts = 0;
358
633
  }
359
634
  this.#emit({ type: "resource-state", resourceId: ctx.binding.resourceId, state: next });
635
+ if (next === "LIVE")
636
+ ctx.liveAt = this.#o.now();
637
+ this.#reportCatchUp(ctx);
360
638
  if (next === "LIVE" && ctx.controlStale) {
361
639
  ctx.controlStale = false;
362
640
  this.#refreshControl(ctx);
@@ -383,12 +661,16 @@ export class SyncClient {
383
661
  durability: ready.durability,
384
662
  maxMessageBytes: ready.maxMessageBytes,
385
663
  });
664
+ this.#readyDurability = ready.durability;
665
+ await this.#refreshBatches();
386
666
  for (const ctx of this.#resources.values())
387
667
  if (ctx.wanted)
388
668
  await this.#sendOpen(ctx);
389
669
  }
390
670
  async #onClosed(reason) {
391
671
  this.#emit({ type: "connection", state: "DISCONNECTED", reason });
672
+ this.#readyDurability = null;
673
+ await this.#refreshBatches();
392
674
  for (const r of this.#requests.values())
393
675
  if (r.kind === "host")
394
676
  r.reject(new Error(`the connection closed before RESOURCE_HOSTED: ${reason}`));
@@ -398,6 +680,7 @@ export class SyncClient {
398
680
  this.#move(ctx, "CLOSE"); // §65, G-SM1: every Resource closes with the connection
399
681
  ctx.view = null;
400
682
  ctx.offering.clear();
683
+ ctx.recoveryTried = false; // a new session may recover again (LFCP-02-106)
401
684
  }
402
685
  await this.#o.outbound.connectionLost(iso(this.#o.now()));
403
686
  if (this.#stopped)
@@ -448,6 +731,8 @@ export class SyncClient {
448
731
  url: this.#o.url,
449
732
  outcome: "hosted",
450
733
  });
734
+ ctx.lossReason ??= "rehost";
735
+ this.#emitStatus(ctx.binding.resourceId, { kind: "rehost", route: this.#o.url });
451
736
  if (ctx.wanted)
452
737
  await this.#sendOpen(ctx);
453
738
  }
@@ -460,7 +745,12 @@ export class SyncClient {
460
745
  ctx.remoteHave = normalizeLiveHaves(m.body.haves);
461
746
  ctx.offered = m.body.snapshot ?? null;
462
747
  await this.#markHosted(ctx.binding.resourceId);
748
+ if (ctx.recoveryReopened) {
749
+ ctx.recoveryReopened = false;
750
+ this.#recoveryEvent(ctx, "recovered");
751
+ }
463
752
  this.#move(ctx, "OPENED");
753
+ await this.#noteServerHeads(ctx, m.body.heads);
464
754
  // §68.1, §88 step 6: what the server lacks goes first, Control Records before units.
465
755
  await this.#offerControl(ctx, m.body.heads);
466
756
  await this.#offerData(ctx);
@@ -470,6 +760,8 @@ export class SyncClient {
470
760
  case "CONTROL_HAVE": {
471
761
  const ctx = this.#ctx(m.body.resourceId);
472
762
  this.#done(m);
763
+ if (ctx !== undefined)
764
+ await this.#noteServerHeads(ctx, m.body.heads);
473
765
  if (ctx !== undefined && ctx.state === "LIVE")
474
766
  await this.#onControlHeads(ctx, m.body.heads);
475
767
  return;
@@ -522,11 +814,25 @@ export class SyncClient {
522
814
  return;
523
815
  }
524
816
  case "ACK":
817
+ if (request?.kind === "recover-control") {
818
+ this.#done(m);
819
+ await this.#recoveryAccepted(request);
820
+ return;
821
+ }
525
822
  if (request !== undefined) {
526
823
  this.#done(m);
527
- if (request.kind === "offer-data")
824
+ if (request.kind === "offer-data") {
825
+ const ctx = this.#resources.get(request.resource);
528
826
  for (const u of request.units)
529
- this.#resources.get(request.resource)?.offering.delete(u.id);
827
+ ctx?.offering.delete(u.id);
828
+ if (ctx !== undefined) {
829
+ // §4.2: a re-offered unit of ours is accepted again by this ACK.
830
+ const named = new Set((m.body.objectIds ?? []).map((id) => toHex(id)));
831
+ const durability = m.body.durable === true ? (this.#readyDurability ?? 0n) : 0n;
832
+ const R = ctx.binding.resourceId;
833
+ await this.#evidence(R, request.units.map((u) => u.id).filter((id) => named.has(id)), this.#ackEvidence(R, durability, true));
834
+ }
835
+ }
530
836
  return;
531
837
  }
532
838
  await this.#onAck(m);
@@ -565,6 +871,20 @@ export class SyncClient {
565
871
  if (ctx !== undefined && (await this.#rehost(ctx)))
566
872
  return;
567
873
  }
874
+ if (request.kind === "open" && code === "AUTHORIZATION_FAILED") {
875
+ const ctx = this.#resources.get(request.resource);
876
+ if (ctx?.recoveryReopened) {
877
+ // The server holds our chain and still refuses: not a loss.
878
+ ctx.recoveryReopened = false;
879
+ this.#recoveryEvent(ctx, "ended", "still-refused");
880
+ }
881
+ else if (ctx !== undefined && (await this.#recoverAccess(ctx)))
882
+ return;
883
+ }
884
+ if (request.kind === "recover-control") {
885
+ await this.#recoveryRefused(request, code, m.body.details);
886
+ return;
887
+ }
568
888
  if (request.kind === "rehost") {
569
889
  const ctx = this.#resources.get(request.resource);
570
890
  if (ctx === undefined)
@@ -627,6 +947,8 @@ export class SyncClient {
627
947
  this.#request({ kind: "close", resource: toHex(ctx.binding.resourceId) }, createMessage("RESOURCE_CLOSE", { resourceId: ctx.binding.resourceId }));
628
948
  this.#move(ctx, "CLOSE");
629
949
  this.#emit({ type: "resource-refused", resourceId: ctx.binding.resourceId, refusal });
950
+ // LFCP-02-115: the status stream says so too.
951
+ this.#serial(() => this.#reportRefusal(ctx));
630
952
  }
631
953
  /** CLOSED now, opened again after the ReconnectPolicy's delay for this Resource's attempt. */
632
954
  #retryLater(ctx) {
@@ -736,9 +1058,11 @@ export class SyncClient {
736
1058
  const R = ctx.binding.resourceId;
737
1059
  const before = previous ?? ctx.view;
738
1060
  ctx.view = chain;
1061
+ ctx.verified = { head: chain.state.head, at: this.#o.now() };
739
1062
  // DEKs we already hold for epochs the saved chain now has (our own
740
1063
  // rotation, or a package that came before the row): no request needed.
741
1064
  await adoptStoredDeks(this.#o.storage, this.#o.secrets, chain);
1065
+ await this.#refreshAccess(ctx);
742
1066
  const epochsChanged = before === null ||
743
1067
  [...chain.state.epochs.values()].some((e) => {
744
1068
  const old = before.state.epochs.get(String(e.epoch));
@@ -755,6 +1079,12 @@ export class SyncClient {
755
1079
  }
756
1080
  }
757
1081
  await this.#o.outbound.controlSynced(R);
1082
+ // §47, §21: on the fresh head, re-propose our own Control Records that a
1083
+ // moved head refused (CONTROL_HEAD_MISMATCH) and recover any persisted
1084
+ // "repropose" items (a restart, or an older SDK). Runs here, under the
1085
+ // serial lock, on every control sync — so it covers both the start of an
1086
+ // open and a sync triggered by a repropose NACK; it is idempotent.
1087
+ await this.#reproposeControl(ctx);
758
1088
  if (ctx.state === "CONTROL_SYNC") {
759
1089
  this.#move(ctx, "CONTROL_COMPLETE");
760
1090
  this.#requestKeys(ctx, this.#o.now());
@@ -763,6 +1093,29 @@ export class SyncClient {
763
1093
  this.#requestKeys(ctx, this.#o.now());
764
1094
  }
765
1095
  }
1096
+ /**
1097
+ * Re-proposes this client's blocked "repropose" Control Records of `ctx`'s
1098
+ * Resource on the current local head, re-sealing their dependent Key
1099
+ * Packages, and flushes the re-authored objects. Bounded and idempotent
1100
+ * (repropose.ts); a dropped record (no longer authorized, or a dependent's
1101
+ * key is not held) is surfaced so the application can act.
1102
+ */
1103
+ async #reproposeControl(ctx) {
1104
+ const R = ctx.binding.resourceId;
1105
+ const { outcomes } = await reproposeControlRecords({
1106
+ storage: this.#o.storage,
1107
+ secrets: this.#o.secrets,
1108
+ signer: this.#o.signer,
1109
+ resource: R,
1110
+ queue: this.#o.outbound,
1111
+ attempts: this.#reproposeAttempts,
1112
+ });
1113
+ if (outcomes.length === 0)
1114
+ return;
1115
+ this.#emit({ type: "repropose", resourceId: R, outcomes });
1116
+ if (outcomes.some((o) => o.kind === "reproposed"))
1117
+ await this.#flush(ctx);
1118
+ }
766
1119
  // -------------------------------------------------------------------------
767
1120
  // Keys (§52, §53, §66 step 2)
768
1121
  async #epochsWithoutDek(ctx) {
@@ -840,6 +1193,7 @@ export class SyncClient {
840
1193
  }
841
1194
  await this.#o.storage.commit(writes);
842
1195
  }
1196
+ await this.#refreshAccess(ctx);
843
1197
  const missing = await this.#epochsWithoutDek(ctx);
844
1198
  const current = view.state.epoch.epoch;
845
1199
  if (missing.some((e) => e === current)) {
@@ -988,6 +1342,247 @@ export class SyncClient {
988
1342
  * that loads it never skips content the state does not hold. Queued and
989
1343
  * sent like any object; returns its ID.
990
1344
  */
1345
+ /**
1346
+ * LFCP-02-060: removes `subject`'s access to `resource`. Every active
1347
+ * grant naming it that this client may revoke is revoked (§17.3); when it
1348
+ * has no read access left, the Data Epoch is rotated with a fresh DEK
1349
+ * (§19, member revoked) and the DEK goes in Key Packages to every other
1350
+ * remaining reader (§25), so it cannot read new data. Built on the
1351
+ * validated head and checked before anything is queued; the records, the
1352
+ * Key Packages and (first) the new DEK are written at once, then sent.
1353
+ * Refused, with nothing queued, unless the Resource is live and current
1354
+ * here and no Control Record of this client is pending.
1355
+ */
1356
+ revokeAccess(resource, subject, options = {}) {
1357
+ return new Promise((resolve, reject) => this.#serial(async () => {
1358
+ try {
1359
+ resolve(await this.#revoke(resource, subject, options));
1360
+ }
1361
+ catch (e) {
1362
+ reject(e);
1363
+ }
1364
+ }));
1365
+ }
1366
+ async #revoke(resource, subject, options) {
1367
+ const refused = (reason, message) => Object.freeze({ kind: "refused", reason, message });
1368
+ const ctx = this.#resources.get(toHex(resource));
1369
+ const view = ctx?.view ?? null;
1370
+ if (ctx === undefined || ctx.state !== "LIVE" || view === null)
1371
+ return refused("offline", "the Resource is not live in this session");
1372
+ if (ctx.serverControlSeq !== null && view.state.seq < ctx.serverControlSeq)
1373
+ return refused("stale", "the validated Control Chain is behind the server's");
1374
+ const storage = this.#o.storage;
1375
+ if ((await storage.outbound.list(resource)).some((i) => i.kind === "control-record"))
1376
+ return refused("control-pending", "Control Records of this client are not committed yet");
1377
+ let plan;
1378
+ try {
1379
+ plan = await planRevocation({
1380
+ view,
1381
+ revoker: this.#o.signer,
1382
+ subject,
1383
+ frontier: (await resourceSyncState(storage, resource)).have,
1384
+ ...(options.rotate === undefined ? {} : { rotate: options.rotate }),
1385
+ });
1386
+ }
1387
+ catch (e) {
1388
+ if (e instanceof RevocationRefused)
1389
+ return refused(e.reason, e.message);
1390
+ throw e;
1391
+ }
1392
+ // The new DEK first (LFCP-034), then everything that names it, at once.
1393
+ if (plan.rotation !== null)
1394
+ await this.#o.secrets.put(dekSecretRef(resource, plan.rotation.epoch), exportSecretKeyBytes(plan.rotation.dek));
1395
+ const r = await storage.commit([
1396
+ ...plan.records.flatMap((rec) => controlRecordWrites(rec.bytes)),
1397
+ ...plan.keyPackages.flatMap(keyPackageWrites),
1398
+ ]);
1399
+ if (!r.ok)
1400
+ throw new Error(`the revocation was not queued: ${r.reason}`);
1401
+ await this.#refreshAccess(ctx);
1402
+ await this.#flush(ctx);
1403
+ return Object.freeze({
1404
+ kind: "queued",
1405
+ revoked: plan.revoked,
1406
+ deactivated: plan.deactivated,
1407
+ recordIds: Object.freeze(plan.records.map((rec) => rec.recordId)),
1408
+ epoch: plan.rotation?.epoch ?? null,
1409
+ recipients: plan.recipients,
1410
+ remainingPaths: plan.remainingPaths,
1411
+ });
1412
+ }
1413
+ /**
1414
+ * SDK-SECTIONS-INTEGRATION-01 §3.1: commits a batch of intents on an open
1415
+ * Resource and returns its receipt once the batch is durable: its Data
1416
+ * Units, outbound entries, the profile checkpoint and the receipt are
1417
+ * stored in one transaction, then the batch is sent. An operation that
1418
+ * has a receipt returns it for the same intents and writes nothing, and
1419
+ * throws OperationIdReusedError for different ones (§3.3). Runs between
1420
+ * received units, never during their merge.
1421
+ */
1422
+ commit(resource, intents, options) {
1423
+ const ctx = this.#resources.get(toHex(resource));
1424
+ if (ctx === undefined)
1425
+ return Promise.reject(new LfcpError("UNSUPPORTED_VALUE", "the Resource is not open here"));
1426
+ const binding = ctx.binding.commit;
1427
+ if (binding === undefined)
1428
+ return Promise.reject(new LfcpError("UNSUPPORTED_VALUE", "the Resource has no commit binding"));
1429
+ return new Promise((resolve, reject) => this.#serial(async () => {
1430
+ try {
1431
+ resolve(await this.#commit(ctx, binding, intents, options.operationId));
1432
+ }
1433
+ catch (e) {
1434
+ reject(e);
1435
+ }
1436
+ }));
1437
+ }
1438
+ /**
1439
+ * SDK-SECTIONS-INTEGRATION-01 §6: whether this client may commit to
1440
+ * `resource`, from the validated Control state only: the chain of the
1441
+ * last Control sync, or the stored chain validated now. `verifiedAt` is
1442
+ * the local time of that validation (the last sync's when the stored
1443
+ * chain is still at its head). Unknown without a validated chain.
1444
+ */
1445
+ async canWrite(resource) {
1446
+ const ctx = this.#resources.get(toHex(resource));
1447
+ const view = ctx?.view ?? (await loadControlChain(this.#o.storage, resource));
1448
+ return this.#access(resource, view, ctx?.verified ?? null, this.#serverRefusal(ctx));
1449
+ }
1450
+ /**
1451
+ * LFCP-02-027: write access with its evidence: the Control sequence it
1452
+ * was validated at against the highest one a server reported in this
1453
+ * session (a stale view is not current), the effective abilities and
1454
+ * the active grant paths giving them, and apart from them the
1455
+ * invitations we issued, our Control Records not yet committed (SI14)
1456
+ * and an unsettled invitation claim of ours.
1457
+ */
1458
+ async accessState(resource) {
1459
+ const ctx = this.#resources.get(toHex(resource));
1460
+ const view = ctx?.view ?? (await loadControlChain(this.#o.storage, resource));
1461
+ const refusal = this.#serverRefusal(ctx);
1462
+ const write = await this.#access(resource, view, ctx?.verified ?? null, refusal);
1463
+ const pendingControl = [];
1464
+ for (const item of await this.#o.storage.outbound.list(resource)) {
1465
+ if (item.kind !== "control-record")
1466
+ continue;
1467
+ const record = decodeControlRecord(item.bytes);
1468
+ pendingControl.push(Object.freeze({ recordId: controlRecordId(record.signed.id), type: record.body.type }));
1469
+ }
1470
+ const dek = view?.kind === "linear" &&
1471
+ (await this.#currentDek(resource, view.state.epoch.epoch)) !== undefined;
1472
+ return accessState(view, this.#o.signer.descriptor.principalId, dek, write.verifiedAt ?? 0, {
1473
+ serverControlSeq: ctx?.serverControlSeq ?? null,
1474
+ pendingControl,
1475
+ pendingClaim: (await readClaimJournal(this.#o.storage, resource)) !== undefined,
1476
+ serverRefusal: refusal,
1477
+ });
1478
+ }
1479
+ /** Records the highest Control sequence a server reported, and reports access if it changed. */
1480
+ async #noteServerHeads(ctx, heads) {
1481
+ const seq = heads.reduce((m, h) => (m === null || h.seq > m ? h.seq : m), null);
1482
+ if (seq === null || (ctx.serverControlSeq !== null && seq <= ctx.serverControlSeq))
1483
+ return;
1484
+ ctx.serverControlSeq = seq;
1485
+ await this.#refreshAccess(ctx);
1486
+ }
1487
+ /**
1488
+ * The DEK of `epoch`, or undefined when it is not held or the key store
1489
+ * cannot be read now (LFCP-02-030): an unreadable key store is
1490
+ * key-unavailable, never a reason to write without it or to reset state.
1491
+ */
1492
+ async #currentDek(resource, epoch) {
1493
+ try {
1494
+ return await dekResolver(this.#o.storage, this.#o.secrets, resource)(epoch);
1495
+ }
1496
+ catch {
1497
+ return undefined;
1498
+ }
1499
+ }
1500
+ async #access(resource, view, verified, refusal = null) {
1501
+ if (view?.kind !== "linear")
1502
+ return writeAccess(view, this.#o.signer.descriptor.principalId, false, 0);
1503
+ const dek = await this.#currentDek(resource, view.state.epoch.epoch);
1504
+ const at = verified !== null && bytesEqual(verified.head, view.state.head) ? verified.at : this.#o.now();
1505
+ const access = writeAccess(view, this.#o.signer.descriptor.principalId, dek !== undefined, at);
1506
+ // LFCP-02-115: the server refuses us the Resource; the local chain is not the last word.
1507
+ return refusal === null
1508
+ ? access
1509
+ : Object.freeze({ ...access, allowed: false, reason: "server-refused" });
1510
+ }
1511
+ /**
1512
+ * SHARED-SECTIONS-PROFILE-01 §13, in depth behind the adapter's own
1513
+ * check: an incomplete load never becomes a new, empty section. A batch
1514
+ * that creates a section is refused (SECTION_EXISTS) when units of the
1515
+ * Resource are stored (content exists that the model may not hold yet),
1516
+ * or when this client is not the Resource's owner and the Resource was
1517
+ * not live in this session (it has not caught up). The owner creates its
1518
+ * new Resource's section offline.
1519
+ */
1520
+ async #guardCreate(ctx, view, intents) {
1521
+ const index = intents.findIndex((i) => typeof i === "object" &&
1522
+ i !== null &&
1523
+ i.intent === "section.create");
1524
+ if (index < 0)
1525
+ return;
1526
+ const R = ctx.binding.resourceId;
1527
+ const stored = (await Promise.all(STORED_STATUSES.map((st) => this.#o.storage.dataUnits.withStatus(R, st)))).some((units) => units.length > 0);
1528
+ if (stored)
1529
+ throw new CommitRefusedError("SECTION_EXISTS", "units of this Resource are stored: it is not a new section (§13)", index);
1530
+ const owner = bytesEqual(view.state.owner.principalId, this.#o.signer.descriptor.principalId);
1531
+ if (!owner && ctx.liveAt === null)
1532
+ throw new CommitRefusedError("SECTION_EXISTS", "the Resource has not caught up in this session: its section may exist (§13)", index);
1533
+ }
1534
+ async #commit(ctx, binding, intents, operationId) {
1535
+ const R = ctx.binding.resourceId;
1536
+ const storage = this.#o.storage;
1537
+ // §3.3: a committed operation answers first; its intents may no longer apply.
1538
+ const known = await receiptOf(storage, R, operationId);
1539
+ if (known !== undefined) {
1540
+ if (known.intentsHash !== intentsHash(intents))
1541
+ throw new OperationIdReusedError(operationId);
1542
+ return known;
1543
+ }
1544
+ const view = ctx.view ?? (await loadControlChain(storage, R));
1545
+ // §3.6, §6: a batch submitted while writing is not allowed writes nothing.
1546
+ const access = await this.#access(R, view, ctx.verified, this.#serverRefusal(ctx));
1547
+ if (!access.allowed || view?.kind !== "linear")
1548
+ throw new NotWritableError(access);
1549
+ const dek = await this.#currentDek(R, view.state.epoch.epoch);
1550
+ if (dek === undefined)
1551
+ throw new NotWritableError({ ...access, allowed: false, reason: "key-unavailable" });
1552
+ await this.#guardCreate(ctx, view, intents);
1553
+ const staged = binding.stage(intents);
1554
+ staged?.apply();
1555
+ try {
1556
+ const { receipt, committed } = await commitOperation(storage, {
1557
+ view,
1558
+ controlHead: view.state.head,
1559
+ actor: this.#o.signer,
1560
+ dek,
1561
+ profile: binding.codec,
1562
+ operationId,
1563
+ intents,
1564
+ values: staged?.values ?? [],
1565
+ affectedNodeIds: staged?.affectedNodeIds ?? [],
1566
+ modelRevision: staged?.modelRevision ?? "",
1567
+ }, (units) => staged?.writes(units) ?? []);
1568
+ if (!committed)
1569
+ staged?.revert();
1570
+ else
1571
+ staged?.committed(receipt.unitIds);
1572
+ if (committed) {
1573
+ // Our own units grow the history a Snapshot covers, as received ones do.
1574
+ ctx.unitsSinceSnapshot += receipt.unitIds.length;
1575
+ await this.#reportBatches(R, [receipt.operationId]);
1576
+ this.#refreshSection(ctx);
1577
+ }
1578
+ await this.#flush(ctx);
1579
+ return receipt;
1580
+ }
1581
+ catch (e) {
1582
+ staged?.revert();
1583
+ throw e;
1584
+ }
1585
+ }
991
1586
  async publishSnapshot(resource) {
992
1587
  const ctx = this.#resources.get(toHex(resource));
993
1588
  if (ctx === undefined)
@@ -1142,16 +1737,20 @@ export class SyncClient {
1142
1737
  if (outcome.kind === "rejected" && outcome.wireCode === "MISSING_DEPENDENCY")
1143
1738
  needControl = true;
1144
1739
  this.#emit({ type: "unit", resourceId: R, outcome });
1740
+ this.#reportReceived(R, outcome);
1145
1741
  // Held units this one released (§26.2): their outcomes too.
1146
1742
  const released = "released" in outcome ? outcome.released : [];
1147
1743
  for (const r of released) {
1148
1744
  if (r.kind === "applied" || r.kind === "profile-pending")
1149
1745
  changed = true;
1150
1746
  this.#emit({ type: "unit", resourceId: R, outcome: r });
1747
+ this.#reportReceived(R, r);
1151
1748
  }
1152
1749
  }
1153
- if (changed)
1750
+ if (changed) {
1154
1751
  ctx.binding.checkpointer?.noteChange();
1752
+ this.#refreshSection(ctx);
1753
+ }
1155
1754
  if (needControl)
1156
1755
  this.#refreshControl(ctx);
1157
1756
  if (ctx.state === "DATA_SYNC" &&
@@ -1189,23 +1788,119 @@ export class SyncClient {
1189
1788
  this.#connection.sendEncoded(m.bytes);
1190
1789
  }
1191
1790
  async #onAck(m) {
1192
- const outcome = await this.#o.outbound.onAck(m, iso(this.#o.now()));
1791
+ if (m.body.requestType === MESSAGE_TYPE.CONTROL_PUT)
1792
+ await this.#adoptAcked(m);
1793
+ // §4.1: acceptance is recorded in the dequeue's transaction.
1794
+ const changed = [];
1795
+ const outcome = await this.#o.outbound.onAck(m, iso(this.#o.now()), async (items, ack) => {
1796
+ const byResource = new Map();
1797
+ for (const item of items) {
1798
+ if (item.kind !== "data-unit")
1799
+ continue;
1800
+ const key = toHex(item.resourceId);
1801
+ const entry = byResource.get(key) ?? { R: item.resourceId, units: [] };
1802
+ entry.units.push(toHex(item.itemId));
1803
+ byResource.set(key, entry);
1804
+ }
1805
+ const writes = [];
1806
+ for (const { R, units } of byResource.values()) {
1807
+ const evidence = this.#ackEvidence(R, ack.durability, ack.correlated);
1808
+ const r = await recordEvidence(this.#o.storage, R, units, evidence);
1809
+ writes.push(...r.writes);
1810
+ if (r.changed.size > 0)
1811
+ changed.push([R, r.changed, r.finals]);
1812
+ }
1813
+ return writes;
1814
+ });
1815
+ for (const [R, ops, finals] of changed)
1816
+ await this.#reportBatches(R, ops.keys(), finals);
1193
1817
  this.#emit({ type: "ack", outcome });
1194
1818
  // Our own Control Record was committed: catch up, since the server does not push it back to us.
1195
- if (m.body.requestType === MESSAGE_TYPE.CONTROL_PUT && outcome.acked.length > 0)
1819
+ if (m.body.requestType === MESSAGE_TYPE.CONTROL_PUT && outcome.acked.length > 0) {
1820
+ // A committed record is settled: clear its bounded re-proposal count.
1821
+ for (const id of outcome.acked)
1822
+ this.#reproposeAttempts.delete(toHex(id));
1196
1823
  for (const ctx of this.#resources.values())
1197
1824
  if (ctx.state === "LIVE")
1198
1825
  this.#refreshControl(ctx);
1199
1826
  else
1200
1827
  ctx.controlStale = true;
1828
+ }
1829
+ }
1830
+ /**
1831
+ * An ACK of our CONTROL_PUT (§47) means the server committed the record
1832
+ * on the head it expected: our own previous record. The record joins the
1833
+ * stored chain the way a fetched one does, BEFORE it leaves the queue, so
1834
+ * it is never held by the server alone: a stop between the ACK and the
1835
+ * catch-up fetch, then a server restored from an older copy, would
1836
+ * otherwise lose it (LFCP-02-106). A crash before the dequeue only sends
1837
+ * it again, and a repeated ACK is idempotent. A record that does not
1838
+ * continue the stored chain is left to the catch-up fetch.
1839
+ */
1840
+ async #adoptAcked(m) {
1841
+ for (const id of m.body.objectIds ?? []) {
1842
+ const item = await this.#o.storage.outbound.get(id);
1843
+ if (item?.kind !== "control-record")
1844
+ continue;
1845
+ const ctx = this.#ctx(item.resourceId);
1846
+ if (ctx === undefined)
1847
+ continue;
1848
+ await this.#onControlRecords(ctx, [item.bytes], false);
1849
+ }
1201
1850
  }
1202
1851
  async #onNack(m) {
1203
1852
  const outcome = await this.#o.outbound.onNack(m, iso(this.#o.now()));
1853
+ // §4.1: a terminal NACK rejects the batch of the unit.
1854
+ if (outcome.kind === "rejected" ||
1855
+ outcome.kind === "stale" ||
1856
+ outcome.kind === "equivocation-alarm") {
1857
+ const code = outcome.kind === "rejected"
1858
+ ? outcome.code
1859
+ : outcome.kind === "stale"
1860
+ ? "STALE_DATA_EPOCH"
1861
+ : "ACTOR_EQUIVOCATION";
1862
+ const byResource = new Map();
1863
+ for (const item of outcome.items) {
1864
+ if (item.kind !== "data-unit")
1865
+ continue;
1866
+ const R = (await this.#o.storage.outbound.get(item.itemId))?.resourceId;
1867
+ if (R === undefined)
1868
+ continue;
1869
+ const entry = byResource.get(toHex(R)) ?? { R, units: [] };
1870
+ entry.units.push(toHex(item.itemId));
1871
+ byResource.set(toHex(R), entry);
1872
+ }
1873
+ for (const { R, units } of byResource.values())
1874
+ await this.#evidence(R, units, { kind: "rejected", code });
1875
+ }
1876
+ if (outcome.kind === "needs-offer") {
1877
+ const ctx = this.#ctx(outcome.resourceId);
1878
+ if (ctx !== undefined)
1879
+ ctx.lossReason ??= "unknown-previous";
1880
+ }
1204
1881
  this.#emit({ type: "nack", outcome });
1882
+ // MISSING_DEPENDENCY: the server lacks our Control records; a sync releases the held puts.
1205
1883
  if (outcome.kind === "needs-control-sync")
1206
1884
  for (const ctx of this.#resources.values())
1207
1885
  if (ctx.state === "LIVE")
1208
1886
  this.#refreshControl(ctx);
1887
+ // CONTROL_HEAD_MISMATCH: re-author the refused record on the current head
1888
+ // (§47, §21). When we already hold that head (e.g. the winning record was
1889
+ // pushed to us live, §65), a CONTROL_GET would return nothing and never
1890
+ // reach #controlComplete, so re-propose here directly; otherwise sync
1891
+ // first and #controlComplete drives it.
1892
+ if (outcome.kind === "repropose")
1893
+ for (const ctx of this.#resources.values()) {
1894
+ if (ctx.state !== "LIVE")
1895
+ continue;
1896
+ const head = await this.#o.storage.control.head(ctx.binding.resourceId);
1897
+ if (outcome.currentHead !== null &&
1898
+ head !== undefined &&
1899
+ bytesEqual(head.head, outcome.currentHead))
1900
+ await this.#reproposeControl(ctx);
1901
+ else
1902
+ this.#refreshControl(ctx);
1903
+ }
1209
1904
  // §51.1: the server lost what our unit names. Its DATA_HAVE answer
1210
1905
  // starts the offer (§68.1), which releases the held items.
1211
1906
  if (outcome.kind === "needs-offer") {
@@ -1248,6 +1943,94 @@ export class SyncClient {
1248
1943
  this.#request({ kind: "rehost", resource: toHex(R) }, createMessage("RESOURCE_HOST", { genesis: genesis.signed.bytes }));
1249
1944
  return true;
1250
1945
  }
1946
+ /**
1947
+ * LFCP-02-106: RESOURCE_OPEN was refused with AUTHORIZATION_FAILED. When
1948
+ * our validated chain grants us data/read, the server may have lost the
1949
+ * records that do (a restore): push our head, and follow the server's
1950
+ * answer (see access-recovery.ts). False when no recovery runs; the
1951
+ * refusal then stands as before.
1952
+ */
1953
+ async #recoverAccess(ctx) {
1954
+ if (ctx.recoveryTried || this.#connection.state !== "READY")
1955
+ return false;
1956
+ const chain = await loadControlChain(this.#o.storage, ctx.binding.resourceId);
1957
+ if (chain?.kind !== "linear")
1958
+ return false;
1959
+ const step = startRecovery(chain.state, chain.records, this.#o.signer.descriptor.principalId);
1960
+ ctx.recoveryTried = true;
1961
+ if (step.kind !== "push") {
1962
+ this.#recoveryEvent(ctx, "ended", step.kind === "final" ? step.reason : undefined);
1963
+ return false;
1964
+ }
1965
+ this.#recoveryEvent(ctx, "started");
1966
+ this.#move(ctx, "CLOSE");
1967
+ await this.#recoveryStep(ctx, step);
1968
+ return true;
1969
+ }
1970
+ async #recoveryStep(ctx, step) {
1971
+ const R = ctx.binding.resourceId;
1972
+ if (step.kind === "reopen") {
1973
+ ctx.recoveryReopened = true;
1974
+ if (ctx.wanted)
1975
+ await this.#sendOpen(ctx);
1976
+ return;
1977
+ }
1978
+ if (step.kind === "final") {
1979
+ this.#recoveryEvent(ctx, "ended", step.reason);
1980
+ this.#refuse(ctx, { code: "AUTHORIZATION_FAILED", url: this.#o.url, request: "open" });
1981
+ return;
1982
+ }
1983
+ if (this.#connection.state !== "READY")
1984
+ return; // the next session opens and may recover again
1985
+ const chain = await loadControlChain(this.#o.storage, R);
1986
+ const record = chain?.kind === "linear" ? chain.records[step.index] : undefined;
1987
+ const previous = record?.payload.prevControlId ?? null;
1988
+ if (record === undefined || previous === null) {
1989
+ await this.#recoveryStep(ctx, { kind: "final", reason: "refused" });
1990
+ return;
1991
+ }
1992
+ this.#request({ kind: "recover-control", resource: toHex(R), index: step.index }, createMessage("CONTROL_PUT", {
1993
+ resourceId: R,
1994
+ expectedHead: previous,
1995
+ record: record.signed.bytes,
1996
+ }));
1997
+ }
1998
+ async #recoveryAccepted(request) {
1999
+ const ctx = this.#resources.get(request.resource);
2000
+ if (ctx === undefined)
2001
+ return;
2002
+ const chain = await loadControlChain(this.#o.storage, ctx.binding.resourceId);
2003
+ if (chain?.kind !== "linear")
2004
+ return;
2005
+ await this.#recoveryStep(ctx, afterAccepted(chain.records, request.index));
2006
+ }
2007
+ async #recoveryRefused(request, code, details) {
2008
+ const ctx = this.#resources.get(request.resource);
2009
+ if (ctx === undefined)
2010
+ return;
2011
+ if (RECOVERY_TRANSIENT_CODES.has(code) && ctx.recoveryAttempts < RECOVERY_TRANSIENT_ATTEMPTS) {
2012
+ // Open again after a backoff; the refused open recovers again.
2013
+ ctx.recoveryAttempts += 1;
2014
+ ctx.recoveryTried = false;
2015
+ this.#retryLater(ctx);
2016
+ return;
2017
+ }
2018
+ const chain = await loadControlChain(this.#o.storage, ctx.binding.resourceId);
2019
+ const step = code === "CONTROL_HEAD_MISMATCH" && chain?.kind === "linear"
2020
+ ? afterMismatch(chain.records, details)
2021
+ : { kind: "final", reason: "refused" };
2022
+ await this.#recoveryStep(ctx, step);
2023
+ }
2024
+ #recoveryEvent(ctx, outcome, reason) {
2025
+ ctx.recoveryEnd = outcome === "ended" ? (reason ?? null) : null;
2026
+ this.#emit({
2027
+ type: "access-recovery",
2028
+ resourceId: ctx.binding.resourceId,
2029
+ url: this.#o.url,
2030
+ outcome,
2031
+ ...(reason === undefined ? {} : { reason }),
2032
+ });
2033
+ }
1251
2034
  /**
1252
2035
  * §68.1: the server's Control Head is a record we hold below our own head:
1253
2036
  * upload the records above it, one CONTROL_PUT each, in order, each
@@ -1302,9 +2085,19 @@ export class SyncClient {
1302
2085
  !queued.has(toHex(u.unitId)) &&
1303
2086
  !ctx.offering.has(toHex(u.unitId)));
1304
2087
  sent += units.length;
2088
+ // §4.2: units of our batches the server lost are pending again.
2089
+ const lost = await this.#evidence(R, units.map((u) => toHex(u.unitId)), { kind: "reoffered" });
2090
+ const again = [...lost.values()].flat();
2091
+ if (again.length > 0)
2092
+ this.#emitStatus(R, {
2093
+ kind: "reoffered",
2094
+ unitIds: Object.freeze(again.map((u) => dataUnitId(fromHex(u)))),
2095
+ reason: ctx.lossReason ?? "have-gap",
2096
+ });
1305
2097
  this.#offerUnits(ctx, units.map((u) => ({ id: toHex(u.unitId), bytes: u.bytes })));
1306
2098
  }
1307
2099
  await this.#o.outbound.offered(R);
2100
+ ctx.lossReason = null;
1308
2101
  if (sent > 0)
1309
2102
  await this.#offerKeys(ctx);
1310
2103
  await this.#flush(ctx);