@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.
package/dist/revoke.js ADDED
@@ -0,0 +1,108 @@
1
+ import { bytesEqual, controlRecordId, toHex, } from "@openlfcp/core";
2
+ import { ABILITY, ABILITY_NAMES, hasAbility, isGrantActive, KEY_EPOCH_REASON, parseKeyPackage, rotateEpoch, sealKeyPackage, signControlRecord, validateControlChain, verifyKeyPackage, } from "@openlfcp/wire";
3
+ export class RevocationRefused extends Error {
4
+ reason;
5
+ constructor(reason, message) {
6
+ super(message);
7
+ this.reason = reason;
8
+ this.name = "RevocationRefused";
9
+ }
10
+ }
11
+ const name = (a) => ABILITY_NAMES.get(a) ?? `${a}`;
12
+ /**
13
+ * Plans the removal of `subject` by `revoker` on the validated chain
14
+ * `view`. `frontier` is the closing epoch's accepted final frontier (the
15
+ * local Have). With `rotate: false`, or when the member keeps read access
16
+ * through a remaining path, the epoch is not rotated. Throws
17
+ * RevocationRefused.
18
+ */
19
+ export async function planRevocation(options) {
20
+ const { view, revoker, subject } = options;
21
+ const state0 = view.state;
22
+ const R = state0.resourceId;
23
+ if (bytesEqual(state0.owner.principalId, subject))
24
+ throw new RevocationRefused("would-remove-owner", "the owner's access is not revoked (§17.3)");
25
+ const grants = [...state0.grants.values()].filter((g) => bytesEqual(g.subject, subject) && isGrantActive(state0, g.id));
26
+ if (grants.length === 0)
27
+ throw new RevocationRefused("not-member", "no active grant names this Principal");
28
+ let bytes = view.records.map((r) => r.signed.bytes);
29
+ let running = view;
30
+ const records = [];
31
+ const revoked = [];
32
+ const remainingPaths = [];
33
+ for (const g of grants) {
34
+ // Already inactive through an earlier revocation of its ancestor (§17.2).
35
+ if (!isGrantActive(running.state, g.id))
36
+ continue;
37
+ const signed = signControlRecord({ resourceId: R, controlSeq: running.state.seq + 1n, prevControlId: running.state.head }, { type: "CAPABILITY_REVOKE", grantId: g.id }, revoker);
38
+ const next = validateControlChain([...bytes, signed.bytes]);
39
+ if (next.kind !== "linear") {
40
+ remainingPaths.push(Object.freeze({
41
+ grantId: g.id,
42
+ issuer: g.issuer,
43
+ abilities: Object.freeze(g.abilities.map(name)),
44
+ }));
45
+ continue;
46
+ }
47
+ bytes = [...bytes, signed.bytes];
48
+ running = next;
49
+ records.push({ recordId: signed.recordId, bytes: signed.bytes });
50
+ revoked.push(g.id);
51
+ }
52
+ if (revoked.length === 0)
53
+ throw new RevocationRefused("not-authorized", "the revoker may revoke none of this Principal's grants (§17.3)");
54
+ const revokedKeys = new Set(revoked.map((id) => toHex(id)));
55
+ const deactivated = [...state0.grants.values()]
56
+ .filter((g) => !revokedKeys.has(toHex(g.id)) &&
57
+ isGrantActive(state0, g.id) &&
58
+ !isGrantActive(running.state, g.id))
59
+ .map((g) => g.id);
60
+ let rotation = null;
61
+ const keyPackages = [];
62
+ const recipients = [];
63
+ const stillReads = hasAbility(running.state, subject, ABILITY.DATA_READ);
64
+ if (options.rotate !== false && !stillReads) {
65
+ const r = rotateEpoch(running.state, revoker, {
66
+ reason: KEY_EPOCH_REASON.MEMBER_REVOKED,
67
+ finalFrontier: options.frontier,
68
+ });
69
+ const next = validateControlChain([...bytes, r.bytes]);
70
+ if (next.kind !== "linear")
71
+ throw new RevocationRefused("not-authorized", "the revoker may not rotate the Data Epoch (key/rotate, §19)");
72
+ running = next;
73
+ records.push({ recordId: controlRecordId(r.recordId), bytes: r.bytes });
74
+ rotation = Object.freeze({ epoch: r.epoch, dek: r.dek });
75
+ // Every remaining reader but the revoker, who keeps the DEK it made.
76
+ const readers = [
77
+ running.state.owner,
78
+ ...[...running.state.principals.values()],
79
+ ].filter((d, i, all) => all.findIndex((x) => bytesEqual(x.principalId, d.principalId)) === i &&
80
+ !bytesEqual(d.principalId, revoker.descriptor.principalId) &&
81
+ hasAbility(running.state, d.principalId, ABILITY.DATA_READ));
82
+ for (const recipient of readers) {
83
+ const sealed = await sealKeyPackage({
84
+ resourceId: R,
85
+ epoch: r.epoch,
86
+ controlHead: r.recordId,
87
+ recipient,
88
+ dek: r.dek,
89
+ signer: revoker,
90
+ });
91
+ const check = verifyKeyPackage(running, parseKeyPackage(sealed.bytes));
92
+ if (check.kind !== "authorized")
93
+ throw new RevocationRefused("not-authorized", `the revoker may not distribute the new key: ${check.message}`);
94
+ keyPackages.push(sealed.bytes);
95
+ recipients.push(recipient.principalId);
96
+ }
97
+ }
98
+ return Object.freeze({
99
+ revoked: Object.freeze(revoked),
100
+ deactivated: Object.freeze(deactivated),
101
+ records: Object.freeze(records),
102
+ rotation,
103
+ keyPackages: Object.freeze(keyPackages),
104
+ recipients: Object.freeze(recipients),
105
+ remainingPaths: Object.freeze(remainingPaths),
106
+ });
107
+ }
108
+ //# sourceMappingURL=revoke.js.map
@@ -0,0 +1,94 @@
1
+ import type { DataUnitId, ResourceId } from "@openlfcp/core";
2
+ import type { LfcpStorage } from "@openlfcp/storage";
3
+ import type { BatchStatus } from "./batch-status.js";
4
+ import type { AccessState } from "./write-access.js";
5
+ /**
6
+ * The status stream of a Resource (SDK-SECTIONS-INTEGRATION-01 §4, §5,
7
+ * LFCP-02-026): what an editor adapter shows as saved, waiting, accepted
8
+ * or not, and what it knows about units received from others. Every fact
9
+ * comes from evidence the SDK holds; none is derived from a sent frame.
10
+ */
11
+ /** §4.2: the signal that showed the server lost units it had acknowledged. */
12
+ export type ReofferReason = "unknown-previous" | "have-gap" | "rehost";
13
+ /** §4.3: what became of a unit received from others. */
14
+ export type ReceivedFact = "held" | "waiting" | "refused";
15
+ /**
16
+ * LFCP-02-028: how far this session caught up with the Resource.
17
+ * "current-at-checkpoint" only once the Resource was live (Control, keys
18
+ * and every offered unit received and applied), at `checkedAt`; it stays
19
+ * so offline, as of that check. "unknown" while a Control conflict or a
20
+ * missing key blocks the catch-up.
21
+ */
22
+ export type CatchUp = "not-started" | "receiving" | "current-at-checkpoint" | "unknown";
23
+ /** §4.3: a section without `ready` is importing. */
24
+ export type SectionState = "ready" | "importing";
25
+ /**
26
+ * §5: one event of a Resource's status stream. `revision` grows by one with
27
+ * each event of that Resource in this SDK session; a consumer that sees a
28
+ * revision other than the next one asks for statusSnapshot.
29
+ */
30
+ export type StatusEvent = {
31
+ readonly revision: number;
32
+ } & (({
33
+ readonly kind: "batch";
34
+ } & BatchStatus) | {
35
+ readonly kind: "reoffered";
36
+ readonly unitIds: readonly DataUnitId[];
37
+ readonly reason: ReofferReason;
38
+ } | {
39
+ readonly kind: "received";
40
+ readonly fact: ReceivedFact;
41
+ readonly unitIds: readonly DataUnitId[];
42
+ /** The admission or merge diagnostic, when refused. */
43
+ readonly diagnostic?: string;
44
+ } | {
45
+ readonly kind: "section-state";
46
+ readonly state: SectionState;
47
+ } | {
48
+ readonly kind: "access";
49
+ readonly access: AccessState;
50
+ } | {
51
+ readonly kind: "catch-up";
52
+ readonly state: CatchUp;
53
+ /** The local time the Resource was last live; null before. */
54
+ readonly checkedAt: number | null;
55
+ }
56
+ /** The Resource was hosted again on this route (§41.1). */
57
+ | {
58
+ readonly kind: "rehost";
59
+ readonly route: string;
60
+ });
61
+ /** The body of a status event, before its revision is assigned. */
62
+ export type StatusEventBody = StatusEvent extends infer E ? E extends {
63
+ readonly revision: number;
64
+ } ? Omit<E, "revision"> : never : never;
65
+ /** §4.3: the received units that are not merged, as stored. */
66
+ export interface ReceivedState {
67
+ readonly held: readonly DataUnitId[];
68
+ readonly waiting: readonly DataUnitId[];
69
+ readonly refused: readonly {
70
+ readonly unitId: DataUnitId;
71
+ readonly diagnostic: string;
72
+ }[];
73
+ }
74
+ /** §5: the complete state of a Resource's status, at `revision`. */
75
+ export interface StatusSnapshot {
76
+ readonly revision: number;
77
+ readonly batches: readonly BatchStatus[];
78
+ readonly received: ReceivedState;
79
+ /** Unknown without a section binding, or before the section exists. */
80
+ readonly section: SectionState | "unknown";
81
+ readonly access: AccessState;
82
+ readonly catchUp: {
83
+ readonly state: CatchUp;
84
+ readonly checkedAt: number | null;
85
+ };
86
+ }
87
+ /**
88
+ * The received units of `resource` that wait or were refused: held behind
89
+ * a change with their actor and sequence (SOP §14.1), waiting for a
90
+ * dependency (the profile's, or the previous unit of their actor, §26.2),
91
+ * refused when merging. Units of this client are never among them.
92
+ */
93
+ export declare function receivedState(storage: Pick<LfcpStorage, "dataUnits">, resource: ResourceId): Promise<ReceivedState>;
94
+ //# sourceMappingURL=section-status.d.ts.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The received units of `resource` that wait or were refused: held behind
3
+ * a change with their actor and sequence (SOP §14.1), waiting for a
4
+ * dependency (the profile's, or the previous unit of their actor, §26.2),
5
+ * refused when merging. Units of this client are never among them.
6
+ */
7
+ export async function receivedState(storage, resource) {
8
+ const ids = async (status) => (await storage.dataUnits.withStatus(resource, status)).map((u) => u.unitId);
9
+ const refused = (await storage.dataUnits.withStatus(resource, "profile-rejected")).map((u) => Object.freeze({ unitId: u.unitId, diagnostic: u.detail ?? "" }));
10
+ return Object.freeze({
11
+ held: Object.freeze(await ids("profile-held")),
12
+ waiting: Object.freeze([...(await ids("profile-pending")), ...(await ids("held"))]),
13
+ refused: Object.freeze(refused),
14
+ });
15
+ }
16
+ //# sourceMappingURL=section-status.js.map
package/dist/storage.d.ts CHANGED
@@ -3,6 +3,7 @@ import { type ResourceDEK } from "@openlfcp/crypto";
3
3
  import { type CommitResult, type DataUnitRow, type LfcpStorage, type SecretStore, type StorageWrite } from "@openlfcp/storage";
4
4
  import { type ChainResult, type SeenRecord, type SeenUnits } from "@openlfcp/wire";
5
5
  import { type CreateDataUnitOptions, type CreatedDataUnit } from "./data-unit.js";
6
+ import { type Receipt } from "./receipts.js";
6
7
  /**
7
8
  * The client code on top of the storage interfaces (LFCP-034), so that a
8
9
  * durable adapter (LFCP-035) is all a platform has to write.
@@ -93,4 +94,32 @@ export declare function createQueuedDataUnit<T>(storage: Pick<LfcpStorage, "acto
93
94
  */
94
95
  readonly onCreated?: (created: CreatedDataUnit, value: T) => void;
95
96
  }, also?: readonly StorageWrite[] | ((created: CreatedDataUnit) => readonly StorageWrite[])): Promise<CreatedDataUnit>;
97
+ /**
98
+ * SDK-SECTIONS-INTEGRATION-01 §3.1 (LFCP-02-025): commits one operation, a
99
+ * batch of a profile's changes, as Data Units with their outbound entries,
100
+ * the caller's writes (`also`: its profile checkpoint) and the receipt, in
101
+ * ONE storage transaction: after a crash either all of it exists or none.
102
+ * The units chain their previous units in order (§26.2).
103
+ *
104
+ * Idempotent per (Resource, operationId) (§3.3): an operation that already
105
+ * has a receipt for the same intents returns it and writes nothing; for
106
+ * different intents it throws OperationIdReusedError. The caller stages
107
+ * its changes first and adopts them only once this resolves.
108
+ */
109
+ export interface CommitOperationOptions<T> extends Omit<CreateDataUnitOptions<T>, "sequences" | "previousUnitId" | "value"> {
110
+ readonly operationId: string;
111
+ /** The batch's intents, as the caller submitted them (their canonical form is hashed, §3.3). */
112
+ readonly intents: readonly unknown[];
113
+ /** The batch's changes in order; each becomes one Data Unit. */
114
+ readonly values: readonly T[];
115
+ readonly affectedNodeIds: readonly string[];
116
+ /** The document's heads after the batch, sorted, as one string. */
117
+ readonly modelRevision: string;
118
+ /** Called for each sealed unit before the commit (e.g. the profile's recordLocal). */
119
+ readonly onCreated?: (created: CreatedDataUnit, value: T, index: number) => void;
120
+ }
121
+ export declare function commitOperation<T>(storage: Pick<LfcpStorage, "actorSequences" | "commit" | "dataUnits" | "localMarks">, options: CommitOperationOptions<T>, also?: readonly StorageWrite[] | ((units: readonly CreatedDataUnit[]) => readonly StorageWrite[])): Promise<{
122
+ readonly receipt: Receipt;
123
+ readonly committed: boolean;
124
+ }>;
96
125
  //# sourceMappingURL=storage.d.ts.map
package/dist/storage.js CHANGED
@@ -2,7 +2,9 @@ import { actorSequence, bytesEqual, hash32, LfcpError, toHex, } from "@openlfcp/
2
2
  import { dekCommitment, importResourceDEK } from "@openlfcp/crypto";
3
3
  import { dekSecretRef, } from "@openlfcp/storage";
4
4
  import { parseDataUnit, validateControlChain, } from "@openlfcp/wire";
5
+ import { receiptIndexWrites, replaceReleasedWrites } from "./batch-status.js";
5
6
  import { createDataUnit } from "./data-unit.js";
7
+ import { intentsHash, OperationIdReusedError, receiptOf, receiptWrite, } from "./receipts.js";
6
8
  /**
7
9
  * The client code on top of the storage interfaces (LFCP-034), so that a
8
10
  * durable adapter (LFCP-035) is all a platform has to write.
@@ -286,4 +288,67 @@ async function createQueuedDataUnitNow(storage, options, also, resource, actor)
286
288
  throw new Error(`the local unit was not stored: ${result.reason}`);
287
289
  return created;
288
290
  }
291
+ export async function commitOperation(storage, options, also = []) {
292
+ const resource = options.view.state.resourceId;
293
+ const actor = options.actor.descriptor.principalId;
294
+ return serialized(storage, `${toHex(resource)}:${toHex(actor)}`, async () => {
295
+ const hash = intentsHash(options.intents);
296
+ const existing = await receiptOf(storage, resource, options.operationId);
297
+ if (existing !== undefined) {
298
+ if (existing.intentsHash !== hash)
299
+ throw new OperationIdReusedError(options.operationId);
300
+ return { receipt: existing, committed: false };
301
+ }
302
+ const { onCreated, operationId, intents, values, affectedNodeIds, modelRevision, ...create } = options;
303
+ void intents;
304
+ const first = await latestAcceptedOwnUnit(storage, resource, actor);
305
+ const created = [];
306
+ let previous = first;
307
+ for (const [i, value] of values.entries()) {
308
+ const unit = await createDataUnit({
309
+ ...create,
310
+ value,
311
+ previousUnitId: previous,
312
+ sequences: storage.actorSequences,
313
+ });
314
+ onCreated?.(unit, value, i);
315
+ created.push(unit);
316
+ previous = unit.unitId;
317
+ }
318
+ const receipt = Object.freeze({
319
+ operationId,
320
+ unitIds: Object.freeze(created.map((u) => u.unitId)),
321
+ affectedNodeIds: Object.freeze([...affectedNodeIds]),
322
+ modelRevision,
323
+ intentsHash: hash,
324
+ durable: true,
325
+ });
326
+ const writes = [
327
+ { op: "expect-previous-unit", resourceId: resource, actor, previous: first },
328
+ ];
329
+ for (const unit of created) {
330
+ const row = dataUnitRow(unit.bytes);
331
+ writes.push({ op: "put-data-unit", unit: row, status: "merged", detail: "local", accepted: true }, {
332
+ op: "enqueue",
333
+ item: {
334
+ itemId: hash32(unit.unitId),
335
+ resourceId: row.resourceId,
336
+ kind: "data-unit",
337
+ bytes: unit.bytes,
338
+ attempts: 0,
339
+ lastAttempt: null,
340
+ nextAttempt: null,
341
+ blocked: null,
342
+ },
343
+ });
344
+ }
345
+ writes.push(
346
+ // A released batch kept for its status under this operation ID is replaced.
347
+ ...(await replaceReleasedWrites(storage, resource, options.operationId)), receiptWrite(resource, receipt), ...receiptIndexWrites(resource, receipt), ...(typeof also === "function" ? also(created) : also));
348
+ const result = await storage.commit(writes);
349
+ if (!result.ok)
350
+ throw new Error(`the operation was not stored: ${result.reason}`);
351
+ return { receipt, committed: true };
352
+ });
353
+ }
289
354
  //# sourceMappingURL=storage.js.map
@@ -1,12 +1,51 @@
1
- import { type ControlRecordId, type DataEpoch, type DataUnitId, type Hash32, type ResourceId } from "@openlfcp/core";
1
+ import { type ControlRecordId, type DataEpoch, type DataUnitId, type Hash32, type PrincipalId, type ResourceId } from "@openlfcp/core";
2
2
  import { type AgreementKeyPair } from "@openlfcp/crypto";
3
- import { type LfcpStorage, type SecretStore } from "@openlfcp/storage";
3
+ import { type LfcpStorage, type SecretStore, type StorageWrite } from "@openlfcp/storage";
4
4
  import { type ClientConnectionState, type DataProfileCodec, type HaveVector, type Signer } from "@openlfcp/wire";
5
5
  import type { ApplyOutcome, DataUnitApplier, EpochReconciliation } from "./apply.js";
6
6
  import type { ProfileCheckpointer } from "./checkpoint.js";
7
7
  import { type WebSocketFactory } from "./connection.js";
8
8
  import type { AckOutcome, NackOutcome, OutboundQueue, StaleOutboundUnit } from "./outbound.js";
9
+ import { type Receipt } from "./receipts.js";
9
10
  import { type ResourcePhase } from "./resource-state.js";
11
+ import { type RemainingPath, type RevocationRefusal } from "./revoke.js";
12
+ import { type SectionState, type StatusEvent, type StatusSnapshot } from "./section-status.js";
13
+ import { type AccessState, type WriteAccess } from "./write-access.js";
14
+ /** LFCP-02-060: what revokeAccess did. */
15
+ export type RevokeAccessResult = {
16
+ readonly kind: "queued";
17
+ /** The grants revoked, and those delegated from them that this deactivates (§17.2). */
18
+ readonly revoked: readonly ControlRecordId[];
19
+ readonly deactivated: readonly ControlRecordId[];
20
+ /** The queued Control Records: the revocations, then the Key Epoch if any. */
21
+ readonly recordIds: readonly ControlRecordId[];
22
+ /** The new Data Epoch, or null when it was not rotated. */
23
+ readonly epoch: DataEpoch | null;
24
+ /** The remaining readers who get the new DEK in Key Packages. */
25
+ readonly recipients: readonly PrincipalId[];
26
+ /** Grants this revoker may not revoke: the member keeps access through them. */
27
+ readonly remainingPaths: readonly RemainingPath[];
28
+ } | {
29
+ readonly kind: "refused";
30
+ /**
31
+ * offline: the Resource is not live in this session; stale: the
32
+ * validated chain is behind what the server reported; control-pending:
33
+ * Control Records of this client are not committed yet. Nothing is
34
+ * queued: a removal never waits in an offline queue.
35
+ */
36
+ readonly reason: RevocationRefusal | "offline" | "stale" | "control-pending";
37
+ readonly message: string;
38
+ };
39
+ /**
40
+ * SDK-SECTIONS-INTEGRATION-01 §3.6: a batch the SDK refuses before the
41
+ * profile sees it, with the same codes and fields as the profile's
42
+ * refusals. Nothing is written.
43
+ */
44
+ export declare class CommitRefusedError extends Error {
45
+ readonly code: string;
46
+ readonly intentIndex: number;
47
+ constructor(code: string, message: string, intentIndex: number);
48
+ }
10
49
  /**
11
50
  * The client sync session (LFCP-039a): one LFCP connection to a server and
12
51
  * the §65 machine of every Resource opened on it, orchestrating the parts
@@ -41,6 +80,37 @@ export interface SnapshotBinding<T> {
41
80
  load(value: T): void;
42
81
  current(): T;
43
82
  }
83
+ /**
84
+ * SDK-SECTIONS-INTEGRATION-01 §3.1: a batch of intents a Data Profile has
85
+ * validated and changed on a copy of its state (for Shared Sections:
86
+ * SharedSectionsDataProfile.commitBinding().stage).
87
+ */
88
+ export interface StagedOperation<T> {
89
+ /** The batch's changes in order: one Data Unit each. */
90
+ readonly values: readonly T[];
91
+ readonly affectedNodeIds: readonly string[];
92
+ /** The profile state's revision after the batch. */
93
+ readonly modelRevision: string;
94
+ /** Adopts the batch in the profile state, before the commit is stored. */
95
+ apply(): void;
96
+ /** Undoes apply(): the commit did not happen, or had happened before. */
97
+ revert(): void;
98
+ /** Writes stored with the units: the profile checkpoint that includes them. */
99
+ writes(units: readonly {
100
+ readonly unitId: DataUnitId;
101
+ }[]): readonly StorageWrite[];
102
+ /** Called once the commit is durable, with the units in order. */
103
+ committed(unitIds: readonly DataUnitId[]): void;
104
+ }
105
+ /** How a Resource's Data Profile commits local batches (§3.1). */
106
+ export interface CommitBinding<T> {
107
+ /** The codec of this client's own units. */
108
+ readonly codec: DataProfileCodec<T>;
109
+ /** Validates and changes the batch on a copy; null when it writes nothing. Throws to refuse it. */
110
+ stage(intents: readonly unknown[]): StagedOperation<T> | null;
111
+ /** The section's state (SDK-SECTIONS-INTEGRATION-01 §4.3); undefined before there is a section. */
112
+ section?(): SectionState | undefined;
113
+ }
44
114
  /** A Resource to synchronize: its Data Profile applier (and optional checkpoints and Snapshots). */
45
115
  export interface ResourceBinding {
46
116
  readonly resourceId: ResourceId;
@@ -48,6 +118,8 @@ export interface ResourceBinding {
48
118
  readonly checkpointer?: ProfileCheckpointer;
49
119
  /** Load an offered Snapshot instead of replaying every unit, and allow publishing. */
50
120
  readonly snapshot?: SnapshotBinding<unknown>;
121
+ /** Commit local batches with receipts (commit, receiptOf). */
122
+ readonly commit?: CommitBinding<unknown>;
51
123
  }
52
124
  /** When to try connecting again after the `attempt`-th failure (1, 2, …): a delay in ms, or null to stop. */
53
125
  export type ReconnectPolicy = (attempt: number) => number | null;
@@ -80,7 +152,8 @@ export interface SyncClientOptions {
80
152
  readonly dataProfiles?: readonly string[];
81
153
  /**
82
154
  * Whether to publish a Snapshot of a LIVE Resource now, asked on every
83
- * tick with the number of units merged since the last one; no automatic
155
+ * tick with the number of units merged since the last one, received or
156
+ * committed here (a section's only writer reaches it too); no automatic
84
157
  * schedule otherwise (publishSnapshot can also be called directly).
85
158
  */
86
159
  readonly snapshotPolicy?: (resourceId: ResourceId, unitsSinceLast: number) => boolean;
@@ -142,6 +215,12 @@ export type SyncEvent = {
142
215
  readonly type: "ack";
143
216
  readonly outcome: AckOutcome;
144
217
  }
218
+ /** SDK-SECTIONS-INTEGRATION-01 §5: the Resource's status stream (statusSnapshot after a gap). */
219
+ | {
220
+ readonly type: "status";
221
+ readonly resourceId: ResourceId;
222
+ readonly event: StatusEvent;
223
+ }
145
224
  /** A NACK of an outbound object: stale, equivocation alarm, rejected, repropose, … */
146
225
  | {
147
226
  readonly type: "nack";
@@ -168,6 +247,22 @@ export type SyncEvent = {
168
247
  readonly outcome: "hosted" | "refused";
169
248
  readonly code?: string;
170
249
  }
250
+ /**
251
+ * RESOURCE_OPEN was refused with AUTHORIZATION_FAILED although this
252
+ * client's validated chain grants it data/read: the server may have lost
253
+ * Control Records in a restore (LFCP-02-106). "started": the client
254
+ * re-supplies its chain with CONTROL_PUT; "recovered": the server opened
255
+ * the Resource again after it; "ended": no recovery, with the reason (see
256
+ * RecoveryEnd, or "still-refused" when the server holds our chain and
257
+ * still refuses), and the refusal stands.
258
+ */
259
+ | {
260
+ readonly type: "access-recovery";
261
+ readonly resourceId: ResourceId;
262
+ readonly url: string;
263
+ readonly outcome: "started" | "recovered" | "ended";
264
+ readonly reason?: string;
265
+ }
171
266
  /**
172
267
  * The server refused the Resource for good (see ResourceRefusal): it is
173
268
  * CLOSED and is not opened again until open() is called for it.
@@ -212,6 +307,13 @@ export declare class SyncClient {
212
307
  constructor(options: SyncClientOptions);
213
308
  /** Subscribes to session events; returns the unsubscribe function. */
214
309
  on(listener: (event: SyncEvent) => void): () => void;
310
+ /**
311
+ * §5: the complete status of `resource` at the stream's current revision:
312
+ * every unreleased batch, the received units that wait or were refused,
313
+ * the section state and write access. Events at or below its revision
314
+ * are already in it.
315
+ */
316
+ statusSnapshot(resource: ResourceId): Promise<StatusSnapshot>;
215
317
  /** Resolves once every message received so far has been handled (tests and shutdown). */
216
318
  idle(): Promise<void>;
217
319
  get connectionState(): ClientConnectionState;
@@ -242,6 +344,49 @@ export declare class SyncClient {
242
344
  * that loads it never skips content the state does not hold. Queued and
243
345
  * sent like any object; returns its ID.
244
346
  */
347
+ /**
348
+ * LFCP-02-060: removes `subject`'s access to `resource`. Every active
349
+ * grant naming it that this client may revoke is revoked (§17.3); when it
350
+ * has no read access left, the Data Epoch is rotated with a fresh DEK
351
+ * (§19, member revoked) and the DEK goes in Key Packages to every other
352
+ * remaining reader (§25), so it cannot read new data. Built on the
353
+ * validated head and checked before anything is queued; the records, the
354
+ * Key Packages and (first) the new DEK are written at once, then sent.
355
+ * Refused, with nothing queued, unless the Resource is live and current
356
+ * here and no Control Record of this client is pending.
357
+ */
358
+ revokeAccess(resource: ResourceId, subject: PrincipalId, options?: {
359
+ readonly rotate?: boolean;
360
+ }): Promise<RevokeAccessResult>;
361
+ /**
362
+ * SDK-SECTIONS-INTEGRATION-01 §3.1: commits a batch of intents on an open
363
+ * Resource and returns its receipt once the batch is durable: its Data
364
+ * Units, outbound entries, the profile checkpoint and the receipt are
365
+ * stored in one transaction, then the batch is sent. An operation that
366
+ * has a receipt returns it for the same intents and writes nothing, and
367
+ * throws OperationIdReusedError for different ones (§3.3). Runs between
368
+ * received units, never during their merge.
369
+ */
370
+ commit(resource: ResourceId, intents: readonly unknown[], options: {
371
+ readonly operationId: string;
372
+ }): Promise<Receipt>;
373
+ /**
374
+ * SDK-SECTIONS-INTEGRATION-01 §6: whether this client may commit to
375
+ * `resource`, from the validated Control state only: the chain of the
376
+ * last Control sync, or the stored chain validated now. `verifiedAt` is
377
+ * the local time of that validation (the last sync's when the stored
378
+ * chain is still at its head). Unknown without a validated chain.
379
+ */
380
+ canWrite(resource: ResourceId): Promise<WriteAccess>;
381
+ /**
382
+ * LFCP-02-027: write access with its evidence: the Control sequence it
383
+ * was validated at against the highest one a server reported in this
384
+ * session (a stale view is not current), the effective abilities and
385
+ * the active grant paths giving them, and apart from them the
386
+ * invitations we issued, our Control Records not yet committed (SI14)
387
+ * and an unsettled invitation claim of ours.
388
+ */
389
+ accessState(resource: ResourceId): Promise<AccessState>;
245
390
  publishSnapshot(resource: ResourceId): Promise<Hash32>;
246
391
  }
247
392
  /**