@openlfcp/shared-objects 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,6 +37,69 @@ edits, the effective tree and the section admission rules are not there
37
37
  yet. It may change
38
38
  without notice until the profile is in a baseline.
39
39
 
40
+ ## Identity profile (MVP 0.4 Working Draft)
41
+
42
+ `@openlfcp/shared-objects/identity` implements IDENTITY-PROFILE-01 §§2–4
43
+ (`org.openlfcp.identity.v1`, `mvp-0.4-baseline.2`, LFCP-04-409 and 409b).
44
+ `IdentityReplica` admits changes with AI0–AI5, validates every concurrent
45
+ field value and exposes `roster()`, `devices()` and `requests()`. Invalid
46
+ entries are omitted; an invalid root makes all three readers empty.
47
+ Concurrent request maps remain invalid: readers never select a winning
48
+ request. Device records here are display metadata and key hints, not authority.
49
+
50
+ ```ts
51
+ import { IdentityReplica } from "@openlfcp/shared-objects/identity";
52
+
53
+ const identity = IdentityReplica.empty({ resource, root });
54
+ const initial = identity.write(
55
+ { signer: root, actorSeq: 1n, writerClass: "root" },
56
+ { intent: "identity.initialize" },
57
+ );
58
+ ```
59
+
60
+ Persist and send `initial.bytes` in the root's Data Unit at actor sequence 1;
61
+ retry those exact bytes. Initialization creates only the six root keys,
62
+ with empty roster/devices/requests/extensions. Write entries in later changes
63
+ with `roster.put`, `device.put`, or `request.put`; matching `.delete` intents
64
+ remove entries. Updates preserve unknown fields; omitted optional fields
65
+ remain unchanged. Writers produce scalar strings and uints, including
66
+ lossless bigint epochs in `missing_keys` (safe epochs read back as numbers).
67
+ The writer permits `missing_keys` only on the signer's own device entry, and
68
+ receivers refuse any other writer's `missing_keys` as `UNAUTHORIZED_WRITER`
69
+ (§3 AI5, decision I8). AI2 and AI5 are decided on the objects of the causal
70
+ history, values the engine does not select and maps since replaced or deleted
71
+ included; AI2 looks only at the objects a write names as its predecessors.
72
+ Reserved `recovery`/`succession` keys have no writer intent; readers preserve them.
73
+
74
+ The receive boundary is `receiveChanges([{ bytes, signer, actorSeq, writerClass }])`,
75
+ or `receive(plaintext, writer)` for SOP-framed bytes. All unit metadata must
76
+ come from verified LFCP units. Derive `writerClass` from this Identity Resource's
77
+ Control state **at the unit's `control_ref`**, never its latest head. Wire
78
+ authorization, decryption, equivocation and epoch cutoffs remain the caller's
79
+ responsibility. The actor domain is inherited unchanged from SOP §8.
80
+ Waiting dependencies and held actor sequences are retained; `rebuildWithout`
81
+ takes excluded change hashes and replays remaining units, removing dependants.
82
+
83
+ `fromSave(save, options, verifiedUnits)` and `fromSnapshot` verify the initial
84
+ change against the root's actual unit 1 before exposing state. They replay all
85
+ snapshot changes with their verified carrying units, which also verifies AI4
86
+ ownership and historical writer classes. Missing proof refuses the snapshot;
87
+ fetch/decrypt units and retry, including when an earlier epoch is KEY_BLOCKED.
88
+ `identityCodec` and `identitySnapshotCodec` provide the shared SOP framing.
89
+
90
+ Diagnostics are `PROFILE_INVALID` with a profile diagnostic and JSON Pointer
91
+ for state errors. Conflicted requests/kind report the first invalid value's
92
+ diagnostic in table order; `INVALID_FIELD_TYPE` is the fallback when every
93
+ concurrent value is valid on its own (§4 rule 2). Unknown request fields also
94
+ invalidate the entry when conflicted. Tests use §12 ID-05 names and synthetic
95
+ canonical changes, including the diagnostic cases beyond the corpus;
96
+ `conformance/identity/profile.test.ts` runs IDENTITY-PROFILE-TEST-VECTORS-01.
97
+ Admission retains causal, object and sequence indexes across calls. It applies
98
+ new changes without cloning history and replays the trusted byte log only to
99
+ recover from an engine error; local writes validate one candidate on a fork.
100
+ Client DataProfileHandler wiring, durable journals, and pairing flows belong
101
+ to LFCP-04-410.
102
+
40
103
  ## Automerge and WebAssembly
41
104
 
42
105
  The package depends on `@automerge/automerge` **3.5.0**, pinned exactly,
@@ -19,12 +19,18 @@ export interface ParsedOp {
19
19
  readonly pred: readonly string[];
20
20
  /** The value's type, the low 4 bits of its metadata (8: a counter). */
21
21
  readonly valueType: number;
22
+ /** Lossless scalar value (Automerge JS decodeChange cannot decode integers above 2^53). */
23
+ readonly value: unknown;
24
+ readonly expand: boolean;
25
+ readonly name: string | null;
22
26
  }
23
27
  /** A change read from its bytes (§11.3), without the engine. */
24
28
  export interface ParsedChange {
25
29
  readonly actor: string;
26
30
  readonly seq: number;
27
31
  readonly startOp: number;
32
+ readonly time: number;
33
+ readonly message: string;
28
34
  /** Hex dependency hashes, ascending. */
29
35
  readonly deps: readonly string[];
30
36
  /** Hex other actors, ascending: actor index i is otherActors[i - 1]. */
@@ -329,25 +329,25 @@ function checkValue(type, bytes) {
329
329
  if (type <= 2) {
330
330
  if (bytes.length !== 0)
331
331
  refuse("a null or boolean value has bytes");
332
- return;
332
+ return type === 0 ? null : type === 2;
333
333
  }
334
334
  if (type === 3 || type === 4 || type === 8 || type === 9) {
335
335
  const r = new Reader(bytes);
336
- if (type === 3)
337
- r.ubig();
338
- else
339
- r.sbig();
336
+ const value = type === 3 ? r.ubig() : r.sbig();
340
337
  if (!r.done || bytes.length === 0)
341
338
  refuse("a number value is not exactly one shortest LEB128");
342
- return;
339
+ return value >= BigInt(Number.MIN_SAFE_INTEGER) && value <= BigInt(Number.MAX_SAFE_INTEGER)
340
+ ? Number(value)
341
+ : value;
343
342
  }
344
343
  if (type === 5) {
345
344
  if (bytes.length !== 8)
346
345
  refuse("a float value is not 8 bytes");
347
- return;
346
+ return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getFloat64(0, true);
348
347
  }
349
348
  if (type === 6)
350
- utf8(bytes, "a string value");
349
+ return utf8(bytes, "a string value");
350
+ return bytes.slice();
351
351
  }
352
352
  /**
353
353
  * §14.1: whether the extra bytes after a change's columns begin with an
@@ -403,8 +403,10 @@ export function checkCanonicalChange(bytes) {
403
403
  const startOp = r.ubig();
404
404
  if (seq < 1n || startOp < 1n)
405
405
  refuse("the sequence number or the start op is 0");
406
- r.sbig(); // time
407
- utf8(r.take(r.unum()), "the message");
406
+ const time = r.sbig();
407
+ if (seq >= 1n << 53n || time <= -(1n << 53n) || time >= 1n << 53n)
408
+ refuse("the sequence number or time is outside the exact JavaScript integer range");
409
+ const message = utf8(r.take(r.unum()), "the message");
408
410
  const otherCount = r.unum();
409
411
  const others = [];
410
412
  for (let i = 0; i < otherCount; i++)
@@ -507,6 +509,7 @@ export function checkCanonicalChange(bytes) {
507
509
  const raw = data.get(VAL_RAW) ?? new Uint8Array(0);
508
510
  let rawAt = 0;
509
511
  const valueTypes = [];
512
+ const values = [];
510
513
  for (let i = 0; i < N; i++) {
511
514
  const m = meta?.[i] ?? null;
512
515
  if (m === null)
@@ -516,7 +519,7 @@ export function checkCanonicalChange(bytes) {
516
519
  const len = mv >> 4n;
517
520
  if (len > BigInt(raw.length - rawAt))
518
521
  refuse("the value bytes are shorter than their metadata");
519
- checkValue(type, raw.subarray(rawAt, rawAt + Number(len)));
522
+ values.push(checkValue(type, raw.subarray(rawAt, rawAt + Number(len))));
520
523
  rawAt += Number(len);
521
524
  valueTypes.push(type);
522
525
  }
@@ -614,6 +617,9 @@ export function checkCanonicalChange(bytes) {
614
617
  action: act,
615
618
  pred: Object.freeze(pred),
616
619
  valueType: valueTypes[i],
620
+ value: values[i],
621
+ expand: exp,
622
+ name: mark === null ? null : String(mark),
617
623
  }));
618
624
  }
619
625
  // Rule 3: each column present exactly when the table says.
@@ -644,6 +650,8 @@ export function checkCanonicalChange(bytes) {
644
650
  actor: hex(actor),
645
651
  seq: Number(seq),
646
652
  startOp: start,
653
+ time: Number(time),
654
+ message,
647
655
  deps: Object.freeze(deps.map(hex)),
648
656
  otherActors: Object.freeze(others.map(hex)),
649
657
  ops: Object.freeze(ops),
@@ -52,7 +52,47 @@ export function checkChange(bytes) {
52
52
  decoded = A.decodeChange(bytes);
53
53
  }
54
54
  catch (e) {
55
- return reject(`invalid Automerge change bytes (§11): ${e.message}`);
55
+ // Canonical uint64 values are legal (SOP §11.3 rule 6), but the JS
56
+ // engine's JSON decoder refuses integers it cannot represent as number.
57
+ // The canonical parser has already checked and decoded them losslessly.
58
+ if (!(e instanceof RangeError) ||
59
+ !/^Error: -?\d+ can't be represented as a JavaScript number$/.test(e.message) ||
60
+ !parsed.ops.some((op) => typeof op.value === "bigint"))
61
+ return reject(`invalid Automerge change bytes (§11): ${e.message}`);
62
+ const hash = refusedChangeHash(bytes);
63
+ if (hash === undefined)
64
+ return reject("the Automerge change checksum does not match its hash (§11)");
65
+ const actions = ["makeMap", "set", "makeList", "del", "makeText", "inc", "makeTable", "mark"];
66
+ const types = {
67
+ 3: "uint",
68
+ 4: "int",
69
+ 5: "float64",
70
+ 8: "counter",
71
+ 9: "timestamp",
72
+ };
73
+ decoded = {
74
+ actor: parsed.actor,
75
+ seq: parsed.seq,
76
+ startOp: parsed.startOp,
77
+ time: parsed.time,
78
+ message: parsed.message,
79
+ author: null,
80
+ hash,
81
+ deps: [...parsed.deps],
82
+ ops: parsed.ops.map((op) => ({
83
+ action: actions[op.action],
84
+ obj: op.obj,
85
+ pred: [...op.pred],
86
+ ...(op.key.kind === "prop"
87
+ ? { key: op.key.name }
88
+ : { elemId: op.key.kind === "head" ? "_head" : op.key.id }),
89
+ ...(op.insert ? { insert: true } : {}),
90
+ ...([1, 5, 7].includes(op.action) ? { value: op.value } : {}),
91
+ ...(types[op.valueType] === undefined ? {} : { datatype: types[op.valueType] }),
92
+ ...(op.name === null ? {} : { name: op.name }),
93
+ ...(op.expand ? { expand: true } : {}),
94
+ })),
95
+ };
56
96
  }
57
97
  if (toHex(bytes.subarray(4, 8)) !== decoded.hash.slice(0, 8))
58
98
  reject("the Automerge change checksum does not match its hash (§11)");
@@ -127,7 +167,14 @@ export function parsedOf(change) {
127
167
  const DECODED = new WeakMap();
128
168
  /** The decoded change, reusing the decode of checkChange when there was one. */
129
169
  export function decodedOf(change) {
130
- return DECODED.get(change) ?? A.decodeChange(change.bytes);
170
+ const decoded = DECODED.get(change);
171
+ if (decoded !== undefined)
172
+ return decoded;
173
+ // CheckedChange is structural: callers can pass a copy without the cached decode.
174
+ const checked = checkChange(change.bytes);
175
+ const result = DECODED.get(checked);
176
+ DECODED.set(change, result);
177
+ return result;
131
178
  }
132
179
  /**
133
180
  * Checks that `bytes` starts as an Automerge full save (a document chunk,
@@ -14,6 +14,7 @@
14
14
  // its actor's changes and their counter ranges.
15
15
  import * as A from "@automerge/automerge";
16
16
  import { checkCanonicalChange } from "./canonical.js";
17
+ import { refusedChangeHash } from "./framing.js";
17
18
  const MAKE = new Set([0, 2, 4, 6]);
18
19
  const MAP_LIKE = new Set([0, 6]);
19
20
  function slotOf(op, id) {
@@ -196,7 +197,9 @@ export function referenceHistoryOf(doc) {
196
197
  const parsed = new Map();
197
198
  for (const bytes of A.getAllChanges(doc)) {
198
199
  try {
199
- parsed.set(A.decodeChange(bytes).hash, checkCanonicalChange(bytes));
200
+ const hash = refusedChangeHash(bytes);
201
+ if (hash !== undefined)
202
+ parsed.set(hash, checkCanonicalChange(bytes));
200
203
  }
201
204
  catch {
202
205
  // not readable: left out
@@ -84,6 +84,11 @@ export interface SharedObjectsCodec {
84
84
  encode(change: CheckedChange): Uint8Array;
85
85
  decode(plaintext: Uint8Array): CheckedChange;
86
86
  }
87
+ /** An object-changed listener that threw: the error, and the change it was given. */
88
+ export interface SharedObjectsListenerFailure {
89
+ readonly error: unknown;
90
+ readonly change: ObjectChange;
91
+ }
87
92
  export declare class SharedObjectsDataProfile {
88
93
  #private;
89
94
  readonly dataProfile = "org.openlfcp.shared-objects.v1";
@@ -103,8 +108,20 @@ export declare class SharedObjectsDataProfile {
103
108
  static restore(checkpoint: SharedObjectsCheckpoint, options: Pick<ReplicaOptions, "resource" | "principal">): SharedObjectsDataProfile;
104
109
  /** The current replica (a G-EP7 rebuild replaces it). */
105
110
  get replica(): SharedObjectsReplica;
106
- /** §98, §100: object change notifications for remote merges and rebuilds. */
111
+ /**
112
+ * §98, §100: object change notifications for remote merges and rebuilds.
113
+ * A listener that changes the model again (re-entrantly) makes the
114
+ * nested event reach the listeners after it before the outer one does,
115
+ * as in 0.2.1.
116
+ */
107
117
  onObjectChanged(listener: (change: ObjectChange) => void): () => void;
118
+ /**
119
+ * Called when an object-changed listener throws (REVIEW-20261015-03),
120
+ * with the error and the change. Without a handler the error goes to the
121
+ * runtime's reportError where there is one (a browser, Electron), else it
122
+ * is dropped. A handler that throws is not reported again.
123
+ */
124
+ onListenerError(handler: (failure: SharedObjectsListenerFailure) => void): () => void;
108
125
  /**
109
126
  * Records which change one of this client's OWN units carries (the unit
110
127
  * was created from a local change, so it is merged already). A later
@@ -1,6 +1,7 @@
1
1
  import { LfcpError, toHex, } from "@openlfcp/core";
2
2
  import { checkChangeActor } from "./admission/actor.js";
3
3
  import { frameChange, frameSnapshot, unframeChange, unframeSnapshot, } from "./admission/framing.js";
4
+ import { GuardedListeners } from "./listeners.js";
4
5
  import { SharedObjectsReplica } from "./replica.js";
5
6
  import { deriveActorId, PROFILE_ID } from "./values.js";
6
7
  export class SharedObjectsDataProfile {
@@ -12,7 +13,7 @@ export class SharedObjectsDataProfile {
12
13
  #pending = new Map();
13
14
  /** LFCP-accepted units whose actor and sequence number a different change has (§14.1), by unit ID hex. */
14
15
  #held = new Map();
15
- #listeners = new Set();
16
+ #listeners = new GuardedListeners((error, change) => Object.freeze({ error, change }));
16
17
  constructor(replica) {
17
18
  this.#replica = replica;
18
19
  }
@@ -55,15 +56,34 @@ export class SharedObjectsDataProfile {
55
56
  get replica() {
56
57
  return this.#replica;
57
58
  }
58
- /** §98, §100: object change notifications for remote merges and rebuilds. */
59
+ /**
60
+ * §98, §100: object change notifications for remote merges and rebuilds.
61
+ * A listener that changes the model again (re-entrantly) makes the
62
+ * nested event reach the listeners after it before the outer one does,
63
+ * as in 0.2.1.
64
+ */
59
65
  onObjectChanged(listener) {
60
- this.#listeners.add(listener);
61
- return () => this.#listeners.delete(listener);
66
+ return this.#listeners.add(listener);
62
67
  }
68
+ /**
69
+ * Called when an object-changed listener throws (REVIEW-20261015-03),
70
+ * with the error and the change. Without a handler the error goes to the
71
+ * runtime's reportError where there is one (a browser, Electron), else it
72
+ * is dropped. A handler that throws is not reported again.
73
+ */
74
+ onListenerError(handler) {
75
+ return this.#listeners.onError(handler);
76
+ }
77
+ /**
78
+ * Every listener gets every change, and a listener that throws never
79
+ * turns the operation that changed the model (a merge, a rebuild, a
80
+ * loaded Snapshot, a reset) into a failure: the change stands and the
81
+ * error is reported (onListenerError), never recursively. An engine trap
82
+ * is thrown on once every listener has had every change, as SyncClient
83
+ * does: the engine is dead.
84
+ */
63
85
  #emit(changes) {
64
- for (const c of changes)
65
- for (const l of this.#listeners)
66
- l(c);
86
+ this.#listeners.emit(changes);
67
87
  }
68
88
  /**
69
89
  * Records which change one of this client's OWN units carries (the unit
@@ -0,0 +1,22 @@
1
+ import * as A from "@automerge/automerge";
2
+ import { LfcpError, type PrincipalId, type ResourceId } from "@openlfcp/core";
3
+ import { type CheckedChange } from "../admission/framing.js";
4
+ import { type IdentityWriter } from "./values.js";
5
+ export declare const IDENTITY_ADMISSION_ORDER: readonly ["INVALID_AUTOMERGE_BYTES", "CHANGE_ACTOR_MISMATCH", "INVALID_ROOT", "CONTAINER_REPLACED", "IMMUTABLE_FIELD_MUTATED", "UNAUTHORIZED_WRITER", "INVALID_FIELD_TYPE"];
6
+ export type IdentityAdmissionDiagnostic = (typeof IDENTITY_ADMISSION_ORDER)[number];
7
+ export declare class IdentityAdmissionError extends LfcpError {
8
+ readonly diagnostic: IdentityAdmissionDiagnostic;
9
+ constructor(diagnostic: IdentityAdmissionDiagnostic, message: string);
10
+ }
11
+ export declare function refuseIdentity(diagnostic: IdentityAdmissionDiagnostic, message: string): never;
12
+ /** Must run on every unit, even a duplicate change, BEFORE SOP §14.1 sequence holding. */
13
+ export declare function checkIdentityUnit(change: CheckedChange, writer: IdentityWriter, resource: ResourceId, root: PrincipalId): void;
14
+ /** Operations are inspected before applying them. Object provenance includes overwritten maps. */
15
+ export declare class IdentityAdmission {
16
+ #private;
17
+ readonly root: PrincipalId;
18
+ constructor(doc: A.Doc<unknown>, root: PrincipalId);
19
+ get document(): A.Doc<unknown>;
20
+ accept(change: CheckedChange, writer: IdentityWriter): void;
21
+ }
22
+ //# sourceMappingURL=admission.d.ts.map
@@ -0,0 +1,280 @@
1
+ import * as A from "@automerge/automerge";
2
+ import { actorSequence, bytesEqual, LfcpError, toHex, UINT64_MAX, } from "@openlfcp/core";
3
+ import { checkChangeActor } from "../admission/actor.js";
4
+ import { checkChange, decodedOf, parsedOf } from "../admission/framing.js";
5
+ import { MAX_DOCUMENT_DEPTH } from "../admission/limits.js";
6
+ import { ReferenceHistory } from "../admission/references.js";
7
+ import { isWasmTrap } from "../replica.js";
8
+ import { principalRef } from "../values.js";
9
+ import { distinct } from "./reader.js";
10
+ import { deriveIdentityActorId, IDENTITY_PROFILE_ID } from "./values.js";
11
+ export const IDENTITY_ADMISSION_ORDER = Object.freeze([
12
+ "INVALID_AUTOMERGE_BYTES",
13
+ "CHANGE_ACTOR_MISMATCH",
14
+ "INVALID_ROOT",
15
+ "CONTAINER_REPLACED",
16
+ "IMMUTABLE_FIELD_MUTATED",
17
+ "UNAUTHORIZED_WRITER",
18
+ "INVALID_FIELD_TYPE",
19
+ ]);
20
+ export class IdentityAdmissionError extends LfcpError {
21
+ diagnostic;
22
+ constructor(diagnostic, message) {
23
+ super("PROFILE_INVALID", message);
24
+ this.diagnostic = diagnostic;
25
+ this.name = "IdentityAdmissionError";
26
+ }
27
+ }
28
+ export function refuseIdentity(diagnostic, message) {
29
+ throw new IdentityAdmissionError(diagnostic, message);
30
+ }
31
+ /** Must run on every unit, even a duplicate change, BEFORE SOP §14.1 sequence holding. */
32
+ export function checkIdentityUnit(change, writer, resource, root) {
33
+ // A dependency-free seq 2 is R1, even if the root/sequence eligibility also fails.
34
+ if (change.deps.length === 0 && new ReferenceHistory().check(parsedOf(change)) !== null)
35
+ refuseIdentity("INVALID_AUTOMERGE_BYTES", "the initial change fails SOP §11.4");
36
+ checkChangeActor(change, toHex(deriveIdentityActorId(resource, writer.signer)));
37
+ if (change.deps.length === 0 &&
38
+ (change.actor !== toHex(deriveIdentityActorId(resource, root)) ||
39
+ change.seq !== 1 ||
40
+ !bytesEqual(writer.signer, root) ||
41
+ (writer.actorSeq !== 1 && writer.actorSeq !== 1n)))
42
+ refuseIdentity("INVALID_ROOT", "AI0: the initial change must be in the root's actor sequence 1 unit");
43
+ actorSequence(writer.actorSeq);
44
+ if ((writer.writerClass === "root") !== bytesEqual(writer.signer, root) ||
45
+ !["root", "personal", "pairing", "other"].includes(writer.writerClass))
46
+ refuseIdentity("UNAUTHORIZED_WRITER", "writer class disagrees with the verified Genesis owner");
47
+ }
48
+ const MAKE = new Map([
49
+ ["makeMap", "map"],
50
+ ["makeList", "list"],
51
+ ["makeText", "text"],
52
+ ["makeTable", "table"],
53
+ ]);
54
+ const CONTAINERS = new Set(["roster", "devices", "requests", "extensions"]);
55
+ const TABLES = new Set(["roster", "devices", "requests"]);
56
+ const FIELDS = {
57
+ roster: new Set(["profile", "endpoints", "coordinator", "name", "archived"]),
58
+ devices: new Set(["label", "kind", "platform", "added_at", "revoked", "missing_keys"]),
59
+ requests: new Set([
60
+ "descriptor",
61
+ "pairing",
62
+ "label",
63
+ "kind",
64
+ "scope",
65
+ "platform",
66
+ "requested_at",
67
+ ]),
68
+ };
69
+ /** Operations are inspected before applying them. Object provenance includes overwritten maps. */
70
+ export class IdentityAdmission {
71
+ root;
72
+ #doc;
73
+ #changes;
74
+ #requests;
75
+ #objects = new Map([["_root", { path: [], type: "map" }]]);
76
+ constructor(doc, root) {
77
+ this.root = root;
78
+ // One independent working document, reused for every accepted change.
79
+ this.#doc = A.clone(doc);
80
+ this.#changes = A.getAllChanges(doc);
81
+ for (const bytes of this.#changes)
82
+ this.#index(decodedOf(checkChange(bytes)));
83
+ }
84
+ get document() {
85
+ return this.#doc;
86
+ }
87
+ #index(change) {
88
+ for (const [i, op] of change.ops.entries()) {
89
+ const type = MAKE.get(op.action);
90
+ const parent = this.#objects.get(op.obj);
91
+ if (type === undefined || parent === undefined)
92
+ continue;
93
+ const id = `${change.startOp + i}@${change.actor}`;
94
+ const path = [...parent.path, op.key ?? -1];
95
+ if (path.length === 1 && path[0] === "requests")
96
+ this.#requests = id;
97
+ const requestMap = path[0] === "requests" && path.length === 2 ? id : parent.requestMap;
98
+ this.#objects.set(id, { path, type, ...(requestMap === undefined ? {} : { requestMap }) });
99
+ }
100
+ }
101
+ accept(change, writer) {
102
+ const decoded = decodedOf(change);
103
+ const ops = decoded.ops;
104
+ const initial = change.deps.length === 0;
105
+ const found = new Set();
106
+ const local = new Map();
107
+ const info = (id) => local.get(id) ?? this.#objects.get(id);
108
+ const backend = A.getBackend(this.#doc);
109
+ const all = (obj, key) => local.has(obj) ? [] : backend.getAll(obj, key, [...change.deps]);
110
+ const restricted = writer.writerClass === "pairing" || writer.writerClass === "other";
111
+ const own = principalRef(writer.signer);
112
+ const touched = new Set();
113
+ const createdRequests = new Set();
114
+ const pairings = new Map();
115
+ if (initial) {
116
+ const roots = ops.filter((op) => op.obj === "_root");
117
+ const identity = roots.find((op) => op.key === "identity" && op.action === "makeMap");
118
+ const identityIndex = identity === undefined ? -1 : ops.indexOf(identity);
119
+ const rootValue = ops.find((op) => op.obj === `${decoded.startOp + identityIndex}@${change.actor}`);
120
+ if (ops.length !== 7 ||
121
+ roots.length !== 6 ||
122
+ new Set(roots.map((op) => op.key)).size !== 6 ||
123
+ !roots.every((op) => op.key === "profile"
124
+ ? op.action === "set" && op.value === IDENTITY_PROFILE_ID && op.datatype === undefined
125
+ : op.key !== undefined &&
126
+ ["identity", ...CONTAINERS].includes(op.key) &&
127
+ op.action === "makeMap") ||
128
+ rootValue?.key !== "root" ||
129
+ rootValue.action !== "set" ||
130
+ rootValue.datatype !== undefined ||
131
+ rootValue.value !== principalRef(this.root))
132
+ found.add("INVALID_ROOT");
133
+ }
134
+ for (const [i, op] of ops.entries()) {
135
+ const id = `${decoded.startOp + i}@${change.actor}`;
136
+ const parent = info(op.obj);
137
+ if (parent === undefined)
138
+ refuseIdentity("INVALID_AUTOMERGE_BYTES", "unknown operation object");
139
+ const path = parent.path;
140
+ const top = String(path[0]);
141
+ const key = op.key;
142
+ const made = MAKE.get(op.action);
143
+ if (made !== undefined) {
144
+ if (path.length + 1 > MAX_DOCUMENT_DEPTH)
145
+ refuseIdentity("INVALID_AUTOMERGE_BYTES", "the change exceeds SOP §11.2 depth");
146
+ const child = [...path, key ?? -1];
147
+ const requestMap = child[0] === "requests" && child.length === 2 ? id : parent.requestMap;
148
+ local.set(id, {
149
+ path: child,
150
+ type: made,
151
+ ...(requestMap === undefined ? {} : { requestMap }),
152
+ });
153
+ }
154
+ if (op.action === "makeText")
155
+ found.add("INVALID_FIELD_TYPE");
156
+ if (!initial && op.obj === "_root") {
157
+ if (key !== undefined && CONTAINERS.has(key))
158
+ found.add("CONTAINER_REPLACED");
159
+ if (key === "profile" || key === "identity")
160
+ found.add("IMMUTABLE_FIELD_MUTATED");
161
+ }
162
+ if (!initial && top === "identity")
163
+ found.add("IMMUTABLE_FIELD_MUTATED");
164
+ // AI2 on the causal history: the objects a scalar replaces are those its
165
+ // operation names as predecessors (SOP §11.4 R6), made in the history or
166
+ // earlier in this change, selected by the engine or not. Two levels
167
+ // only, unlike AI5: an entry map, and a list or map held in a field of
168
+ // an entry map. Deeper values, and a list in place of an entry map, are
169
+ // §4's to judge.
170
+ if (TABLES.has(top) && key !== undefined && op.action === "set")
171
+ for (const pred of op.pred) {
172
+ const type = info(pred)?.type;
173
+ if ((path.length === 1 && type === "map") ||
174
+ (path.length === 2 && parent.type === "map" && (type === "list" || type === "map")))
175
+ found.add("CONTAINER_REPLACED");
176
+ }
177
+ // AI5: devices[k].missing_keys and everything under it, in every entry
178
+ // map of the history (hidden, replaced or deleted ones included), is
179
+ // written only by the Principal k names.
180
+ const hint = path[0] === "devices" &&
181
+ ((path.length === 2 && key === "missing_keys") ||
182
+ (path.length >= 3 && path[2] === "missing_keys"));
183
+ if (hint && path[1] !== own)
184
+ found.add("UNAUTHORIZED_WRITER");
185
+ // AI3 checks structural value kinds, leaving scalar field semantics to §4.
186
+ const defined = (path.length === 1 && TABLES.has(top)) ||
187
+ (path.length === 2 && key !== undefined && FIELDS[top]?.has(key)) ||
188
+ (path.length >= 3 && ["endpoints", "scope", "missing_keys"].includes(String(path[2])));
189
+ if (defined && op.action !== "del") {
190
+ const scalar = op.action === "set" &&
191
+ ((typeof op.value === "string" && op.datatype === undefined) ||
192
+ typeof op.value === "boolean" ||
193
+ (op.datatype === "uint" &&
194
+ ((typeof op.value === "number" && Number.isSafeInteger(op.value) && op.value >= 0) ||
195
+ (typeof op.value === "bigint" && op.value >= 0n && op.value <= UINT64_MAX))));
196
+ if (!scalar && made !== "map" && made !== "list")
197
+ found.add("INVALID_FIELD_TYPE");
198
+ if (parent.type === "list" && !scalar)
199
+ found.add("INVALID_FIELD_TYPE");
200
+ }
201
+ if (!restricted)
202
+ continue;
203
+ if (op.action === "del")
204
+ found.add("UNAUTHORIZED_WRITER");
205
+ if (top !== "requests" || path.length < 1) {
206
+ found.add("UNAUTHORIZED_WRITER");
207
+ continue;
208
+ }
209
+ const entryKey = path.length === 1 ? key : String(path[1]);
210
+ if (entryKey === undefined) {
211
+ found.add("UNAUTHORIZED_WRITER");
212
+ continue;
213
+ }
214
+ touched.add(entryKey);
215
+ const requestMap = path.length === 1 ? (op.action === "makeMap" ? id : undefined) : parent.requestMap;
216
+ const requests = path.length === 1 ? op.obj : this.#requests;
217
+ const history = requests === undefined ? [] : all(requests, entryKey);
218
+ if (path.length === 1) {
219
+ if (op.action !== "makeMap" || history.length !== 0 || createdRequests.has(entryKey))
220
+ found.add("UNAUTHORIZED_WRITER");
221
+ createdRequests.add(entryKey);
222
+ }
223
+ else if (requestMap === undefined)
224
+ found.add("UNAUTHORIZED_WRITER");
225
+ else if (!local.has(requestMap)) {
226
+ const pairing = distinct(all(requestMap, "pairing"));
227
+ if (history.length !== 1 ||
228
+ history[0]?.[0] !== "map" ||
229
+ history[0][1] !== requestMap ||
230
+ !requestMap.endsWith(`@${change.actor}`) ||
231
+ pairing.length !== 1 ||
232
+ pairing[0]?.[0] !== "str" ||
233
+ pairing[0][1] !== own)
234
+ found.add("UNAUTHORIZED_WRITER");
235
+ }
236
+ if (requestMap !== undefined && !pairings.has(requestMap))
237
+ pairings.set(requestMap, new Map(all(requestMap, "pairing").map((v) => [String(v[2]), v])));
238
+ if (path.length === 2 && key === "pairing" && requestMap !== undefined) {
239
+ const values = pairings.get(requestMap);
240
+ for (const pred of op.pred)
241
+ values.delete(pred);
242
+ if (op.action !== "del")
243
+ values.set(id, [
244
+ made ?? op.datatype ?? (typeof op.value === "string" ? "str" : typeof op.value),
245
+ op.value,
246
+ id,
247
+ ]);
248
+ }
249
+ }
250
+ if (restricted) {
251
+ if (touched.size !== 1)
252
+ found.add("UNAUTHORIZED_WRITER");
253
+ for (const values of pairings.values()) {
254
+ const final = distinct([...values.values()]);
255
+ if (final.length !== 1 || final[0]?.[0] !== "str" || final[0][1] !== own)
256
+ found.add("UNAUTHORIZED_WRITER");
257
+ }
258
+ }
259
+ const diagnostic = IDENTITY_ADMISSION_ORDER.find((d) => found.has(d));
260
+ if (diagnostic !== undefined)
261
+ refuseIdentity(diagnostic, `IDENTITY-PROFILE-01 §3: ${diagnostic}`);
262
+ // Do not copy the accumulated history on every accepted change. The byte
263
+ // log and object index are committed only after apply succeeds; if the
264
+ // engine aborts, replay that trusted log into a fresh working document.
265
+ const actor = A.getActorId(this.#doc);
266
+ try {
267
+ [this.#doc] = A.applyChanges(this.#doc, [change.bytes]);
268
+ }
269
+ catch (e) {
270
+ if (isWasmTrap(e))
271
+ throw e;
272
+ A.free(this.#doc);
273
+ [this.#doc] = A.applyChanges(A.init({ actor }), this.#changes);
274
+ refuseIdentity("INVALID_AUTOMERGE_BYTES", `the engine refused the change: ${String(e)}`);
275
+ }
276
+ this.#changes.push(change.bytes);
277
+ this.#index(decoded);
278
+ }
279
+ }
280
+ //# sourceMappingURL=admission.js.map
@@ -0,0 +1,6 @@
1
+ /** IDENTITY-PROFILE-01 §§2–4. No authority is inferred from document metadata. */
2
+ export { checkIdentityUnit, IDENTITY_ADMISSION_ORDER, IdentityAdmission, type IdentityAdmissionDiagnostic, IdentityAdmissionError, } from "./admission.js";
3
+ export { type IdentityIntent, type IdentityOptions, type IdentityReceiveResult, type IdentityRefusal, IdentityReplica, type IdentityUnit, identityCodec, identitySnapshotCodec, } from "./replica.js";
4
+ export { IDENTITY_DIAGNOSTIC_ORDER, type IdentityDiagnostic, type IdentityProblem, type IdentityValidation, validateIdentity, } from "./schema.js";
5
+ export { type DeviceEntry, deriveIdentityActorId, IDENTITY_PROFILE_ID, type IdentityDocument, type IdentityEpoch, type IdentityWriter, type IdentityWriterClass, isResourceRef, type PairingRequest, type ResourceRef, type RosterEntry, } from "./values.js";
6
+ //# sourceMappingURL=index.d.ts.map