@openlfcp/client 0.1.5 → 0.2.0-beta.1

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,51 @@
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 { 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";
6
9
  import { 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";
10
14
  import { resourcePhaseTransition, } from "./resource-state.js";
11
- import { adoptStoredDeks, dekResolver, loadControlChain, saveControlChain, saveControlConflict, } from "./storage.js";
15
+ import { planRevocation, RevocationRefused, } from "./revoke.js";
16
+ import { receivedState, } from "./section-status.js";
17
+ import { adoptStoredDeks, commitOperation, dekResolver, loadControlChain, saveControlChain, saveControlConflict, } from "./storage.js";
18
+ import { accessState, NotWritableError, writeAccess, } from "./write-access.js";
19
+ /** Every status of a stored unit: any of them means the Resource has content. */
20
+ const STORED_STATUSES = [
21
+ "seen",
22
+ "merged",
23
+ "profile-pending",
24
+ "held",
25
+ "quarantined",
26
+ "equivocation",
27
+ "local-failure",
28
+ "profile-held",
29
+ "profile-rejected",
30
+ "profile-unsupported",
31
+ ];
32
+ /**
33
+ * SDK-SECTIONS-INTEGRATION-01 §3.6: a batch the SDK refuses before the
34
+ * profile sees it, with the same codes and fields as the profile's
35
+ * refusals. Nothing is written.
36
+ */
37
+ export class CommitRefusedError extends Error {
38
+ code;
39
+ intentIndex;
40
+ constructor(code, message, intentIndex) {
41
+ super(message);
42
+ this.code = code;
43
+ this.intentIndex = intentIndex;
44
+ this.name = "CommitRefusedError";
45
+ }
46
+ }
47
+ /** Access as a comparable key, to report it only when it changed (verifiedAt aside). */
48
+ const accessKey = (a) => JSON.stringify({ ...a, verifiedAt: null }, (_, v) => typeof v === "bigint" ? `${v}` : v instanceof Uint8Array ? toHex(v) : v);
12
49
  /** 1 s, doubling, at most 60 s, forever. */
13
50
  export const defaultReconnect = (attempt) => Math.min(60_000, 1000 * 2 ** (attempt - 1));
14
51
  /**
@@ -77,6 +114,10 @@ export class SyncClient {
77
114
  #maxMessageBytes = 8 * 1024 * 1024;
78
115
  /** Serializes message handling: each message is handled after the previous one finished. */
79
116
  #queue = Promise.resolve();
117
+ /** READY's durability in this session; null without one (§37). */
118
+ #readyDurability = null;
119
+ /** The status stream's revision per Resource (hex), in this session (§5). */
120
+ #statusRevision = new Map();
80
121
  constructor(options) {
81
122
  this.#o = options;
82
123
  this.#snapshots = new EngineGuard(options.storage, "snapshots");
@@ -105,6 +146,202 @@ export class SyncClient {
105
146
  for (const l of this.#listeners)
106
147
  l(event);
107
148
  }
149
+ // -------------------------------------------------------------------------
150
+ // Status (SDK-SECTIONS-INTEGRATION-01 §4, §5; LFCP-02-026)
151
+ #emitStatus(resource, body) {
152
+ const key = toHex(resource);
153
+ const revision = (this.#statusRevision.get(key) ?? 0) + 1;
154
+ this.#statusRevision.set(key, revision);
155
+ this.#emit({
156
+ type: "status",
157
+ resourceId: resource,
158
+ event: Object.freeze({ ...body, revision }),
159
+ });
160
+ }
161
+ /**
162
+ * §5: the complete status of `resource` at the stream's current revision:
163
+ * every unreleased batch, the received units that wait or were refused,
164
+ * the section state and write access. Events at or below its revision
165
+ * are already in it.
166
+ */
167
+ async statusSnapshot(resource) {
168
+ const ctx = this.#resources.get(toHex(resource));
169
+ const batches = await batchStatuses(this.#o.storage, resource, this.#readyDurability, (ctx?.refusal ?? null) !== null);
170
+ const received = await receivedState(this.#o.storage, resource);
171
+ const access = await this.accessState(resource);
172
+ const section = ctx?.binding.commit?.section?.() ?? "unknown";
173
+ if (ctx !== undefined) {
174
+ for (const b of batches)
175
+ ctx.reported.batches.set(b.operationId, b.status);
176
+ ctx.reported.access = accessKey(access);
177
+ ctx.reported.section = section === "unknown" ? null : section;
178
+ ctx.reported.catchUp = this.#catchUp(ctx);
179
+ }
180
+ return Object.freeze({
181
+ revision: this.#statusRevision.get(toHex(resource)) ?? 0,
182
+ batches: Object.freeze(batches),
183
+ received,
184
+ section,
185
+ access,
186
+ catchUp: Object.freeze({
187
+ state: ctx === undefined ? "not-started" : this.#catchUp(ctx),
188
+ checkedAt: ctx?.liveAt ?? null,
189
+ }),
190
+ });
191
+ }
192
+ /** The batches of `operations`, reported as they are now. */
193
+ async #reportBatches(resource, operations, finals = new Map()) {
194
+ const ctx = this.#resources.get(toHex(resource));
195
+ for (const op of operations) {
196
+ // A released batch forgotten at its final status: reported with that status.
197
+ const b = finals.get(op) ??
198
+ (await batchStatus(this.#o.storage, resource, op, this.#readyDurability, (ctx?.refusal ?? null) !== null));
199
+ if (b === undefined)
200
+ continue;
201
+ ctx?.reported.batches.set(op, b.status);
202
+ this.#emitStatus(resource, { kind: "batch", ...b });
203
+ }
204
+ }
205
+ /** §4.3: a received unit that is held, waits for a dependency, or was refused. */
206
+ #reportReceived(resource, outcome) {
207
+ switch (outcome.kind) {
208
+ case "profile-held":
209
+ this.#emitStatus(resource, { kind: "received", fact: "held", unitIds: [outcome.unitId] });
210
+ return;
211
+ case "profile-pending":
212
+ case "held":
213
+ this.#emitStatus(resource, {
214
+ kind: "received",
215
+ fact: "waiting",
216
+ unitIds: [outcome.unitId],
217
+ });
218
+ return;
219
+ case "profile-rejected":
220
+ this.#emitStatus(resource, {
221
+ kind: "received",
222
+ fact: "refused",
223
+ unitIds: [outcome.unitId],
224
+ diagnostic: outcome.message,
225
+ });
226
+ return;
227
+ }
228
+ }
229
+ /** Batches already reported whose status the session's durability changed (§4.1). */
230
+ async #refreshBatches() {
231
+ for (const ctx of this.#resources.values()) {
232
+ if (ctx.reported.batches.size === 0)
233
+ continue;
234
+ const R = ctx.binding.resourceId;
235
+ const changed = [];
236
+ for (const b of await batchStatuses(this.#o.storage, R, this.#readyDurability, ctx.refusal !== null)) {
237
+ const old = ctx.reported.batches.get(b.operationId);
238
+ if (old !== undefined && old !== b.status)
239
+ changed.push(b.operationId);
240
+ }
241
+ await this.#reportBatches(R, changed);
242
+ }
243
+ }
244
+ /**
245
+ * LFCP-02-115: the server refuses this client the Resource in this
246
+ * session (AUTHORIZATION_FAILED on RESOURCE_OPEN, the recovery of 106
247
+ * not run or not enough). Null otherwise.
248
+ */
249
+ #serverRefusal(ctx) {
250
+ if (ctx?.refusal?.code !== "AUTHORIZATION_FAILED")
251
+ return null;
252
+ return Object.freeze({ code: ctx.refusal.code, recovery: ctx.recoveryEnd });
253
+ }
254
+ /**
255
+ * After a refusal of the Resource, or an open that clears one: reports
256
+ * access, catch-up, and every batch whose status that changes (blocked
257
+ * while refused, pending again after).
258
+ */
259
+ async #reportRefusal(ctx) {
260
+ const R = ctx.binding.resourceId;
261
+ await this.#refreshAccess(ctx);
262
+ this.#reportCatchUp(ctx);
263
+ const changed = [];
264
+ for (const b of await batchStatuses(this.#o.storage, R, this.#readyDurability, ctx.refusal !== null)) {
265
+ const old = ctx.reported.batches.get(b.operationId);
266
+ if (old !== b.status && (old !== undefined || b.status === "blocked"))
267
+ changed.push(b.operationId);
268
+ }
269
+ await this.#reportBatches(R, changed);
270
+ }
271
+ /** Reports write access when it changed (§5 `access`). */
272
+ async #refreshAccess(ctx) {
273
+ const R = ctx.binding.resourceId;
274
+ const access = await this.accessState(R);
275
+ const key = accessKey(access);
276
+ if (key === ctx.reported.access)
277
+ return;
278
+ ctx.reported.access = key;
279
+ this.#emitStatus(R, { kind: "access", access });
280
+ }
281
+ /** LFCP-02-028: the catch-up fact of a Resource from its phase. */
282
+ #catchUp(ctx) {
283
+ // Refused by the server in this session: how current we are is unknown.
284
+ if (ctx.refusal !== null)
285
+ return "unknown";
286
+ switch (ctx.state) {
287
+ case "LIVE":
288
+ return "current-at-checkpoint";
289
+ case "OPENING":
290
+ case "CONTROL_SYNC":
291
+ case "KEY_SYNC":
292
+ case "DATA_SYNC":
293
+ return "receiving";
294
+ case "KEY_BLOCKED":
295
+ case "CONTROL_CONFLICT":
296
+ return "unknown";
297
+ case "CLOSED":
298
+ return ctx.liveAt === null ? "not-started" : "current-at-checkpoint";
299
+ }
300
+ }
301
+ /** Reports the catch-up fact when it changed. */
302
+ #reportCatchUp(ctx) {
303
+ const state = this.#catchUp(ctx);
304
+ if (state === ctx.reported.catchUp)
305
+ return;
306
+ ctx.reported.catchUp = state;
307
+ this.#emitStatus(ctx.binding.resourceId, {
308
+ kind: "catch-up",
309
+ state,
310
+ checkedAt: ctx.liveAt,
311
+ });
312
+ }
313
+ /** Reports the section state when it changed (§5 `section-state`). */
314
+ #refreshSection(ctx) {
315
+ const state = ctx.binding.commit?.section?.();
316
+ if (state === undefined || state === ctx.reported.section)
317
+ return;
318
+ ctx.reported.section = state;
319
+ this.#emitStatus(ctx.binding.resourceId, { kind: "section-state", state });
320
+ }
321
+ /**
322
+ * §4.1: what an ACK establishes for units of `resource`: acceptance when
323
+ * it answers our message (correlated), says durable at a READY level of
324
+ * 2 or more, and comes from a route of the Resource's current route set.
325
+ */
326
+ #ackEvidence(resource, durability, correlated) {
327
+ const route = this.#resources.get(toHex(resource))?.view?.state.route;
328
+ const onRoute = route !== undefined &&
329
+ (route.endpoints.some((e) => e.url === this.#o.url) || route.coordinatorUrl === this.#o.url);
330
+ return correlated && onRoute && durability >= ACCEPTANCE_DURABILITY
331
+ ? { kind: "accepted" }
332
+ : { kind: "unconfirmed" };
333
+ }
334
+ /** Records `evidence` for units (hex) of `resource` and reports the batches it changed. */
335
+ async #evidence(resource, units, evidence) {
336
+ const { writes, changed, finals } = await recordEvidence(this.#o.storage, resource, units, evidence);
337
+ if (writes.length === 0)
338
+ return changed;
339
+ const r = await this.#o.storage.commit(writes);
340
+ if (!r.ok)
341
+ throw new Error(`the batch status was not stored: ${r.reason}`);
342
+ await this.#reportBatches(resource, changed.keys(), finals);
343
+ return changed;
344
+ }
108
345
  #serial(fn) {
109
346
  this.#queue = this.#queue
110
347
  .then(() => (this.#trapped ? undefined : fn()))
@@ -171,6 +408,11 @@ export class SyncClient {
171
408
  state: "CLOSED",
172
409
  wanted: true,
173
410
  view: null,
411
+ verified: null,
412
+ serverControlSeq: null,
413
+ liveAt: null,
414
+ lossReason: null,
415
+ reported: { catchUp: null, access: null, section: null, batches: new Map() },
174
416
  fetched: [],
175
417
  controlTarget: null,
176
418
  remoteHave: [],
@@ -193,12 +435,19 @@ export class SyncClient {
193
435
  offering: new Set(),
194
436
  keysOffered: false,
195
437
  rehosted: false,
438
+ recoveryTried: false,
439
+ recoveryAttempts: 0,
440
+ recoveryReopened: false,
441
+ recoveryEnd: null,
196
442
  };
197
443
  this.#resources.set(key, ctx);
198
444
  }
199
445
  ctx.wanted = true;
200
446
  // An explicit open asks the server again after a refusal.
447
+ const wasRefused = ctx.refusal !== null;
201
448
  ctx.refusal = null;
449
+ if (wasRefused)
450
+ this.#serial(() => this.#reportRefusal(ctx));
202
451
  ctx.refusedAttempt = 0;
203
452
  ctx.reopenAt = null;
204
453
  if (this.#connection.state === "READY")
@@ -261,8 +510,13 @@ export class SyncClient {
261
510
  ctx.wanted &&
262
511
  this.#connection.state === "READY")
263
512
  await this.#sendOpen(ctx);
264
- if (ctx.state === "LIVE" && now - ctx.lastHave >= this.#antiEntropyMs)
513
+ // Periodic anti-entropy (§68.1), both planes: the server's answer
514
+ // to CONTROL_HAVE also re-offers Control Records it still lacks,
515
+ // e.g. after a refused or lost re-supply.
516
+ if (ctx.state === "LIVE" && now - ctx.lastHave >= this.#antiEntropyMs) {
265
517
  this.#sendHave(ctx, now);
518
+ this.#refreshControl(ctx);
519
+ }
266
520
  if (ctx.state === "KEY_BLOCKED" && now - ctx.lastKeyRequest >= this.#antiEntropyMs)
267
521
  this.#requestKeys(ctx, now);
268
522
  await this.#flush(ctx);
@@ -310,7 +564,9 @@ export class SyncClient {
310
564
  ctx?.offering.delete(u.id);
311
565
  continue;
312
566
  }
313
- if (request.kind === "offer-control" || request.kind === "offer-keys")
567
+ if (request.kind === "offer-control" ||
568
+ request.kind === "offer-keys" ||
569
+ request.kind === "recover-control")
314
570
  continue;
315
571
  if ("resource" in request)
316
572
  resend.set(`${request.kind}:${request.resource}`, request);
@@ -355,8 +611,13 @@ export class SyncClient {
355
611
  if (next === "LIVE") {
356
612
  ctx.refusedAttempt = 0;
357
613
  ctx.rehosted = false;
614
+ ctx.recoveryTried = false;
615
+ ctx.recoveryAttempts = 0;
358
616
  }
359
617
  this.#emit({ type: "resource-state", resourceId: ctx.binding.resourceId, state: next });
618
+ if (next === "LIVE")
619
+ ctx.liveAt = this.#o.now();
620
+ this.#reportCatchUp(ctx);
360
621
  if (next === "LIVE" && ctx.controlStale) {
361
622
  ctx.controlStale = false;
362
623
  this.#refreshControl(ctx);
@@ -383,12 +644,16 @@ export class SyncClient {
383
644
  durability: ready.durability,
384
645
  maxMessageBytes: ready.maxMessageBytes,
385
646
  });
647
+ this.#readyDurability = ready.durability;
648
+ await this.#refreshBatches();
386
649
  for (const ctx of this.#resources.values())
387
650
  if (ctx.wanted)
388
651
  await this.#sendOpen(ctx);
389
652
  }
390
653
  async #onClosed(reason) {
391
654
  this.#emit({ type: "connection", state: "DISCONNECTED", reason });
655
+ this.#readyDurability = null;
656
+ await this.#refreshBatches();
392
657
  for (const r of this.#requests.values())
393
658
  if (r.kind === "host")
394
659
  r.reject(new Error(`the connection closed before RESOURCE_HOSTED: ${reason}`));
@@ -398,6 +663,7 @@ export class SyncClient {
398
663
  this.#move(ctx, "CLOSE"); // §65, G-SM1: every Resource closes with the connection
399
664
  ctx.view = null;
400
665
  ctx.offering.clear();
666
+ ctx.recoveryTried = false; // a new session may recover again (LFCP-02-106)
401
667
  }
402
668
  await this.#o.outbound.connectionLost(iso(this.#o.now()));
403
669
  if (this.#stopped)
@@ -448,6 +714,8 @@ export class SyncClient {
448
714
  url: this.#o.url,
449
715
  outcome: "hosted",
450
716
  });
717
+ ctx.lossReason ??= "rehost";
718
+ this.#emitStatus(ctx.binding.resourceId, { kind: "rehost", route: this.#o.url });
451
719
  if (ctx.wanted)
452
720
  await this.#sendOpen(ctx);
453
721
  }
@@ -460,7 +728,12 @@ export class SyncClient {
460
728
  ctx.remoteHave = normalizeLiveHaves(m.body.haves);
461
729
  ctx.offered = m.body.snapshot ?? null;
462
730
  await this.#markHosted(ctx.binding.resourceId);
731
+ if (ctx.recoveryReopened) {
732
+ ctx.recoveryReopened = false;
733
+ this.#recoveryEvent(ctx, "recovered");
734
+ }
463
735
  this.#move(ctx, "OPENED");
736
+ await this.#noteServerHeads(ctx, m.body.heads);
464
737
  // §68.1, §88 step 6: what the server lacks goes first, Control Records before units.
465
738
  await this.#offerControl(ctx, m.body.heads);
466
739
  await this.#offerData(ctx);
@@ -470,6 +743,8 @@ export class SyncClient {
470
743
  case "CONTROL_HAVE": {
471
744
  const ctx = this.#ctx(m.body.resourceId);
472
745
  this.#done(m);
746
+ if (ctx !== undefined)
747
+ await this.#noteServerHeads(ctx, m.body.heads);
473
748
  if (ctx !== undefined && ctx.state === "LIVE")
474
749
  await this.#onControlHeads(ctx, m.body.heads);
475
750
  return;
@@ -522,11 +797,25 @@ export class SyncClient {
522
797
  return;
523
798
  }
524
799
  case "ACK":
800
+ if (request?.kind === "recover-control") {
801
+ this.#done(m);
802
+ await this.#recoveryAccepted(request);
803
+ return;
804
+ }
525
805
  if (request !== undefined) {
526
806
  this.#done(m);
527
- if (request.kind === "offer-data")
807
+ if (request.kind === "offer-data") {
808
+ const ctx = this.#resources.get(request.resource);
528
809
  for (const u of request.units)
529
- this.#resources.get(request.resource)?.offering.delete(u.id);
810
+ ctx?.offering.delete(u.id);
811
+ if (ctx !== undefined) {
812
+ // §4.2: a re-offered unit of ours is accepted again by this ACK.
813
+ const named = new Set((m.body.objectIds ?? []).map((id) => toHex(id)));
814
+ const durability = m.body.durable === true ? (this.#readyDurability ?? 0n) : 0n;
815
+ const R = ctx.binding.resourceId;
816
+ await this.#evidence(R, request.units.map((u) => u.id).filter((id) => named.has(id)), this.#ackEvidence(R, durability, true));
817
+ }
818
+ }
530
819
  return;
531
820
  }
532
821
  await this.#onAck(m);
@@ -565,6 +854,20 @@ export class SyncClient {
565
854
  if (ctx !== undefined && (await this.#rehost(ctx)))
566
855
  return;
567
856
  }
857
+ if (request.kind === "open" && code === "AUTHORIZATION_FAILED") {
858
+ const ctx = this.#resources.get(request.resource);
859
+ if (ctx?.recoveryReopened) {
860
+ // The server holds our chain and still refuses: not a loss.
861
+ ctx.recoveryReopened = false;
862
+ this.#recoveryEvent(ctx, "ended", "still-refused");
863
+ }
864
+ else if (ctx !== undefined && (await this.#recoverAccess(ctx)))
865
+ return;
866
+ }
867
+ if (request.kind === "recover-control") {
868
+ await this.#recoveryRefused(request, code, m.body.details);
869
+ return;
870
+ }
568
871
  if (request.kind === "rehost") {
569
872
  const ctx = this.#resources.get(request.resource);
570
873
  if (ctx === undefined)
@@ -627,6 +930,8 @@ export class SyncClient {
627
930
  this.#request({ kind: "close", resource: toHex(ctx.binding.resourceId) }, createMessage("RESOURCE_CLOSE", { resourceId: ctx.binding.resourceId }));
628
931
  this.#move(ctx, "CLOSE");
629
932
  this.#emit({ type: "resource-refused", resourceId: ctx.binding.resourceId, refusal });
933
+ // LFCP-02-115: the status stream says so too.
934
+ this.#serial(() => this.#reportRefusal(ctx));
630
935
  }
631
936
  /** CLOSED now, opened again after the ReconnectPolicy's delay for this Resource's attempt. */
632
937
  #retryLater(ctx) {
@@ -736,9 +1041,11 @@ export class SyncClient {
736
1041
  const R = ctx.binding.resourceId;
737
1042
  const before = previous ?? ctx.view;
738
1043
  ctx.view = chain;
1044
+ ctx.verified = { head: chain.state.head, at: this.#o.now() };
739
1045
  // DEKs we already hold for epochs the saved chain now has (our own
740
1046
  // rotation, or a package that came before the row): no request needed.
741
1047
  await adoptStoredDeks(this.#o.storage, this.#o.secrets, chain);
1048
+ await this.#refreshAccess(ctx);
742
1049
  const epochsChanged = before === null ||
743
1050
  [...chain.state.epochs.values()].some((e) => {
744
1051
  const old = before.state.epochs.get(String(e.epoch));
@@ -840,6 +1147,7 @@ export class SyncClient {
840
1147
  }
841
1148
  await this.#o.storage.commit(writes);
842
1149
  }
1150
+ await this.#refreshAccess(ctx);
843
1151
  const missing = await this.#epochsWithoutDek(ctx);
844
1152
  const current = view.state.epoch.epoch;
845
1153
  if (missing.some((e) => e === current)) {
@@ -988,6 +1296,247 @@ export class SyncClient {
988
1296
  * that loads it never skips content the state does not hold. Queued and
989
1297
  * sent like any object; returns its ID.
990
1298
  */
1299
+ /**
1300
+ * LFCP-02-060: removes `subject`'s access to `resource`. Every active
1301
+ * grant naming it that this client may revoke is revoked (§17.3); when it
1302
+ * has no read access left, the Data Epoch is rotated with a fresh DEK
1303
+ * (§19, member revoked) and the DEK goes in Key Packages to every other
1304
+ * remaining reader (§25), so it cannot read new data. Built on the
1305
+ * validated head and checked before anything is queued; the records, the
1306
+ * Key Packages and (first) the new DEK are written at once, then sent.
1307
+ * Refused, with nothing queued, unless the Resource is live and current
1308
+ * here and no Control Record of this client is pending.
1309
+ */
1310
+ revokeAccess(resource, subject, options = {}) {
1311
+ return new Promise((resolve, reject) => this.#serial(async () => {
1312
+ try {
1313
+ resolve(await this.#revoke(resource, subject, options));
1314
+ }
1315
+ catch (e) {
1316
+ reject(e);
1317
+ }
1318
+ }));
1319
+ }
1320
+ async #revoke(resource, subject, options) {
1321
+ const refused = (reason, message) => Object.freeze({ kind: "refused", reason, message });
1322
+ const ctx = this.#resources.get(toHex(resource));
1323
+ const view = ctx?.view ?? null;
1324
+ if (ctx === undefined || ctx.state !== "LIVE" || view === null)
1325
+ return refused("offline", "the Resource is not live in this session");
1326
+ if (ctx.serverControlSeq !== null && view.state.seq < ctx.serverControlSeq)
1327
+ return refused("stale", "the validated Control Chain is behind the server's");
1328
+ const storage = this.#o.storage;
1329
+ if ((await storage.outbound.list(resource)).some((i) => i.kind === "control-record"))
1330
+ return refused("control-pending", "Control Records of this client are not committed yet");
1331
+ let plan;
1332
+ try {
1333
+ plan = await planRevocation({
1334
+ view,
1335
+ revoker: this.#o.signer,
1336
+ subject,
1337
+ frontier: (await resourceSyncState(storage, resource)).have,
1338
+ ...(options.rotate === undefined ? {} : { rotate: options.rotate }),
1339
+ });
1340
+ }
1341
+ catch (e) {
1342
+ if (e instanceof RevocationRefused)
1343
+ return refused(e.reason, e.message);
1344
+ throw e;
1345
+ }
1346
+ // The new DEK first (LFCP-034), then everything that names it, at once.
1347
+ if (plan.rotation !== null)
1348
+ await this.#o.secrets.put(dekSecretRef(resource, plan.rotation.epoch), exportSecretKeyBytes(plan.rotation.dek));
1349
+ const r = await storage.commit([
1350
+ ...plan.records.flatMap((rec) => controlRecordWrites(rec.bytes)),
1351
+ ...plan.keyPackages.flatMap(keyPackageWrites),
1352
+ ]);
1353
+ if (!r.ok)
1354
+ throw new Error(`the revocation was not queued: ${r.reason}`);
1355
+ await this.#refreshAccess(ctx);
1356
+ await this.#flush(ctx);
1357
+ return Object.freeze({
1358
+ kind: "queued",
1359
+ revoked: plan.revoked,
1360
+ deactivated: plan.deactivated,
1361
+ recordIds: Object.freeze(plan.records.map((rec) => rec.recordId)),
1362
+ epoch: plan.rotation?.epoch ?? null,
1363
+ recipients: plan.recipients,
1364
+ remainingPaths: plan.remainingPaths,
1365
+ });
1366
+ }
1367
+ /**
1368
+ * SDK-SECTIONS-INTEGRATION-01 §3.1: commits a batch of intents on an open
1369
+ * Resource and returns its receipt once the batch is durable: its Data
1370
+ * Units, outbound entries, the profile checkpoint and the receipt are
1371
+ * stored in one transaction, then the batch is sent. An operation that
1372
+ * has a receipt returns it for the same intents and writes nothing, and
1373
+ * throws OperationIdReusedError for different ones (§3.3). Runs between
1374
+ * received units, never during their merge.
1375
+ */
1376
+ commit(resource, intents, options) {
1377
+ const ctx = this.#resources.get(toHex(resource));
1378
+ if (ctx === undefined)
1379
+ return Promise.reject(new LfcpError("UNSUPPORTED_VALUE", "the Resource is not open here"));
1380
+ const binding = ctx.binding.commit;
1381
+ if (binding === undefined)
1382
+ return Promise.reject(new LfcpError("UNSUPPORTED_VALUE", "the Resource has no commit binding"));
1383
+ return new Promise((resolve, reject) => this.#serial(async () => {
1384
+ try {
1385
+ resolve(await this.#commit(ctx, binding, intents, options.operationId));
1386
+ }
1387
+ catch (e) {
1388
+ reject(e);
1389
+ }
1390
+ }));
1391
+ }
1392
+ /**
1393
+ * SDK-SECTIONS-INTEGRATION-01 §6: whether this client may commit to
1394
+ * `resource`, from the validated Control state only: the chain of the
1395
+ * last Control sync, or the stored chain validated now. `verifiedAt` is
1396
+ * the local time of that validation (the last sync's when the stored
1397
+ * chain is still at its head). Unknown without a validated chain.
1398
+ */
1399
+ async canWrite(resource) {
1400
+ const ctx = this.#resources.get(toHex(resource));
1401
+ const view = ctx?.view ?? (await loadControlChain(this.#o.storage, resource));
1402
+ return this.#access(resource, view, ctx?.verified ?? null, this.#serverRefusal(ctx));
1403
+ }
1404
+ /**
1405
+ * LFCP-02-027: write access with its evidence: the Control sequence it
1406
+ * was validated at against the highest one a server reported in this
1407
+ * session (a stale view is not current), the effective abilities and
1408
+ * the active grant paths giving them, and apart from them the
1409
+ * invitations we issued, our Control Records not yet committed (SI14)
1410
+ * and an unsettled invitation claim of ours.
1411
+ */
1412
+ async accessState(resource) {
1413
+ const ctx = this.#resources.get(toHex(resource));
1414
+ const view = ctx?.view ?? (await loadControlChain(this.#o.storage, resource));
1415
+ const refusal = this.#serverRefusal(ctx);
1416
+ const write = await this.#access(resource, view, ctx?.verified ?? null, refusal);
1417
+ const pendingControl = [];
1418
+ for (const item of await this.#o.storage.outbound.list(resource)) {
1419
+ if (item.kind !== "control-record")
1420
+ continue;
1421
+ const record = decodeControlRecord(item.bytes);
1422
+ pendingControl.push(Object.freeze({ recordId: controlRecordId(record.signed.id), type: record.body.type }));
1423
+ }
1424
+ const dek = view?.kind === "linear" &&
1425
+ (await this.#currentDek(resource, view.state.epoch.epoch)) !== undefined;
1426
+ return accessState(view, this.#o.signer.descriptor.principalId, dek, write.verifiedAt ?? 0, {
1427
+ serverControlSeq: ctx?.serverControlSeq ?? null,
1428
+ pendingControl,
1429
+ pendingClaim: (await readClaimJournal(this.#o.storage, resource)) !== undefined,
1430
+ serverRefusal: refusal,
1431
+ });
1432
+ }
1433
+ /** Records the highest Control sequence a server reported, and reports access if it changed. */
1434
+ async #noteServerHeads(ctx, heads) {
1435
+ const seq = heads.reduce((m, h) => (m === null || h.seq > m ? h.seq : m), null);
1436
+ if (seq === null || (ctx.serverControlSeq !== null && seq <= ctx.serverControlSeq))
1437
+ return;
1438
+ ctx.serverControlSeq = seq;
1439
+ await this.#refreshAccess(ctx);
1440
+ }
1441
+ /**
1442
+ * The DEK of `epoch`, or undefined when it is not held or the key store
1443
+ * cannot be read now (LFCP-02-030): an unreadable key store is
1444
+ * key-unavailable, never a reason to write without it or to reset state.
1445
+ */
1446
+ async #currentDek(resource, epoch) {
1447
+ try {
1448
+ return await dekResolver(this.#o.storage, this.#o.secrets, resource)(epoch);
1449
+ }
1450
+ catch {
1451
+ return undefined;
1452
+ }
1453
+ }
1454
+ async #access(resource, view, verified, refusal = null) {
1455
+ if (view?.kind !== "linear")
1456
+ return writeAccess(view, this.#o.signer.descriptor.principalId, false, 0);
1457
+ const dek = await this.#currentDek(resource, view.state.epoch.epoch);
1458
+ const at = verified !== null && bytesEqual(verified.head, view.state.head) ? verified.at : this.#o.now();
1459
+ const access = writeAccess(view, this.#o.signer.descriptor.principalId, dek !== undefined, at);
1460
+ // LFCP-02-115: the server refuses us the Resource; the local chain is not the last word.
1461
+ return refusal === null
1462
+ ? access
1463
+ : Object.freeze({ ...access, allowed: false, reason: "server-refused" });
1464
+ }
1465
+ /**
1466
+ * SHARED-SECTIONS-PROFILE-01 §13, in depth behind the adapter's own
1467
+ * check: an incomplete load never becomes a new, empty section. A batch
1468
+ * that creates a section is refused (SECTION_EXISTS) when units of the
1469
+ * Resource are stored (content exists that the model may not hold yet),
1470
+ * or when this client is not the Resource's owner and the Resource was
1471
+ * not live in this session (it has not caught up). The owner creates its
1472
+ * new Resource's section offline.
1473
+ */
1474
+ async #guardCreate(ctx, view, intents) {
1475
+ const index = intents.findIndex((i) => typeof i === "object" &&
1476
+ i !== null &&
1477
+ i.intent === "section.create");
1478
+ if (index < 0)
1479
+ return;
1480
+ const R = ctx.binding.resourceId;
1481
+ const stored = (await Promise.all(STORED_STATUSES.map((st) => this.#o.storage.dataUnits.withStatus(R, st)))).some((units) => units.length > 0);
1482
+ if (stored)
1483
+ throw new CommitRefusedError("SECTION_EXISTS", "units of this Resource are stored: it is not a new section (§13)", index);
1484
+ const owner = bytesEqual(view.state.owner.principalId, this.#o.signer.descriptor.principalId);
1485
+ if (!owner && ctx.liveAt === null)
1486
+ throw new CommitRefusedError("SECTION_EXISTS", "the Resource has not caught up in this session: its section may exist (§13)", index);
1487
+ }
1488
+ async #commit(ctx, binding, intents, operationId) {
1489
+ const R = ctx.binding.resourceId;
1490
+ const storage = this.#o.storage;
1491
+ // §3.3: a committed operation answers first; its intents may no longer apply.
1492
+ const known = await receiptOf(storage, R, operationId);
1493
+ if (known !== undefined) {
1494
+ if (known.intentsHash !== intentsHash(intents))
1495
+ throw new OperationIdReusedError(operationId);
1496
+ return known;
1497
+ }
1498
+ const view = ctx.view ?? (await loadControlChain(storage, R));
1499
+ // §3.6, §6: a batch submitted while writing is not allowed writes nothing.
1500
+ const access = await this.#access(R, view, ctx.verified, this.#serverRefusal(ctx));
1501
+ if (!access.allowed || view?.kind !== "linear")
1502
+ throw new NotWritableError(access);
1503
+ const dek = await this.#currentDek(R, view.state.epoch.epoch);
1504
+ if (dek === undefined)
1505
+ throw new NotWritableError({ ...access, allowed: false, reason: "key-unavailable" });
1506
+ await this.#guardCreate(ctx, view, intents);
1507
+ const staged = binding.stage(intents);
1508
+ staged?.apply();
1509
+ try {
1510
+ const { receipt, committed } = await commitOperation(storage, {
1511
+ view,
1512
+ controlHead: view.state.head,
1513
+ actor: this.#o.signer,
1514
+ dek,
1515
+ profile: binding.codec,
1516
+ operationId,
1517
+ intents,
1518
+ values: staged?.values ?? [],
1519
+ affectedNodeIds: staged?.affectedNodeIds ?? [],
1520
+ modelRevision: staged?.modelRevision ?? "",
1521
+ }, (units) => staged?.writes(units) ?? []);
1522
+ if (!committed)
1523
+ staged?.revert();
1524
+ else
1525
+ staged?.committed(receipt.unitIds);
1526
+ if (committed) {
1527
+ // Our own units grow the history a Snapshot covers, as received ones do.
1528
+ ctx.unitsSinceSnapshot += receipt.unitIds.length;
1529
+ await this.#reportBatches(R, [receipt.operationId]);
1530
+ this.#refreshSection(ctx);
1531
+ }
1532
+ await this.#flush(ctx);
1533
+ return receipt;
1534
+ }
1535
+ catch (e) {
1536
+ staged?.revert();
1537
+ throw e;
1538
+ }
1539
+ }
991
1540
  async publishSnapshot(resource) {
992
1541
  const ctx = this.#resources.get(toHex(resource));
993
1542
  if (ctx === undefined)
@@ -1142,16 +1691,20 @@ export class SyncClient {
1142
1691
  if (outcome.kind === "rejected" && outcome.wireCode === "MISSING_DEPENDENCY")
1143
1692
  needControl = true;
1144
1693
  this.#emit({ type: "unit", resourceId: R, outcome });
1694
+ this.#reportReceived(R, outcome);
1145
1695
  // Held units this one released (§26.2): their outcomes too.
1146
1696
  const released = "released" in outcome ? outcome.released : [];
1147
1697
  for (const r of released) {
1148
1698
  if (r.kind === "applied" || r.kind === "profile-pending")
1149
1699
  changed = true;
1150
1700
  this.#emit({ type: "unit", resourceId: R, outcome: r });
1701
+ this.#reportReceived(R, r);
1151
1702
  }
1152
1703
  }
1153
- if (changed)
1704
+ if (changed) {
1154
1705
  ctx.binding.checkpointer?.noteChange();
1706
+ this.#refreshSection(ctx);
1707
+ }
1155
1708
  if (needControl)
1156
1709
  this.#refreshControl(ctx);
1157
1710
  if (ctx.state === "DATA_SYNC" &&
@@ -1189,7 +1742,32 @@ export class SyncClient {
1189
1742
  this.#connection.sendEncoded(m.bytes);
1190
1743
  }
1191
1744
  async #onAck(m) {
1192
- const outcome = await this.#o.outbound.onAck(m, iso(this.#o.now()));
1745
+ if (m.body.requestType === MESSAGE_TYPE.CONTROL_PUT)
1746
+ await this.#adoptAcked(m);
1747
+ // §4.1: acceptance is recorded in the dequeue's transaction.
1748
+ const changed = [];
1749
+ const outcome = await this.#o.outbound.onAck(m, iso(this.#o.now()), async (items, ack) => {
1750
+ const byResource = new Map();
1751
+ for (const item of items) {
1752
+ if (item.kind !== "data-unit")
1753
+ continue;
1754
+ const key = toHex(item.resourceId);
1755
+ const entry = byResource.get(key) ?? { R: item.resourceId, units: [] };
1756
+ entry.units.push(toHex(item.itemId));
1757
+ byResource.set(key, entry);
1758
+ }
1759
+ const writes = [];
1760
+ for (const { R, units } of byResource.values()) {
1761
+ const evidence = this.#ackEvidence(R, ack.durability, ack.correlated);
1762
+ const r = await recordEvidence(this.#o.storage, R, units, evidence);
1763
+ writes.push(...r.writes);
1764
+ if (r.changed.size > 0)
1765
+ changed.push([R, r.changed, r.finals]);
1766
+ }
1767
+ return writes;
1768
+ });
1769
+ for (const [R, ops, finals] of changed)
1770
+ await this.#reportBatches(R, ops.keys(), finals);
1193
1771
  this.#emit({ type: "ack", outcome });
1194
1772
  // Our own Control Record was committed: catch up, since the server does not push it back to us.
1195
1773
  if (m.body.requestType === MESSAGE_TYPE.CONTROL_PUT && outcome.acked.length > 0)
@@ -1199,8 +1777,57 @@ export class SyncClient {
1199
1777
  else
1200
1778
  ctx.controlStale = true;
1201
1779
  }
1780
+ /**
1781
+ * An ACK of our CONTROL_PUT (§47) means the server committed the record
1782
+ * on the head it expected: our own previous record. The record joins the
1783
+ * stored chain the way a fetched one does, BEFORE it leaves the queue, so
1784
+ * it is never held by the server alone: a stop between the ACK and the
1785
+ * catch-up fetch, then a server restored from an older copy, would
1786
+ * otherwise lose it (LFCP-02-106). A crash before the dequeue only sends
1787
+ * it again, and a repeated ACK is idempotent. A record that does not
1788
+ * continue the stored chain is left to the catch-up fetch.
1789
+ */
1790
+ async #adoptAcked(m) {
1791
+ for (const id of m.body.objectIds ?? []) {
1792
+ const item = await this.#o.storage.outbound.get(id);
1793
+ if (item?.kind !== "control-record")
1794
+ continue;
1795
+ const ctx = this.#ctx(item.resourceId);
1796
+ if (ctx === undefined)
1797
+ continue;
1798
+ await this.#onControlRecords(ctx, [item.bytes], false);
1799
+ }
1800
+ }
1202
1801
  async #onNack(m) {
1203
1802
  const outcome = await this.#o.outbound.onNack(m, iso(this.#o.now()));
1803
+ // §4.1: a terminal NACK rejects the batch of the unit.
1804
+ if (outcome.kind === "rejected" ||
1805
+ outcome.kind === "stale" ||
1806
+ outcome.kind === "equivocation-alarm") {
1807
+ const code = outcome.kind === "rejected"
1808
+ ? outcome.code
1809
+ : outcome.kind === "stale"
1810
+ ? "STALE_DATA_EPOCH"
1811
+ : "ACTOR_EQUIVOCATION";
1812
+ const byResource = new Map();
1813
+ for (const item of outcome.items) {
1814
+ if (item.kind !== "data-unit")
1815
+ continue;
1816
+ const R = (await this.#o.storage.outbound.get(item.itemId))?.resourceId;
1817
+ if (R === undefined)
1818
+ continue;
1819
+ const entry = byResource.get(toHex(R)) ?? { R, units: [] };
1820
+ entry.units.push(toHex(item.itemId));
1821
+ byResource.set(toHex(R), entry);
1822
+ }
1823
+ for (const { R, units } of byResource.values())
1824
+ await this.#evidence(R, units, { kind: "rejected", code });
1825
+ }
1826
+ if (outcome.kind === "needs-offer") {
1827
+ const ctx = this.#ctx(outcome.resourceId);
1828
+ if (ctx !== undefined)
1829
+ ctx.lossReason ??= "unknown-previous";
1830
+ }
1204
1831
  this.#emit({ type: "nack", outcome });
1205
1832
  if (outcome.kind === "needs-control-sync")
1206
1833
  for (const ctx of this.#resources.values())
@@ -1248,6 +1875,94 @@ export class SyncClient {
1248
1875
  this.#request({ kind: "rehost", resource: toHex(R) }, createMessage("RESOURCE_HOST", { genesis: genesis.signed.bytes }));
1249
1876
  return true;
1250
1877
  }
1878
+ /**
1879
+ * LFCP-02-106: RESOURCE_OPEN was refused with AUTHORIZATION_FAILED. When
1880
+ * our validated chain grants us data/read, the server may have lost the
1881
+ * records that do (a restore): push our head, and follow the server's
1882
+ * answer (see access-recovery.ts). False when no recovery runs; the
1883
+ * refusal then stands as before.
1884
+ */
1885
+ async #recoverAccess(ctx) {
1886
+ if (ctx.recoveryTried || this.#connection.state !== "READY")
1887
+ return false;
1888
+ const chain = await loadControlChain(this.#o.storage, ctx.binding.resourceId);
1889
+ if (chain?.kind !== "linear")
1890
+ return false;
1891
+ const step = startRecovery(chain.state, chain.records, this.#o.signer.descriptor.principalId);
1892
+ ctx.recoveryTried = true;
1893
+ if (step.kind !== "push") {
1894
+ this.#recoveryEvent(ctx, "ended", step.kind === "final" ? step.reason : undefined);
1895
+ return false;
1896
+ }
1897
+ this.#recoveryEvent(ctx, "started");
1898
+ this.#move(ctx, "CLOSE");
1899
+ await this.#recoveryStep(ctx, step);
1900
+ return true;
1901
+ }
1902
+ async #recoveryStep(ctx, step) {
1903
+ const R = ctx.binding.resourceId;
1904
+ if (step.kind === "reopen") {
1905
+ ctx.recoveryReopened = true;
1906
+ if (ctx.wanted)
1907
+ await this.#sendOpen(ctx);
1908
+ return;
1909
+ }
1910
+ if (step.kind === "final") {
1911
+ this.#recoveryEvent(ctx, "ended", step.reason);
1912
+ this.#refuse(ctx, { code: "AUTHORIZATION_FAILED", url: this.#o.url, request: "open" });
1913
+ return;
1914
+ }
1915
+ if (this.#connection.state !== "READY")
1916
+ return; // the next session opens and may recover again
1917
+ const chain = await loadControlChain(this.#o.storage, R);
1918
+ const record = chain?.kind === "linear" ? chain.records[step.index] : undefined;
1919
+ const previous = record?.payload.prevControlId ?? null;
1920
+ if (record === undefined || previous === null) {
1921
+ await this.#recoveryStep(ctx, { kind: "final", reason: "refused" });
1922
+ return;
1923
+ }
1924
+ this.#request({ kind: "recover-control", resource: toHex(R), index: step.index }, createMessage("CONTROL_PUT", {
1925
+ resourceId: R,
1926
+ expectedHead: previous,
1927
+ record: record.signed.bytes,
1928
+ }));
1929
+ }
1930
+ async #recoveryAccepted(request) {
1931
+ const ctx = this.#resources.get(request.resource);
1932
+ if (ctx === undefined)
1933
+ return;
1934
+ const chain = await loadControlChain(this.#o.storage, ctx.binding.resourceId);
1935
+ if (chain?.kind !== "linear")
1936
+ return;
1937
+ await this.#recoveryStep(ctx, afterAccepted(chain.records, request.index));
1938
+ }
1939
+ async #recoveryRefused(request, code, details) {
1940
+ const ctx = this.#resources.get(request.resource);
1941
+ if (ctx === undefined)
1942
+ return;
1943
+ if (RECOVERY_TRANSIENT_CODES.has(code) && ctx.recoveryAttempts < RECOVERY_TRANSIENT_ATTEMPTS) {
1944
+ // Open again after a backoff; the refused open recovers again.
1945
+ ctx.recoveryAttempts += 1;
1946
+ ctx.recoveryTried = false;
1947
+ this.#retryLater(ctx);
1948
+ return;
1949
+ }
1950
+ const chain = await loadControlChain(this.#o.storage, ctx.binding.resourceId);
1951
+ const step = code === "CONTROL_HEAD_MISMATCH" && chain?.kind === "linear"
1952
+ ? afterMismatch(chain.records, details)
1953
+ : { kind: "final", reason: "refused" };
1954
+ await this.#recoveryStep(ctx, step);
1955
+ }
1956
+ #recoveryEvent(ctx, outcome, reason) {
1957
+ ctx.recoveryEnd = outcome === "ended" ? (reason ?? null) : null;
1958
+ this.#emit({
1959
+ type: "access-recovery",
1960
+ resourceId: ctx.binding.resourceId,
1961
+ url: this.#o.url,
1962
+ outcome,
1963
+ ...(reason === undefined ? {} : { reason }),
1964
+ });
1965
+ }
1251
1966
  /**
1252
1967
  * §68.1: the server's Control Head is a record we hold below our own head:
1253
1968
  * upload the records above it, one CONTROL_PUT each, in order, each
@@ -1302,9 +2017,19 @@ export class SyncClient {
1302
2017
  !queued.has(toHex(u.unitId)) &&
1303
2018
  !ctx.offering.has(toHex(u.unitId)));
1304
2019
  sent += units.length;
2020
+ // §4.2: units of our batches the server lost are pending again.
2021
+ const lost = await this.#evidence(R, units.map((u) => toHex(u.unitId)), { kind: "reoffered" });
2022
+ const again = [...lost.values()].flat();
2023
+ if (again.length > 0)
2024
+ this.#emitStatus(R, {
2025
+ kind: "reoffered",
2026
+ unitIds: Object.freeze(again.map((u) => dataUnitId(fromHex(u)))),
2027
+ reason: ctx.lossReason ?? "have-gap",
2028
+ });
1305
2029
  this.#offerUnits(ctx, units.map((u) => ({ id: toHex(u.unitId), bytes: u.bytes })));
1306
2030
  }
1307
2031
  await this.#o.outbound.offered(R);
2032
+ ctx.lossReason = null;
1308
2033
  if (sent > 0)
1309
2034
  await this.#offerKeys(ctx);
1310
2035
  await this.#flush(ctx);