@uuaid/agent-commons 0.0.0-stage → 0.2.0-alpha.2

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.
Files changed (69) hide show
  1. package/CONFORMANCE.md +26 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +25 -0
  4. package/PROVENANCE.json +29 -0
  5. package/README.md +316 -2
  6. package/bin/agent-commons.mjs +71 -0
  7. package/examples/Dockerfile +12 -0
  8. package/examples/agent.mjs +17 -0
  9. package/examples/global-policy.json +9 -0
  10. package/examples/run-cohort.mjs +68 -0
  11. package/package.json +31 -4
  12. package/profiles/agentnet.chat.json +11 -0
  13. package/profiles/local.json +11 -0
  14. package/profiles/zilligon.com.json +11 -0
  15. package/profiles/zilligons.com.json +11 -0
  16. package/src/carrier.mjs +79 -0
  17. package/src/channels.d.mts +75 -0
  18. package/src/channels.mjs +158 -0
  19. package/src/cohort.mjs +61 -0
  20. package/src/collaboration.mjs +875 -0
  21. package/src/config.mjs +39 -0
  22. package/src/continuity.d.mts +24 -0
  23. package/src/continuity.mjs +594 -0
  24. package/src/governance-scope.d.mts +46 -0
  25. package/src/governance-scope.mjs +139 -0
  26. package/src/governance.mjs +353 -0
  27. package/src/index.d.ts +109 -0
  28. package/src/index.mjs +15 -0
  29. package/src/loop.mjs +71 -0
  30. package/src/offline.d.mts +7 -0
  31. package/src/offline.mjs +29 -0
  32. package/src/pillar.mjs +59 -0
  33. package/src/profiles.d.mts +12 -0
  34. package/src/profiles.mjs +91 -0
  35. package/src/runtime.mjs +346 -0
  36. package/src/store.mjs +73 -0
  37. package/src/transport.d.mts +76 -0
  38. package/src/transport.mjs +267 -0
  39. package/src/trust.mjs +65 -0
  40. package/src/vendor/pillar/blob/blob.mjs +510 -0
  41. package/src/vendor/pillar/crypto/e2e.mjs +97 -0
  42. package/src/vendor/pillar/identity/jcs.mjs +35 -0
  43. package/src/vendor/pillar/identity/keychain.mjs +192 -0
  44. package/src/vendor/pillar/identity/tier.mjs +130 -0
  45. package/src/vendor/pillar/net/carrier-client.mjs +197 -0
  46. package/src/vendor/pillar/net/delegation.mjs +209 -0
  47. package/src/vendor/pillar/net/envelope.mjs +258 -0
  48. package/src/vendor/pillar/net/refusals.mjs +127 -0
  49. package/src/vendor/pillar/net/seed-tier.mjs +185 -0
  50. package/test/channels.test.mjs +268 -0
  51. package/test/cohort.test.mjs +36 -0
  52. package/test/collaboration.test.mjs +459 -0
  53. package/test/continuity.test.mjs +229 -0
  54. package/test/custody.negative.test.mjs +165 -0
  55. package/test/derivation-parity.test.mjs +19 -0
  56. package/test/fixtures/derivation-vectors.json +26 -0
  57. package/test/fixtures/ed25519-small-order.vectors.json +15 -0
  58. package/test/governance-expected-red.expected-red.mjs +241 -0
  59. package/test/governance-scope.test.mjs +142 -0
  60. package/test/governance-t1-t10.test.mjs +477 -0
  61. package/test/governance.test.mjs +514 -0
  62. package/test/hold-admission.test.mjs +1185 -0
  63. package/test/keychain-strict-ed25519.test.mjs +240 -0
  64. package/test/offline.test.mjs +152 -0
  65. package/test/runtime-channels.test.mjs +1140 -0
  66. package/test/runtime.test.mjs +201 -0
  67. package/test/security-runtime.test.mjs +104 -0
  68. package/test/strict-ed25519-consumers.test.mjs +234 -0
  69. package/test/transport.test.mjs +353 -0
package/src/config.mjs ADDED
@@ -0,0 +1,39 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { randomBytes } from "node:crypto";
5
+ import { Keychain } from "./pillar.mjs";
6
+ import { createProfile } from "./profiles.mjs";
7
+ import { CommonsStore } from "./store.mjs";
8
+ import { AgentCommons } from "./runtime.mjs";
9
+
10
+ export const CAPABILITIES=["commons:message","commons:relay","commons:evolve","commons:contribute","commons:recover"];
11
+ export function homePath(path){return resolve(path??join(homedir(),".agent-commons"))}
12
+ export function saveConfig(home,config){writeFileSync(join(home,"config.json"),JSON.stringify(config,null,2),{mode:0o600});chmodSync(join(home,"config.json"),0o600)}
13
+ export function initialize({home,target="local",name="Agent Commons"}={}){
14
+ home=homePath(home);mkdirSync(home,{recursive:true,mode:0o700});
15
+ if(existsSync(join(home,"config.json"))||existsSync(join(home,"identity.json")))throw new Error("Home already initialized or contains an identity; existing identity was not overwritten");
16
+ // Preserve old prerelease invocations, but all new target configuration uses
17
+ // the production-domain spelling confirmed by the owner.
18
+ if(target==="zilligons.com")target="zilligon.com";
19
+ if(!["local","agentnet.chat","zilligon.com"].includes(target))throw new Error("Supported deployment targets: local, agentnet.chat, zilligon.com");
20
+ const passphrase=process.env.AGENT_COMMONS_PASSPHRASE??randomBytes(32).toString("base64url");
21
+ if(!process.env.AGENT_COMMONS_PASSPHRASE)writeFileSync(join(home,"local-secret"),passphrase,{mode:0o600});
22
+ const keychain=new Keychain(join(home,"identity.json"));keychain.save(Keychain.generate(),{passphrase});
23
+ const identity=keychain.publicView();
24
+ const config={version:1,name,target,role:"host",carriers:[],policy:{mode:"local",offline:true,registryUrl:"https://api.uuaid.org",authorityUrl:"https://authority.iaaso.org",standardPins:{},blocked:[],agents:{[identity.uuaid]:{kind:"agent",publicKey:identity.publicKeyHex,capabilities:CAPABILITIES}}}};
25
+ saveConfig(home,config);
26
+ const store=new CommonsStore(join(home,"commons.db"));
27
+ const template=JSON.parse(readFileSync(new URL(`../profiles/${target==="local"?"local":target}.json`,import.meta.url),"utf8"));
28
+ const profile=createProfile(template);
29
+ const runtime=new AgentCommons({keychain,store,policy:config.policy});runtime.addProfile(profile);store.close();
30
+ return {home,identity,target,profileId:profile.id,registryRegistration:"not-performed",certified:false,keyProtection:process.env.AGENT_COMMONS_PASSPHRASE?"environment-passphrase":"file-permissions-with-colocated-local-secret"};
31
+ }
32
+ export function loadRuntime(home){
33
+ home=homePath(home);
34
+ const config=JSON.parse(readFileSync(join(home,"config.json"),"utf8"));
35
+ const passphrase=process.env.AGENT_COMMONS_PASSPHRASE??readFileSync(join(home,"local-secret"),"utf8");
36
+ const keychain=new Keychain(join(home,"identity.json"));keychain.load({passphrase});
37
+ const store=new CommonsStore(join(home,"commons.db"));
38
+ return {home,config,keychain,store,runtime:new AgentCommons({keychain,store,policy:config.policy,carriers:config.carriers})};
39
+ }
@@ -0,0 +1,24 @@
1
+ export type ProvenanceSource = "self"|"peer"|"operator"|"tool";
2
+ export interface ContinuityStore {get(key:string,fallback?:unknown):any;set(key:string,value:unknown):void;transaction?<T>(fn:()=>T):T}
3
+ export interface MemoryKeychain {_identity:{uuaid:string;publicKeyHex:string};sign(bytes:Buffer):Buffer}
4
+ export interface MemoryClient {saveMemory(agent:string,slot:string,text:string,key:string):Promise<any>;loadMemory(agent:string,slot:string,key:string):Promise<string>}
5
+ export interface ContinuityEntry {id:string;seq:number;kind:string;content:any;hash:string;signature:string;at:string;provenance:any}
6
+ export interface ContinuityOptions {keychain:MemoryKeychain;store?:ContinuityStore;client?:MemoryClient|null;vaultKey?:string|null;slot?:string;limits?:{maxEntries?:number;maxBytes?:number;maxEntryBytes?:number;retentionMs?:number};now?:()=>number}
7
+ export declare class ContinuityMemory {
8
+ constructor(options:ContinuityOptions);
9
+ readonly uuaid:string;readonly publicKey:string;
10
+ remember(kind:string,content:unknown,provenance?:{source?:ProvenanceSource;actor?:string;origin?:string|null;evidence?:string|null;pin?:boolean}):ContinuityEntry;
11
+ recall(filter?:{kind?:string;since?:string;source?:ProvenanceSource;limit?:number}):ContinuityEntry[];
12
+ verify():boolean;head():string;status():any;
13
+ snapshot():any;
14
+ reload():any;compact():any;expired():any;
15
+ push():Promise<any>;pull():Promise<any>;sync():Promise<any>;
16
+ static storeKey(uuaid:string):string;
17
+ static quarantine(store:ContinuityStore,uuaid:string):string|null;
18
+ }
19
+ export declare class ContinuityError extends Error {code:string}
20
+ export declare class MemoryContinuityStore implements ContinuityStore {get(key:string,fallback?:unknown):any;set(key:string,value:unknown):void;transaction<T>(fn:()=>T):T}
21
+ export declare const CONTINUITY_PROTOCOL:string;
22
+ export declare const PROVENANCE_SOURCES:readonly ProvenanceSource[];
23
+ export declare function validateSlot(slot:string):string;
24
+ export declare function verifyContinuity(state:unknown,owner:unknown,options?:any):any;
@@ -0,0 +1,594 @@
1
+ /**
2
+ * Continuity memory for one UUAID-bound agent.
3
+ *
4
+ * The module keeps a bounded, hash-chained, signed memory ledger for exactly one
5
+ * identity (the agent whose Keychain is supplied). Every entry carries explicit
6
+ * provenance and is persisted locally through a CommonsStore (SQLite, WAL) so the
7
+ * agent survives process restarts without any network access.
8
+ *
9
+ * Remote continuity is strictly opt-in. Synchronization only happens when the
10
+ * caller (a) injects an official `@uuaid/sdk` `UuaidClient` (or an object with the
11
+ * identical `saveMemory`/`loadMemory` surface) and (b) supplies a `uvk_…` vault key,
12
+ * and then (c) explicitly calls `push()`, `pull()`, or `sync()`. The official SDK
13
+ * encrypts client-side (AES-256-GCM under HKDF, AAD bound to `${uuaid}/${slot}`),
14
+ * so only ciphertext leaves the process. This module never constructs a network
15
+ * client, never calls `registerAgent`, never performs signup, and never persists
16
+ * the vault key.
17
+ *
18
+ * Fail-closed guarantees: a corrupt or foreign local blob refuses to load; a remote
19
+ * snapshot that belongs to another UUAID or key, fails chain or signature checks,
20
+ * or has diverged from local history is rejected rather than merged. Several
21
+ * instances may share one store for one identity: every write re-reads the stored
22
+ * head inside the transaction and refuses (`STALE_WRITER`) if another instance
23
+ * advanced it, so cached state can never overwrite newer history.
24
+ *
25
+ * @module continuity
26
+ */
27
+ import { randomUUID } from "node:crypto";
28
+ import { digest, assertJSON } from "./profiles.mjs";
29
+ // Imported from the vendored Pillar identity modules directly (not ./pillar.mjs or
30
+ // ./store.mjs) so this file never loads node:sqlite or carrier code. The module is
31
+ // therefore usable on Node 20 with an injected store; Node 22 remains the tested target.
32
+ import { Keychain } from "./vendor/pillar/identity/keychain.mjs";
33
+ import { jcs } from "./vendor/pillar/identity/jcs.mjs";
34
+
35
+ /** Wire/storage protocol identifier for continuity snapshots. */
36
+ export const CONTINUITY_PROTOCOL = "agent-commons/continuity/1";
37
+
38
+ /**
39
+ * Provenance sources recognized by the ledger.
40
+ * - `self`: produced by this agent's own reasoning or tooling.
41
+ * - `peer`: received from another verified agent (actor must be that agent's UUAID).
42
+ * - `operator`: supplied by the deploying operator/policy.
43
+ * - `tool`: output of a deterministic local tool, benchmark, or fixture.
44
+ * @type {ReadonlyArray<ProvenanceSource>}
45
+ */
46
+ export const PROVENANCE_SOURCES = Object.freeze(["self", "peer", "operator", "tool"]);
47
+
48
+ /**
49
+ * Default resource ceilings. All are overridable per instance but never unbounded.
50
+ * @type {Readonly<ContinuityLimits>}
51
+ */
52
+ export const DEFAULT_LIMITS = Object.freeze({
53
+ maxEntries: 500,
54
+ maxBytes: 256 * 1024,
55
+ maxEntryBytes: 16 * 1024,
56
+ retentionMs: 90 * 86400000,
57
+ });
58
+
59
+ const UUAID_RE = /^uuaid:[a-z0-9-]+:agent:[0-9a-f-]{36}$/;
60
+ const KIND_RE = /^[a-z][a-z0-9:_-]{0,63}$/;
61
+ const SLOT_SEGMENT_RE = /^[A-Za-z0-9._-]{1,64}$/;
62
+ const VAULT_KEY_RE = /^uvk_[A-Za-z0-9_-]{16,}$/;
63
+
64
+ /**
65
+ * @typedef {"self"|"peer"|"operator"|"tool"} ProvenanceSource
66
+ */
67
+
68
+ /**
69
+ * Minimal durable key/value surface the ledger needs. `CommonsStore` (SQLite)
70
+ * satisfies it; any wrapper with the same shape may be injected instead.
71
+ * `transaction` is optional — when absent, writes are applied directly.
72
+ * @typedef {object} ContinuityStore
73
+ * @property {(key:string,fallback?:unknown)=>unknown} get Return the JSON value for `key`, or `fallback` (default null) when absent.
74
+ * @property {(key:string,value:unknown)=>void} set Durably write a JSON value.
75
+ * @property {(<T>(fn:()=>T)=>T)=} transaction Run `fn` atomically; rethrow on failure without applying partial writes.
76
+ */
77
+
78
+ /**
79
+ * Volatile fallback store (process memory only). Used when no store is injected so
80
+ * the module stays dependency-free; it is NOT durable and `status().durable` says so.
81
+ * @implements {ContinuityStore}
82
+ */
83
+ export class MemoryContinuityStore {
84
+ #map = new Map();
85
+ get(key, fallback = null) { return this.#map.has(key) ? structuredClone(this.#map.get(key)) : fallback; }
86
+ set(key, value) { this.#map.set(key, structuredClone(value)); }
87
+ transaction(fn) { const before = new Map(this.#map); try { return fn(); } catch (e) { this.#map = before; throw e; } }
88
+ }
89
+
90
+ /**
91
+ * @typedef {object} ContinuityLimits
92
+ * @property {number} maxEntries Maximum retained entries (oldest unpinned evicted first).
93
+ * @property {number} maxBytes Maximum canonical-JSON bytes of all retained entries; also caps the remote snapshot.
94
+ * @property {number} maxEntryBytes Maximum canonical-JSON bytes of one entry; larger entries are refused.
95
+ * @property {number} retentionMs Entries older than this are pruned on the next write (pinned entries exempt).
96
+ */
97
+
98
+ /**
99
+ * @typedef {object} Provenance
100
+ * @property {ProvenanceSource} source Where the memory came from.
101
+ * @property {string} actor UUAID of the agent that produced it (defaults to the owner).
102
+ * @property {string|null} origin Free-form, non-secret locator: session id, document digest, tool name.
103
+ * @property {string|null} evidence Optional sha256 hex digest of supporting material kept elsewhere.
104
+ */
105
+
106
+ /**
107
+ * @typedef {object} ContinuityEntry
108
+ * @property {typeof CONTINUITY_PROTOCOL} v
109
+ * @property {number} seq Monotonic sequence number within this identity's ledger (never reused after pruning).
110
+ * @property {string} id Random UUID for the entry.
111
+ * @property {string} at RFC 3339 creation timestamp.
112
+ * @property {string} kind Lower-case classifier such as `note`, `commitment`, `preference:operational`.
113
+ * @property {unknown} content Canonical-JSON-safe payload (see profiles.assertJSON).
114
+ * @property {Provenance} provenance
115
+ * @property {boolean} pin Pinned entries are exempt from retention/eviction but still count toward budgets.
116
+ * @property {string} previous Hash of the previous entry, or the anchor hash for the first retained entry.
117
+ * @property {string} hash sha256 of the canonical body (everything except `hash` and `signature`).
118
+ * @property {string} signature Ed25519 signature over the hash bytes, made by the owner's Keychain.
119
+ */
120
+
121
+ /**
122
+ * @typedef {object} ContinuityAnchor
123
+ * @property {string} previous Hash the first retained entry must link to (`genesis` until pruning occurs).
124
+ * @property {number} pruned Count of entries pruned over the lifetime of this ledger.
125
+ * @property {string|null} through `at` timestamp of the most recently pruned entry.
126
+ */
127
+
128
+ /**
129
+ * @typedef {object} SyncReceipt
130
+ * @property {string} at RFC 3339 time of the operation.
131
+ * @property {string} head Local head hash at the time of the operation.
132
+ * @property {string|null} contentHash Registry-reported `content_hash` of the stored envelope (push only).
133
+ * @property {number} sizeBytes Plaintext snapshot size that was encrypted and sent / received.
134
+ * @property {boolean|null} replaced Registry-reported `replaced` flag (push only).
135
+ * @property {"in-sync"|"fast-forwarded"|"local-ahead"|"remote-empty"|"pushed"} relation
136
+ */
137
+
138
+ /**
139
+ * @typedef {object} ContinuityState
140
+ * @property {typeof CONTINUITY_PROTOCOL} v
141
+ * @property {string} uuaid
142
+ * @property {string} publicKey Owner's Ed25519 public key (hex) — every entry signature must verify under it.
143
+ * @property {number} nextSeq
144
+ * @property {ContinuityAnchor} anchor
145
+ * @property {ContinuityEntry[]} entries
146
+ * @property {{lastPush:SyncReceipt|null,lastPull:SyncReceipt|null}} sync
147
+ */
148
+
149
+ /**
150
+ * @typedef {object} ContinuitySnapshot
151
+ * @property {typeof CONTINUITY_PROTOCOL} v
152
+ * @property {string} uuaid
153
+ * @property {string} publicKey
154
+ * @property {number} nextSeq
155
+ * @property {ContinuityAnchor} anchor
156
+ * @property {ContinuityEntry[]} entries
157
+ * @property {string} head
158
+ * @property {string} exportedAt
159
+ */
160
+
161
+ /**
162
+ * Minimal structural type of the official `@uuaid/sdk` client surface that this
163
+ * module depends on. A real `UuaidClient` satisfies it; test doubles must too.
164
+ * @typedef {object} OfficialMemoryClient
165
+ * @property {(agentUuaid:string,key:string,text:string,vaultKey:string)=>Promise<{agent:string,key:string,content_hash?:string,size_bytes?:number,replaced?:boolean}>} saveMemory
166
+ * @property {(agentUuaid:string,key:string,vaultKey:string)=>Promise<string>} loadMemory
167
+ */
168
+
169
+ /**
170
+ * @typedef {"IDENTITY_REQUIRED"|"CLIENT_INVALID"|"VAULT_KEY_INVALID"|"SLOT_INVALID"|"REMOTE_NOT_CONFIGURED"|"INTEGRITY"|"IDENTITY_MISMATCH"|"REMOTE_MISMATCH"|"DIVERGED"|"ENTRY_INVALID"|"ENTRY_TOO_LARGE"|"BUDGET_EXHAUSTED"|"REMOTE_UNAVAILABLE"|"STALE_WRITER"|"LIMIT_EXCEEDED"} ContinuityErrorCode
171
+ */
172
+
173
+ /** Error raised by the continuity module; `code` is stable and machine-checkable. */
174
+ export class ContinuityError extends Error {
175
+ /**
176
+ * @param {ContinuityErrorCode} code
177
+ * @param {string} message
178
+ * @param {Record<string,unknown>} [details]
179
+ */
180
+ constructor(code, message, details = {}) {
181
+ super(message);
182
+ this.name = "ContinuityError";
183
+ this.code = code;
184
+ this.details = details;
185
+ }
186
+ }
187
+
188
+ function isoNow(now) { return new Date(now()).toISOString(); }
189
+ function runAtomically(store, fn) { return typeof store.transaction === "function" ? store.transaction(fn) : fn(); }
190
+ function bytesOf(value) { return Buffer.byteLength(jcs(value), "utf-8"); }
191
+
192
+ /**
193
+ * Validate a vault slot path. Slots are path-like (`continuity/chain`), each
194
+ * segment restricted to a conservative charset; `.`/`..`/empty segments are refused.
195
+ * @param {string} slot
196
+ * @returns {string}
197
+ */
198
+ export function validateSlot(slot) {
199
+ if (typeof slot !== "string" || slot.length > 128) throw new ContinuityError("SLOT_INVALID", "Vault slot must be a short path-like string");
200
+ const segments = slot.split("/");
201
+ if (!segments.every(s => SLOT_SEGMENT_RE.test(s) && s !== "." && s !== "..")) throw new ContinuityError("SLOT_INVALID", "Vault slot segments must be [A-Za-z0-9._-] and non-empty", { slot });
202
+ return slot;
203
+ }
204
+
205
+ /**
206
+ * Verify a continuity snapshot/state structurally and cryptographically against the
207
+ * expected owner, and — when `limits` are supplied — against the resource envelope.
208
+ * Throws `ContinuityError` (`INTEGRITY`, `IDENTITY_MISMATCH`, or `LIMIT_EXCEEDED`) on
209
+ * any defect; returns the verified object unchanged otherwise. A correctly signed
210
+ * chain that exceeds the configured envelope is rejected whole, never pruned: a
211
+ * writer that bypassed the limits is not a trusted writer.
212
+ *
213
+ * @param {ContinuityState|ContinuitySnapshot} state
214
+ * @param {{uuaid:string,publicKey:string}} owner
215
+ * @param {{limits?:ContinuityLimits,now?:()=>number,age?:boolean}} [envelope] `age` (default true) also rejects unpinned entries older than `retentionMs`.
216
+ * @returns {ContinuityState|ContinuitySnapshot}
217
+ */
218
+ export function verifyContinuity(state, owner, envelope = {}) {
219
+ if (!state || typeof state !== "object") throw new ContinuityError("INTEGRITY", "Continuity state is not an object");
220
+ assertJSON(state);
221
+ if (state.v !== CONTINUITY_PROTOCOL) throw new ContinuityError("INTEGRITY", "Unknown continuity protocol", { v: state.v });
222
+ if (state.uuaid !== owner.uuaid || state.publicKey !== owner.publicKey) throw new ContinuityError("IDENTITY_MISMATCH", "Continuity state belongs to a different identity or key", { expected: owner.uuaid, found: state.uuaid });
223
+ if (Keychain.localIdFromKey(Buffer.from(owner.publicKey, "hex")) !== owner.uuaid.split(":")[3]) throw new ContinuityError("IDENTITY_MISMATCH", "Owner key is not bound to owner UUAID");
224
+ const { anchor, entries } = state;
225
+ if (!anchor || typeof anchor.previous !== "string" || !Number.isSafeInteger(anchor.pruned) || anchor.pruned < 0 || !Array.isArray(entries)) throw new ContinuityError("INTEGRITY", "Malformed anchor or entries");
226
+ if (!Number.isSafeInteger(state.nextSeq) || state.nextSeq < anchor.pruned + entries.length) throw new ContinuityError("INTEGRITY", "Sequence counter is inconsistent with ledger length");
227
+ let previous = anchor.previous;
228
+ let lastSeq = -1;
229
+ for (const entry of entries) {
230
+ const { hash, signature, ...body } = entry;
231
+ if (body.v !== CONTINUITY_PROTOCOL || body.previous !== previous || !Number.isSafeInteger(body.seq) || body.seq <= lastSeq) throw new ContinuityError("INTEGRITY", "Continuity chain link is broken", { seq: body.seq });
232
+ if (digest(body) !== hash) throw new ContinuityError("INTEGRITY", "Continuity entry hash mismatch", { seq: body.seq });
233
+ if (!/^[0-9a-f]{128}$/.test(signature ?? "") || !Keychain.verifyDetached(owner.publicKey, Buffer.from(hash, "hex"), Buffer.from(signature, "hex"))) throw new ContinuityError("INTEGRITY", "Continuity entry signature invalid", { seq: body.seq });
234
+ if (!PROVENANCE_SOURCES.includes(body.provenance?.source) || !UUAID_RE.test(body.provenance?.actor ?? "")) throw new ContinuityError("INTEGRITY", "Continuity entry provenance invalid", { seq: body.seq });
235
+ previous = hash;
236
+ lastSeq = body.seq;
237
+ }
238
+ if ("head" in state && state.head !== previous) throw new ContinuityError("INTEGRITY", "Snapshot head does not match chain");
239
+ if ((anchor.pruned === 0) !== (anchor.previous === "genesis") || (anchor.previous !== "genesis" && !/^[0-9a-f]{64}$/.test(anchor.previous))) throw new ContinuityError("INTEGRITY", "Anchor hash and pruned count disagree");
240
+ if (anchor.through !== null && (typeof anchor.through !== "string" || Number.isNaN(Date.parse(anchor.through)) || (entries.length && anchor.through > entries[0].at))) throw new ContinuityError("INTEGRITY", "Anchor retention marker is inconsistent with retained entries");
241
+ if ((anchor.through === null) !== (anchor.pruned === 0)) throw new ContinuityError("INTEGRITY", "Anchor retention marker and pruned count disagree");
242
+ if (envelope.limits) assertWithinLimits(state, envelope.limits, envelope.now ?? (() => Date.now()), envelope.age ?? true);
243
+ return state;
244
+ }
245
+
246
+ /**
247
+ * Enforce the resource envelope on an already-verified state. Fail closed with
248
+ * `LIMIT_EXCEEDED`; never trims.
249
+ * @param {ContinuityState|ContinuitySnapshot} state
250
+ * @param {ContinuityLimits} limits
251
+ * @param {() => number} now
252
+ * @param {boolean} [age] When true, unpinned entries older than `retentionMs` are a violation.
253
+ */
254
+ export function assertWithinLimits(state, limits, now = () => Date.now(), age = true) {
255
+ const { entries } = state;
256
+ if (entries.length > limits.maxEntries) throw new ContinuityError("LIMIT_EXCEEDED", "Snapshot retains more entries than maxEntries", { entries: entries.length, limit: limits.maxEntries });
257
+ let total = 0;
258
+ const cutoff = now() - limits.retentionMs;
259
+ for (const entry of entries) {
260
+ const size = bytesOf(entry);
261
+ if (size > limits.maxEntryBytes) throw new ContinuityError("LIMIT_EXCEEDED", "Snapshot entry exceeds maxEntryBytes", { seq: entry.seq, size, limit: limits.maxEntryBytes });
262
+ total += size;
263
+ const at = Date.parse(entry.at);
264
+ if (Number.isNaN(at) || at > now() + 300000) throw new ContinuityError("LIMIT_EXCEEDED", "Snapshot entry timestamp is invalid or in the future", { seq: entry.seq, at: entry.at });
265
+ if (age && !entry.pin && at < cutoff) throw new ContinuityError("LIMIT_EXCEEDED", "Snapshot retains an unpinned entry older than retentionMs", { seq: entry.seq, at: entry.at });
266
+ }
267
+ if (total > limits.maxBytes) throw new ContinuityError("LIMIT_EXCEEDED", "Snapshot exceeds maxBytes", { bytes: total, limit: limits.maxBytes });
268
+ }
269
+
270
+ /**
271
+ * Identity-scoped, bounded, hash-chained continuity memory with optional explicit
272
+ * encrypted remote synchronization through an injected official UUAID SDK client.
273
+ */
274
+ export class ContinuityMemory {
275
+ /** @type {string|null} */ #vaultKey;
276
+ /** @type {OfficialMemoryClient|null} */ #client;
277
+ /** @type {ContinuityState} */ #state;
278
+ /** @type {() => number} */ #now;
279
+ /** @type {string} Head hash this instance last loaded or committed (stale-writer guard). */ #loadedHead;
280
+ /** @type {number} nextSeq this instance last loaded or committed. */ #loadedSeq;
281
+
282
+ /**
283
+ * @param {object} options
284
+ * @param {Keychain} options.keychain Loaded Keychain whose `_identity` owns this ledger. Required.
285
+ * @param {ContinuityStore} [options.store] Durable local store (e.g. CommonsStore). Defaults to volatile MemoryContinuityStore.
286
+ * @param {OfficialMemoryClient|null} [options.client] Injected official `UuaidClient`. Never constructed here.
287
+ * @param {string|null} [options.vaultKey] `uvk_…` key from `generateVaultKey()`. Held in a private field, never persisted.
288
+ * @param {string} [options.slot] Vault slot path; defaults to `continuity/chain`.
289
+ * @param {Partial<ContinuityLimits>} [options.limits]
290
+ * @param {() => number} [options.now] Clock injection for tests (ms since epoch).
291
+ */
292
+ constructor({ keychain, store = new MemoryContinuityStore(), client = null, vaultKey = null, slot = "continuity/chain", limits = {}, now = () => Date.now() } = {}) {
293
+ const identity = keychain?._identity;
294
+ if (!identity || !UUAID_RE.test(identity.uuaid ?? "") || !/^[0-9a-f]{64}$/.test(identity.publicKeyHex ?? "") || typeof keychain.sign !== "function") throw new ContinuityError("IDENTITY_REQUIRED", "A loaded Keychain with a bound agent UUAID is required");
295
+ if (Keychain.localIdFromKey(Buffer.from(identity.publicKeyHex, "hex")) !== identity.uuaid.split(":")[3]) throw new ContinuityError("IDENTITY_REQUIRED", "Keychain public key is not bound to its UUAID");
296
+ if (client !== null && (typeof client !== "object" || typeof client.saveMemory !== "function" || typeof client.loadMemory !== "function")) throw new ContinuityError("CLIENT_INVALID", "Injected client must expose the official saveMemory/loadMemory surface");
297
+ if (vaultKey !== null && !VAULT_KEY_RE.test(vaultKey)) throw new ContinuityError("VAULT_KEY_INVALID", "Vault key must be a uvk_ key from @uuaid/sdk generateVaultKey()");
298
+ if (!store || typeof store.get !== "function" || typeof store.set !== "function") throw new ContinuityError("CLIENT_INVALID", "Store must expose get(key,fallback) and set(key,value)");
299
+ this.keychain = keychain;
300
+ this.uuaid = identity.uuaid;
301
+ this.publicKey = identity.publicKeyHex;
302
+ this.store = store;
303
+ this.slot = validateSlot(slot);
304
+ /** @type {ContinuityLimits} */
305
+ this.limits = Object.freeze({ ...DEFAULT_LIMITS, ...limits });
306
+ for (const [k, v] of Object.entries(this.limits)) if (!Number.isSafeInteger(v) || v <= 0) throw new ContinuityError("BUDGET_EXHAUSTED", `Limit ${k} must be a positive integer`);
307
+ this.#client = client;
308
+ this.#vaultKey = vaultKey;
309
+ this.#now = now;
310
+ this.#state = this.#load();
311
+ this.#loadedHead = this.#head(); this.#loadedSeq = this.#state.nextSeq;
312
+ }
313
+
314
+ /** Store key under which this identity's ledger lives. Scoped by UUAID so two agents sharing one store never collide. */
315
+ static storeKey(uuaid) { return `continuity:${uuaid}`; }
316
+
317
+ /**
318
+ * Explicitly move a corrupt or foreign ledger blob aside so a fresh ledger can be
319
+ * opened. This is an operator decision, never automatic: the constructor fails
320
+ * closed instead of calling this. Returns the quarantine key or null if nothing was stored.
321
+ * @param {ContinuityStore} store
322
+ * @param {string} uuaid
323
+ * @returns {string|null}
324
+ */
325
+ static quarantine(store, uuaid) {
326
+ const key = ContinuityMemory.storeKey(uuaid);
327
+ const blob = store.get(key, null);
328
+ if (blob === null) return null;
329
+ const quarantineKey = `${key}:quarantine:${new Date().toISOString()}`;
330
+ runAtomically(store, () => { store.set(quarantineKey, blob); store.set(key, null); });
331
+ return quarantineKey;
332
+ }
333
+
334
+ #owner() { return { uuaid: this.uuaid, publicKey: this.publicKey }; }
335
+
336
+ /** @returns {ContinuityState} */
337
+ #load() {
338
+ const stored = this.store.get(ContinuityMemory.storeKey(this.uuaid), null);
339
+ if (stored === null || stored === undefined) return { v: CONTINUITY_PROTOCOL, uuaid: this.uuaid, publicKey: this.publicKey, nextSeq: 0, anchor: { previous: "genesis", pruned: 0, through: null }, entries: [], sync: { lastPush: null, lastPull: null } };
340
+ const state = verifyContinuity(stored, this.#owner(), this.#envelope(false)); // age tolerated locally: expired entries are pruned on the next write/compact(), never shipped
341
+ state.sync ??= { lastPush: null, lastPull: null };
342
+ return state;
343
+ }
344
+
345
+ #persist() { this.store.set(ContinuityMemory.storeKey(this.uuaid), this.#state); }
346
+
347
+ /**
348
+ * Optimistic concurrency guard. Multiple ContinuityMemory instances (or processes)
349
+ * may share one store for the same identity; each instance caches the state it
350
+ * loaded. Before any write we re-read the stored blob and require its head and
351
+ * sequence counter to equal the ones this instance last loaded or committed. A
352
+ * mismatch means another writer advanced the ledger: we throw `STALE_WRITER` and
353
+ * change nothing. The caller must `reload()` and re-apply its intent explicitly.
354
+ */
355
+ #assertFresh() {
356
+ const stored = this.store.get(ContinuityMemory.storeKey(this.uuaid), null);
357
+ const storedHead = stored == null ? "genesis" : (stored.entries?.length ? stored.entries.at(-1)?.hash : stored.anchor?.previous);
358
+ const storedSeq = stored == null ? 0 : stored.nextSeq;
359
+ if (storedHead !== this.#loadedHead || storedSeq !== this.#loadedSeq) throw new ContinuityError("STALE_WRITER", "Another instance advanced this identity's ledger; reload() before writing", { expectedHead: this.#loadedHead, storedHead, expectedSeq: this.#loadedSeq, storedSeq });
360
+ }
361
+
362
+ /** Run a state mutation atomically with the stale-writer guard; rolls back in-memory state on any failure. */
363
+ #commit(mutate) {
364
+ const before = structuredClone(this.#state);
365
+ const loaded = [this.#loadedHead, this.#loadedSeq];
366
+ try {
367
+ return runAtomically(this.store, () => {
368
+ this.#assertFresh();
369
+ const result = mutate();
370
+ this.#persist();
371
+ this.#loadedHead = this.#head(); this.#loadedSeq = this.#state.nextSeq;
372
+ return result;
373
+ });
374
+ } catch (e) { this.#state = before; [this.#loadedHead, this.#loadedSeq] = loaded; throw e; }
375
+ }
376
+
377
+ /**
378
+ * Explicitly discard this instance's cached state and re-read (and re-verify) the
379
+ * stored ledger. Required after `STALE_WRITER`. Never merges: whatever the store
380
+ * holds becomes this instance's view, and any un-committed intent is the caller's
381
+ * to re-apply.
382
+ * @returns {{head:string,entries:number}}
383
+ */
384
+ reload() {
385
+ this.#state = this.#load();
386
+ this.#loadedHead = this.#head(); this.#loadedSeq = this.#state.nextSeq;
387
+ return { head: this.#loadedHead, entries: this.#state.entries.length };
388
+ }
389
+
390
+ #head(state = this.#state) { return state.entries.length ? state.entries.at(-1).hash : state.anchor.previous; }
391
+
392
+ #bytes(entries = this.#state.entries) { return entries.reduce((n, e) => n + bytesOf(e), 0); }
393
+
394
+ /**
395
+ * Apply retention and budgets, evicting oldest unpinned entries first. Each
396
+ * eviction advances the anchor so the remaining chain still verifies.
397
+ * @param {number} incomingBytes
398
+ * @param {number} [incomingCount]
399
+ */
400
+ #prune(incomingBytes, incomingCount = 1) {
401
+ const { entries, anchor } = this.#state;
402
+ const cutoff = this.#now() - this.limits.retentionMs;
403
+ const evict = (index) => {
404
+ const [gone] = entries.splice(index, 1);
405
+ anchor.previous = gone.hash; anchor.pruned += 1; anchor.through = gone.at;
406
+ if (entries.length && entries[0].previous !== gone.hash) throw new ContinuityError("INTEGRITY", "Eviction would break the continuity chain");
407
+ };
408
+ while (entries.length && !entries[0].pin && Date.parse(entries[0].at) < cutoff) evict(0);
409
+ while (entries.length + incomingCount > this.limits.maxEntries || this.#bytes() + incomingBytes > this.limits.maxBytes) {
410
+ const index = entries.findIndex(e => !e.pin);
411
+ if (index !== 0) throw new ContinuityError("BUDGET_EXHAUSTED", index === -1 ? "Continuity budget exhausted by pinned entries" : "Cannot evict past a pinned entry without breaking the chain", { limits: this.limits });
412
+ evict(index);
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Append a memory. Fails closed on invalid kind/provenance, oversize entries, or an
418
+ * exhausted budget; on success the entry is signed, chained, and persisted.
419
+ *
420
+ * @param {string} kind
421
+ * @param {unknown} content Canonical-JSON-safe value.
422
+ * @param {Partial<Provenance> & {pin?:boolean}} [provenance]
423
+ * @returns {ContinuityEntry}
424
+ */
425
+ remember(kind, content, provenance = {}) {
426
+ if (typeof kind !== "string" || !KIND_RE.test(kind)) throw new ContinuityError("ENTRY_INVALID", "Kind must be lower-case [a-z0-9:_-], ≤64 chars");
427
+ const source = provenance.source ?? "self";
428
+ const actor = provenance.actor ?? this.uuaid;
429
+ if (!PROVENANCE_SOURCES.includes(source)) throw new ContinuityError("ENTRY_INVALID", "Unknown provenance source", { source });
430
+ if (!UUAID_RE.test(actor)) throw new ContinuityError("ENTRY_INVALID", "Provenance actor must be an agent UUAID");
431
+ if (source === "self" && actor !== this.uuaid) throw new ContinuityError("ENTRY_INVALID", "Self-provenance must name the owner as actor");
432
+ if (source === "peer" && actor === this.uuaid) throw new ContinuityError("ENTRY_INVALID", "Peer-provenance cannot name the owner as actor");
433
+ if (provenance.evidence != null && !/^[0-9a-f]{64}$/.test(provenance.evidence)) throw new ContinuityError("ENTRY_INVALID", "Evidence must be a sha256 hex digest");
434
+ const body = {
435
+ v: CONTINUITY_PROTOCOL, seq: this.#state.nextSeq, id: randomUUID(), at: isoNow(this.#now), kind, content,
436
+ provenance: { source, actor, origin: provenance.origin ?? null, evidence: provenance.evidence ?? null },
437
+ pin: provenance.pin === true, previous: this.#head(),
438
+ };
439
+ try { assertJSON(body); } catch (e) { throw new ContinuityError("ENTRY_INVALID", e.message); }
440
+ const hash = digest(body);
441
+ const entry = { ...body, hash, signature: this.keychain.sign(Buffer.from(hash, "hex")).toString("hex") };
442
+ const size = bytesOf(entry);
443
+ if (size > this.limits.maxEntryBytes) throw new ContinuityError("ENTRY_TOO_LARGE", "Entry exceeds maxEntryBytes", { size, limit: this.limits.maxEntryBytes });
444
+ this.#commit(() => {
445
+ this.#prune(size);
446
+ this.#state.entries.push(entry);
447
+ this.#state.nextSeq += 1;
448
+ });
449
+ return structuredClone(entry);
450
+ }
451
+
452
+ /**
453
+ * Explicitly apply retention/budget pruning without appending. Use before `push()`
454
+ * when `status().verified` is false solely because unpinned entries aged past
455
+ * `retentionMs` while this instance was alive. Returns the number of entries evicted.
456
+ * @returns {number}
457
+ */
458
+ compact() {
459
+ const before = this.#state.entries.length;
460
+ this.#commit(() => this.#prune(0, 0));
461
+ return before - this.#state.entries.length;
462
+ }
463
+
464
+ /**
465
+ * Read retained entries, oldest first.
466
+ * @param {{kind?:string,since?:string,source?:ProvenanceSource,limit?:number}} [filter]
467
+ * @returns {ContinuityEntry[]}
468
+ */
469
+ recall({ kind, since, source, limit = this.limits.maxEntries } = {}) {
470
+ let out = this.#state.entries;
471
+ if (kind) out = out.filter(e => e.kind === kind);
472
+ if (source) out = out.filter(e => e.provenance.source === source);
473
+ if (since) out = out.filter(e => e.at > since);
474
+ return structuredClone(out.slice(-limit));
475
+ }
476
+
477
+ /** Re-verify the whole local ledger. Returns false (never throws) so callers can gate behavior. */
478
+ verify() { try { verifyContinuity(this.#state, this.#owner(), this.#envelope(false)); return true; } catch { return false; } }
479
+
480
+ #envelope(age = true) { return { limits: this.limits, now: this.#now, age }; }
481
+
482
+ /** Number of retained unpinned entries older than retentionMs (would be evicted by the next write or compact()). */
483
+ expired() { const cutoff = this.#now() - this.limits.retentionMs; return this.#state.entries.filter(e => !e.pin && Date.parse(e.at) < cutoff).length; }
484
+
485
+ /** Current head hash (last entry hash, or anchor hash when no entries are retained). */
486
+ head() { return this.#head(); }
487
+
488
+ /**
489
+ * Non-secret status view. Never includes the vault key or client internals.
490
+ * @returns {{uuaid:string,entries:number,bytes:number,pruned:number,head:string,anchor:ContinuityAnchor,limits:ContinuityLimits,verified:boolean,expired:number,durable:boolean,remote:{configured:boolean,slot:string,lastPush:SyncReceipt|null,lastPull:SyncReceipt|null}}}
491
+ */
492
+ status() {
493
+ return structuredClone({
494
+ uuaid: this.uuaid, entries: this.#state.entries.length, bytes: this.#bytes(), pruned: this.#state.anchor.pruned, head: this.#head(), anchor: this.#state.anchor,
495
+ limits: this.limits, verified: this.verify(), expired: this.expired(), durable: !(this.store instanceof MemoryContinuityStore),
496
+ remote: { configured: this.#client !== null && this.#vaultKey !== null, slot: this.slot, lastPush: this.#state.sync.lastPush, lastPull: this.#state.sync.lastPull },
497
+ });
498
+ }
499
+
500
+ /**
501
+ * Export a verifiable snapshot (what `push()` encrypts). Contains no secrets and no sync receipts.
502
+ * @returns {ContinuitySnapshot}
503
+ */
504
+ snapshot() {
505
+ const { v, uuaid, publicKey, nextSeq, anchor, entries } = this.#state;
506
+ return structuredClone({ v, uuaid, publicKey, nextSeq, anchor, entries, head: this.#head(), exportedAt: isoNow(this.#now) });
507
+ }
508
+
509
+ #requireRemote() {
510
+ if (this.#client === null || this.#vaultKey === null) throw new ContinuityError("REMOTE_NOT_CONFIGURED", "Remote continuity requires an injected official client and a vault key");
511
+ verifyContinuity(this.#state, this.#owner(), this.#envelope(true)); // expired entries must be compact()ed before any remote operation
512
+ this.#assertFresh(); // never ship or reconcile a cached view another instance has already advanced
513
+ }
514
+
515
+ /**
516
+ * Encrypt (inside the official SDK) and upload the current snapshot to this
517
+ * agent's vault slot. Explicit; never called automatically.
518
+ * @returns {Promise<SyncReceipt>}
519
+ */
520
+ async push() {
521
+ this.#requireRemote();
522
+ const text = jcs(this.snapshot());
523
+ const sizeBytes = Buffer.byteLength(text, "utf-8");
524
+ if (sizeBytes > this.limits.maxBytes * 2) throw new ContinuityError("BUDGET_EXHAUSTED", "Snapshot exceeds remote byte budget", { sizeBytes });
525
+ let result;
526
+ try { result = await this.#client.saveMemory(this.uuaid, this.slot, text, this.#vaultKey); }
527
+ catch (e) { throw new ContinuityError("REMOTE_UNAVAILABLE", `Remote save failed: ${e?.message ?? e}`, { status: e?.status ?? null }); }
528
+ if (!result || result.agent !== this.uuaid || result.key !== this.slot) throw new ContinuityError("REMOTE_MISMATCH", "Registry acknowledged a different agent or slot", { result: result ?? null });
529
+ /** @type {SyncReceipt} */
530
+ const receipt = { at: isoNow(this.#now), head: this.#head(), contentHash: typeof result.content_hash === "string" ? result.content_hash : null, sizeBytes, replaced: typeof result.replaced === "boolean" ? result.replaced : null, relation: "pushed" };
531
+ // Re-checked after the await: if a sibling wrote meanwhile, the remote now holds a
532
+ // stale snapshot relative to the store. Surface that loudly instead of recording success.
533
+ this.#commit(() => { this.#state.sync.lastPush = receipt; });
534
+ return structuredClone(receipt);
535
+ }
536
+
537
+ /**
538
+ * Download and decrypt (inside the official SDK) the remote snapshot and reconcile
539
+ * it with local history. Outcomes:
540
+ * - `remote-empty`: slot does not exist yet (HTTP 404); local unchanged.
541
+ * - `in-sync`: identical heads; local unchanged.
542
+ * - `fast-forwarded`: local head is an ancestor of the remote head; remote adopted.
543
+ * - `local-ahead`: remote head is an ancestor of local head; local unchanged.
544
+ * Any other relationship throws `DIVERGED` and changes nothing.
545
+ * @returns {Promise<SyncReceipt>}
546
+ */
547
+ async pull() {
548
+ this.#requireRemote();
549
+ let text;
550
+ try { text = await this.#client.loadMemory(this.uuaid, this.slot, this.#vaultKey); }
551
+ catch (e) {
552
+ if (e?.status === 404) return this.#receipt("remote-empty", 0);
553
+ throw new ContinuityError("REMOTE_UNAVAILABLE", `Remote load failed: ${e?.message ?? e}`, { status: e?.status ?? null });
554
+ }
555
+ if (typeof text !== "string") throw new ContinuityError("REMOTE_MISMATCH", "Official client must return decrypted text");
556
+ let remote;
557
+ try { remote = JSON.parse(text); } catch { throw new ContinuityError("INTEGRITY", "Remote snapshot is not JSON"); }
558
+ verifyContinuity(remote, this.#owner(), this.#envelope()); // chain, signatures, identity AND this instance's limits; over-limit remotes are refused whole
559
+ const sizeBytes = Buffer.byteLength(text, "utf-8");
560
+ const localHead = this.#head(), remoteHead = remote.head;
561
+ const known = (state, hash) => hash === state.anchor.previous || state.entries.some(e => e.hash === hash);
562
+ if (remoteHead === localHead) return this.#receipt("in-sync", sizeBytes);
563
+ if (remoteHead === "genesis" && remote.entries.length === 0) return this.#receipt("remote-empty", sizeBytes);
564
+ if (known(remote, localHead)) {
565
+ if (remote.nextSeq < this.#state.nextSeq) throw new ContinuityError("DIVERGED", "Remote claims to be ahead but has a smaller sequence counter");
566
+ return this.#commit(() => {
567
+ const sync = this.#state.sync;
568
+ this.#state = { v: CONTINUITY_PROTOCOL, uuaid: this.uuaid, publicKey: this.publicKey, nextSeq: remote.nextSeq, anchor: structuredClone(remote.anchor), entries: structuredClone(remote.entries), sync };
569
+ const receipt = { at: isoNow(this.#now), head: this.#head(), contentHash: null, sizeBytes, replaced: null, relation: "fast-forwarded" };
570
+ this.#state.sync.lastPull = receipt;
571
+ return structuredClone(receipt);
572
+ });
573
+ }
574
+ if (known(this.#state, remoteHead)) return this.#receipt("local-ahead", sizeBytes);
575
+ throw new ContinuityError("DIVERGED", "Local and remote continuity histories have diverged; refusing to merge", { localHead, remoteHead });
576
+ }
577
+
578
+ #receipt(relation, sizeBytes) {
579
+ /** @type {SyncReceipt} */
580
+ const receipt = { at: isoNow(this.#now), head: this.#head(), contentHash: null, sizeBytes, replaced: null, relation };
581
+ this.#commit(() => { this.#state.sync.lastPull = receipt; });
582
+ return structuredClone(receipt);
583
+ }
584
+
585
+ /**
586
+ * Pull, then push only when local history is ahead of (or the sole source for) the remote.
587
+ * @returns {Promise<{pull:SyncReceipt,push:SyncReceipt|null}>}
588
+ */
589
+ async sync() {
590
+ const pull = await this.pull();
591
+ const push = ["local-ahead", "remote-empty"].includes(pull.relation) ? await this.push() : null;
592
+ return { pull, push };
593
+ }
594
+ }