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