@nullsquare/agent-authority 0.4.3 → 0.4.5

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/src/storage.js CHANGED
@@ -1,7 +1,24 @@
1
- import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
1
+ import {
2
+ createCipheriv,
3
+ createDecipheriv,
4
+ createHmac,
5
+ randomBytes,
6
+ randomUUID,
7
+ timingSafeEqual
8
+ } from 'node:crypto';
9
+ import {
10
+ chmodSync,
11
+ existsSync,
12
+ mkdirSync,
13
+ readFileSync,
14
+ renameSync,
15
+ rmSync,
16
+ unlinkSync,
17
+ writeFileSync
18
+ } from 'node:fs';
3
19
  import { homedir } from 'node:os';
4
20
  import { dirname, join, resolve } from 'node:path';
21
+ import { TaskLease } from './task-lease.js';
5
22
 
6
23
  export function authorityHome(env = process.env) {
7
24
  return resolve(env.AGENT_AUTHORITY_HOME || join(homedir(), '.agent-authority'));
@@ -26,6 +43,68 @@ function atomicJson(path, value, mode = 0o600) {
26
43
  try { chmodSync(path, mode); } catch {}
27
44
  }
28
45
 
46
+ function loadOrCreateMasterKey(keyPath) {
47
+ ensureDir(dirname(keyPath));
48
+ if (!existsSync(keyPath)) {
49
+ writeFileSync(keyPath, randomBytes(32), { mode: 0o600 });
50
+ try { chmodSync(keyPath, 0o600); } catch {}
51
+ }
52
+ const key = readFileSync(keyPath);
53
+ if (key.length !== 32) throw new Error('Agent Authority master key is invalid');
54
+ return key;
55
+ }
56
+
57
+ function deriveLocalKey(keyPath, purpose) {
58
+ return createHmac('sha256', loadOrCreateMasterKey(keyPath))
59
+ .update(`agent-authority/${purpose}/v1`)
60
+ .digest();
61
+ }
62
+
63
+ function safeLeaseFileName(leaseId) {
64
+ if (typeof leaseId !== 'string' || leaseId.trim() === '') throw new Error('lease_id is required');
65
+ return `${Buffer.from(leaseId, 'utf8').toString('base64url')}.json`;
66
+ }
67
+
68
+ function taskLeaseEnvelopePayload(envelope) {
69
+ return JSON.stringify({
70
+ version: envelope.version,
71
+ lease_id: envelope.lease_id,
72
+ mission_id: envelope.mission_id,
73
+ lease_hash: envelope.lease_hash,
74
+ snapshot: envelope.snapshot
75
+ });
76
+ }
77
+
78
+ function taskLeaseMac(key, envelope) {
79
+ return createHmac('sha256', key).update(taskLeaseEnvelopePayload(envelope)).digest('hex');
80
+ }
81
+
82
+ function authenticationError(message) {
83
+ const error = new Error(message);
84
+ error.code = 'task_lease_state_authentication_failed';
85
+ return error;
86
+ }
87
+
88
+ function stateConflictError(message, details = {}) {
89
+ const error = new Error(message);
90
+ error.code = 'task_lease_state_conflict';
91
+ Object.assign(error, details);
92
+ return error;
93
+ }
94
+
95
+ function stateLockedError(leaseId) {
96
+ const error = new Error(`task lease ${leaseId} is already being updated by another local worker`);
97
+ error.code = 'task_lease_state_locked';
98
+ error.lease_id = leaseId;
99
+ return error;
100
+ }
101
+
102
+ function transactionError(code, message) {
103
+ const error = new Error(message);
104
+ error.code = code;
105
+ return error;
106
+ }
107
+
29
108
  export function defaultConfig(home = authorityHome()) {
30
109
  return {
31
110
  version: 1,
@@ -37,6 +116,7 @@ export function defaultConfig(home = authorityHome()) {
37
116
  master_key: join(home, 'vault', 'master.key'),
38
117
  revocations: join(home, 'state', 'revocations.json'),
39
118
  usage: join(home, 'state', 'usage.json'),
119
+ task_leases: join(home, 'state', 'task-leases'),
40
120
  receipts: join(home, 'receipts')
41
121
  }
42
122
  };
@@ -136,13 +216,7 @@ export class EncryptedFileSecretStore {
136
216
  }
137
217
 
138
218
  key() {
139
- if (!existsSync(this.keyPath)) {
140
- writeFileSync(this.keyPath, randomBytes(32), { mode: 0o600 });
141
- try { chmodSync(this.keyPath, 0o600); } catch {}
142
- }
143
- const key = readFileSync(this.keyPath);
144
- if (key.length !== 32) throw new Error('Agent Authority master key is invalid');
145
- return key;
219
+ return loadOrCreateMasterKey(this.keyPath);
146
220
  }
147
221
 
148
222
  all() { return readJson(this.path, {}); }
@@ -179,6 +253,221 @@ export class EncryptedFileSecretStore {
179
253
  }
180
254
  }
181
255
 
256
+ /**
257
+ * Local authenticated persistence for Task Lease authority state.
258
+ *
259
+ * The entire snapshot is written atomically and authenticated with an HMAC key
260
+ * derived from the Agent Authority local master key. Loading verifies the MAC,
261
+ * exact lease/mission identity, snapshot hash and TaskLease lineage validation
262
+ * before reconstructed authority is returned to the caller.
263
+ *
264
+ * Durable mutations should go through transact(). The per-lease lock serializes
265
+ * local read-modify-write transactions, while expected_lease_hash provides an
266
+ * optimistic compare-and-swap check for workers operating on recovered views.
267
+ *
268
+ * This protects against accidental/caller-controlled state-file modification and
269
+ * stale local writers on the trusted host. It is not designed to contain a
270
+ * malicious host or an attacker that can read the local master key.
271
+ */
272
+ export class JsonFileTaskLeaseStore {
273
+ constructor({ dir, keyPath }) {
274
+ if (!dir) throw new Error('task lease store dir is required');
275
+ if (!keyPath) throw new Error('task lease store keyPath is required');
276
+ this.dir = dir;
277
+ this.keyPath = keyPath;
278
+ ensureDir(dir);
279
+ ensureDir(dirname(keyPath));
280
+ }
281
+
282
+ key() {
283
+ return deriveLocalKey(this.keyPath, 'task-lease-state');
284
+ }
285
+
286
+ path(leaseId) {
287
+ return join(this.dir, safeLeaseFileName(leaseId));
288
+ }
289
+
290
+ lockPath(leaseId) {
291
+ return `${this.path(leaseId)}.lock`;
292
+ }
293
+
294
+ withLeaseLock(leaseId, fn) {
295
+ const lockPath = this.lockPath(leaseId);
296
+ try {
297
+ mkdirSync(lockPath, { mode: 0o700 });
298
+ } catch (error) {
299
+ if (error?.code === 'EEXIST') throw stateLockedError(leaseId);
300
+ throw error;
301
+ }
302
+
303
+ try {
304
+ return fn();
305
+ } finally {
306
+ rmSync(lockPath, { recursive: true, force: true });
307
+ }
308
+ }
309
+
310
+ envelopeFor(lease) {
311
+ const snapshot = lease.snapshot();
312
+ const envelope = {
313
+ version: 1,
314
+ lease_id: lease.lease_id,
315
+ mission_id: lease.mission.mission_id,
316
+ lease_hash: lease.hash(),
317
+ snapshot
318
+ };
319
+ envelope.mac = taskLeaseMac(this.key(), envelope);
320
+ return envelope;
321
+ }
322
+
323
+ writeLease(lease, path = this.path(lease.lease_id)) {
324
+ const envelope = this.envelopeFor(lease);
325
+ atomicJson(path, envelope, 0o600);
326
+ return {
327
+ lease_id: envelope.lease_id,
328
+ mission_id: envelope.mission_id,
329
+ lease_hash: envelope.lease_hash
330
+ };
331
+ }
332
+
333
+ save(lease, { expected_lease_hash = null } = {}) {
334
+ if (!(lease instanceof TaskLease)) throw new Error('TaskLease instance is required');
335
+ return this.withLeaseLock(lease.lease_id, () => {
336
+ const path = this.path(lease.lease_id);
337
+ if (existsSync(path)) {
338
+ const current = this.load({ mission: lease.mission, lease_id: lease.lease_id });
339
+ const currentHash = current.hash();
340
+ const nextHash = lease.hash();
341
+
342
+ if (expected_lease_hash !== null && currentHash !== expected_lease_hash) {
343
+ throw stateConflictError('task lease state changed since the caller recovered it', {
344
+ lease_id: lease.lease_id,
345
+ expected_lease_hash,
346
+ current_lease_hash: currentHash
347
+ });
348
+ }
349
+ if (expected_lease_hash === null && currentHash !== nextHash) {
350
+ throw stateConflictError(
351
+ 'changed durable Task Lease state requires expected_lease_hash or transact()',
352
+ { lease_id: lease.lease_id, current_lease_hash: currentHash, attempted_lease_hash: nextHash }
353
+ );
354
+ }
355
+ if (currentHash === nextHash) {
356
+ return {
357
+ lease_id: lease.lease_id,
358
+ mission_id: lease.mission.mission_id,
359
+ lease_hash: currentHash
360
+ };
361
+ }
362
+ } else if (expected_lease_hash !== null) {
363
+ throw stateConflictError('task lease state does not exist for the supplied expected hash', {
364
+ lease_id: lease.lease_id,
365
+ expected_lease_hash,
366
+ current_lease_hash: null
367
+ });
368
+ }
369
+
370
+ const validated = TaskLease.restore({ mission: lease.mission, snapshot: lease.snapshot() });
371
+ return this.writeLease(validated, path);
372
+ });
373
+ }
374
+
375
+ load({ mission, lease_id } = {}) {
376
+ if (!mission) throw new Error('mission is required');
377
+ if (!lease_id) throw new Error('lease_id is required');
378
+ const path = this.path(lease_id);
379
+ if (!existsSync(path)) return null;
380
+
381
+ const envelope = readJson(path, null);
382
+ if (!envelope || envelope.version !== 1) {
383
+ throw authenticationError('task lease state envelope is invalid or unsupported');
384
+ }
385
+ if (envelope.lease_id !== lease_id || envelope.mission_id !== mission.mission_id) {
386
+ throw authenticationError('task lease state identity does not match the requested lease and mission');
387
+ }
388
+ if (typeof envelope.mac !== 'string' || !/^[a-f0-9]{64}$/.test(envelope.mac)) {
389
+ throw authenticationError('task lease state authentication tag is invalid');
390
+ }
391
+
392
+ const expected = Buffer.from(taskLeaseMac(this.key(), envelope), 'hex');
393
+ const actual = Buffer.from(envelope.mac, 'hex');
394
+ if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
395
+ throw authenticationError('task lease state authentication failed');
396
+ }
397
+
398
+ let lease;
399
+ try {
400
+ // Recover against an isolated mission snapshot so a transaction callback
401
+ // cannot mutate the caller's authority ceiling through shared references.
402
+ lease = TaskLease.restore({ mission: structuredClone(mission), snapshot: envelope.snapshot });
403
+ } catch (error) {
404
+ if (!error.code) error.code = 'task_lease_snapshot_invalid';
405
+ throw error;
406
+ }
407
+ if (lease.lease_id !== lease_id || lease.hash() !== envelope.lease_hash) {
408
+ throw authenticationError('task lease state hash does not match recovered authority');
409
+ }
410
+ return lease;
411
+ }
412
+
413
+ /**
414
+ * Apply one synchronous durable mutation to the authenticated current lease.
415
+ *
416
+ * The mutation runs against a freshly recovered lease while holding an
417
+ * exclusive local per-lease lock. If expected_lease_hash is supplied, a stale
418
+ * worker fails before its mutation is applied. The resulting snapshot is fully
419
+ * validated and atomically replaced before the updated lease is returned.
420
+ */
421
+ transact({ mission, lease_id, expected_lease_hash = null, mutate } = {}) {
422
+ if (!mission) throw new Error('mission is required');
423
+ if (!lease_id) throw new Error('lease_id is required');
424
+ if (typeof mutate !== 'function') throw new Error('transaction mutate function is required');
425
+
426
+ return this.withLeaseLock(lease_id, () => {
427
+ const current = this.load({ mission, lease_id });
428
+ if (!current) {
429
+ throw transactionError('task_lease_state_missing', `task lease ${lease_id} has no durable state`);
430
+ }
431
+
432
+ const previousHash = current.hash();
433
+ if (expected_lease_hash !== null && previousHash !== expected_lease_hash) {
434
+ throw stateConflictError('task lease state changed since the caller recovered it', {
435
+ lease_id,
436
+ expected_lease_hash,
437
+ current_lease_hash: previousHash
438
+ });
439
+ }
440
+
441
+ const value = mutate(current);
442
+ if (value && typeof value.then === 'function') {
443
+ throw transactionError(
444
+ 'task_lease_transaction_async_unsupported',
445
+ 'durable Task Lease transactions must be synchronous and side-effect free outside lease state'
446
+ );
447
+ }
448
+ if (current.lease_id !== lease_id || current.mission.mission_id !== mission.mission_id) {
449
+ throw transactionError('task_lease_transaction_identity_changed', 'transaction changed Task Lease identity');
450
+ }
451
+
452
+ const validated = TaskLease.restore({ mission, snapshot: current.snapshot() });
453
+ const saved = this.writeLease(validated, this.path(lease_id));
454
+ return {
455
+ lease: validated,
456
+ value,
457
+ previous_lease_hash: previousHash,
458
+ lease_hash: saved.lease_hash
459
+ };
460
+ });
461
+ }
462
+
463
+ delete(leaseId) {
464
+ const path = this.path(leaseId);
465
+ if (!existsSync(path)) return false;
466
+ unlinkSync(path);
467
+ return true;
468
+ }
469
+ }
470
+
182
471
  export class JsonFileRevocationStore {
183
472
  constructor(path) { this.path = path; ensureDir(dirname(path)); }
184
473
  all() { return readJson(this.path, {}); }
package/src/task-lease.js CHANGED
@@ -24,6 +24,19 @@ function sameValue(a, b) {
24
24
  return hashObject(a) === hashObject(b);
25
25
  }
26
26
 
27
+ function requiredString(value, code, message) {
28
+ if (typeof value !== 'string' || value.trim() === '') throw authorityError(code, message);
29
+ return value;
30
+ }
31
+
32
+ function validDate(value, code, message, { nullable = false } = {}) {
33
+ if ((value === null || value === undefined) && nullable) return value ?? null;
34
+ if (typeof value !== 'string' || Number.isNaN(new Date(value).getTime())) {
35
+ throw authorityError(code, message);
36
+ }
37
+ return value;
38
+ }
39
+
27
40
  function validateBinding(binding) {
28
41
  if (!binding?.service) throw new Error('binding.service is required');
29
42
  if (!binding?.action) throw new Error('binding.action is required');
@@ -37,6 +50,201 @@ function validateBinding(binding) {
37
50
  };
38
51
  }
39
52
 
53
+ function validateSnapshotFact(fact, leaseId) {
54
+ if (!fact || typeof fact !== 'object' || Array.isArray(fact)) {
55
+ throw authorityError('task_lease_snapshot_invalid', 'task lease fact must be an object');
56
+ }
57
+ const factId = requiredString(
58
+ fact.fact_id,
59
+ 'task_lease_snapshot_invalid',
60
+ 'task lease fact_id is required'
61
+ );
62
+ if (fact.value === undefined) {
63
+ throw authorityError('task_lease_snapshot_invalid', `task lease fact ${factId} is missing its value`);
64
+ }
65
+ requiredString(
66
+ fact.created_at,
67
+ 'task_lease_snapshot_invalid',
68
+ `task lease fact ${factId} is missing created_at`
69
+ );
70
+ validDate(
71
+ fact.created_at,
72
+ 'task_lease_snapshot_invalid',
73
+ `task lease fact ${factId} created_at is invalid`
74
+ );
75
+
76
+ const provenance = fact.provenance;
77
+ if (!provenance || typeof provenance !== 'object' || Array.isArray(provenance)) {
78
+ throw authorityError('task_lease_snapshot_invalid', `task lease fact ${factId} is missing provenance`);
79
+ }
80
+
81
+ if (provenance.type === 'root') {
82
+ requiredString(
83
+ provenance.source,
84
+ 'task_lease_snapshot_invalid',
85
+ `root fact ${factId} is missing its authority source`
86
+ );
87
+ } else if (provenance.type === 'derived') {
88
+ if (!['host-trusted', 'execution-evidence-v1'].includes(provenance.derivation_mode)) {
89
+ throw authorityError('task_lease_snapshot_invalid', `derived fact ${factId} has an unsupported derivation mode`);
90
+ }
91
+ if (!Array.isArray(provenance.from) || provenance.from.length === 0) {
92
+ throw authorityError('task_lease_snapshot_invalid', `derived fact ${factId} must retain parent lineage`);
93
+ }
94
+ for (const parentId of provenance.from) {
95
+ requiredString(
96
+ parentId,
97
+ 'task_lease_snapshot_invalid',
98
+ `derived fact ${factId} contains an invalid parent fact id`
99
+ );
100
+ }
101
+ if (provenance.task_lease_id !== leaseId) {
102
+ throw authorityError('task_lease_snapshot_invalid', `derived fact ${factId} belongs to another task lease`);
103
+ }
104
+ for (const [field, label] of [
105
+ ['receipt_id', 'receipt id'],
106
+ ['receipt_hash', 'receipt hash'],
107
+ ['source_service', 'source service'],
108
+ ['source_action', 'source action'],
109
+ ['source_request_hash', 'source request hash'],
110
+ ['selector', 'selector']
111
+ ]) {
112
+ requiredString(
113
+ provenance[field],
114
+ 'task_lease_snapshot_invalid',
115
+ `derived fact ${factId} is missing ${label}`
116
+ );
117
+ }
118
+
119
+ if (provenance.derivation_mode === 'execution-evidence-v1') {
120
+ for (const [field, label] of [
121
+ ['extractor_id', 'extractor id'],
122
+ ['source_output_hash', 'source output hash'],
123
+ ['execution_evidence_hash', 'execution evidence hash']
124
+ ]) {
125
+ requiredString(
126
+ provenance[field],
127
+ 'task_lease_snapshot_invalid',
128
+ `evidence-derived fact ${factId} is missing ${label}`
129
+ );
130
+ }
131
+ }
132
+ } else {
133
+ throw authorityError('task_lease_snapshot_invalid', `task lease fact ${factId} has unknown provenance type`);
134
+ }
135
+
136
+ return structuredClone({
137
+ fact_id: factId,
138
+ kind: fact.kind ?? 'opaque',
139
+ value: fact.value,
140
+ provenance,
141
+ created_at: fact.created_at
142
+ });
143
+ }
144
+
145
+ function validateFactGraph(facts) {
146
+ const byId = new Map();
147
+ for (const fact of facts) {
148
+ if (byId.has(fact.fact_id)) {
149
+ throw authorityError('task_lease_snapshot_invalid', `duplicate authority fact ${fact.fact_id}`);
150
+ }
151
+ byId.set(fact.fact_id, fact);
152
+ }
153
+
154
+ for (const fact of facts) {
155
+ if (fact.provenance.type !== 'derived') continue;
156
+ for (const parentId of fact.provenance.from) {
157
+ if (!byId.has(parentId)) {
158
+ throw authorityError(
159
+ 'task_lease_snapshot_invalid',
160
+ `derived fact ${fact.fact_id} references missing parent ${parentId}`
161
+ );
162
+ }
163
+ }
164
+ }
165
+
166
+ const visiting = new Set();
167
+ const visited = new Set();
168
+ function visit(factId) {
169
+ if (visited.has(factId)) return;
170
+ if (visiting.has(factId)) {
171
+ throw authorityError('task_lease_snapshot_invalid', 'task lease fact lineage contains a cycle');
172
+ }
173
+ visiting.add(factId);
174
+ const fact = byId.get(factId);
175
+ if (fact?.provenance.type === 'derived') {
176
+ for (const parentId of fact.provenance.from) visit(parentId);
177
+ }
178
+ visiting.delete(factId);
179
+ visited.add(factId);
180
+ }
181
+ for (const factId of byId.keys()) visit(factId);
182
+ return byId;
183
+ }
184
+
185
+ function validateSnapshot(mission, snapshot) {
186
+ if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
187
+ throw authorityError('task_lease_snapshot_invalid', 'task lease snapshot must be an object');
188
+ }
189
+ if (snapshot.version !== '0.1') {
190
+ throw authorityError('task_lease_snapshot_version_unsupported', `unsupported task lease snapshot version ${snapshot.version}`);
191
+ }
192
+
193
+ const leaseId = requiredString(
194
+ snapshot.lease_id,
195
+ 'task_lease_snapshot_invalid',
196
+ 'task lease snapshot lease_id is required'
197
+ );
198
+ if (snapshot.mission_id !== mission.mission_id) {
199
+ throw authorityError('task_lease_snapshot_mission_mismatch', 'task lease snapshot belongs to another mission');
200
+ }
201
+ if (snapshot.principal_id !== mission.principal.id || snapshot.agent_id !== mission.agent.id) {
202
+ throw authorityError('task_lease_snapshot_mission_mismatch', 'task lease snapshot principal or agent does not match mission');
203
+ }
204
+ if (snapshot.mission_hash !== hashObject(mission)) {
205
+ throw authorityError(
206
+ 'task_lease_snapshot_mission_mismatch',
207
+ 'task lease snapshot mission hash does not match the exact recovery mission'
208
+ );
209
+ }
210
+
211
+ if (!['active', 'completed'].includes(snapshot.status)) {
212
+ throw authorityError('task_lease_snapshot_invalid', 'task lease snapshot status must be active or completed');
213
+ }
214
+ validDate(snapshot.created_at, 'task_lease_snapshot_invalid', 'task lease snapshot created_at is invalid');
215
+ validDate(snapshot.expires_at, 'task_lease_snapshot_invalid', 'task lease snapshot expires_at is invalid', { nullable: true });
216
+ validDate(snapshot.completed_at, 'task_lease_snapshot_invalid', 'task lease snapshot completed_at is invalid', { nullable: true });
217
+
218
+ if (snapshot.status === 'active' && snapshot.completed_at !== null) {
219
+ throw authorityError('task_lease_snapshot_invalid', 'active task lease snapshot cannot contain completed_at');
220
+ }
221
+ if (snapshot.status === 'completed' && snapshot.completed_at === null) {
222
+ throw authorityError('task_lease_snapshot_invalid', 'completed task lease snapshot must contain completed_at');
223
+ }
224
+ if (snapshot.completion_reason !== null && typeof snapshot.completion_reason !== 'string') {
225
+ throw authorityError('task_lease_snapshot_invalid', 'task lease completion_reason must be a string or null');
226
+ }
227
+ if (!Array.isArray(snapshot.bindings) || !Array.isArray(snapshot.facts)) {
228
+ throw authorityError('task_lease_snapshot_invalid', 'task lease snapshot bindings and facts must be arrays');
229
+ }
230
+
231
+ const bindings = snapshot.bindings.map(validateBinding);
232
+ const facts = snapshot.facts.map((fact) => validateSnapshotFact(fact, leaseId));
233
+ const byId = validateFactGraph(facts);
234
+
235
+ return {
236
+ lease_id: leaseId,
237
+ request: structuredClone(snapshot.request),
238
+ created_at: snapshot.created_at,
239
+ expires_at: snapshot.expires_at,
240
+ status: snapshot.status,
241
+ completed_at: snapshot.completed_at,
242
+ completion_reason: snapshot.completion_reason,
243
+ bindings,
244
+ facts: byId
245
+ };
246
+ }
247
+
40
248
  function taskReceipt(lease, request, result) {
41
249
  const base = createReceipt({ mission: lease.mission, request, result });
42
250
  const { receipt_hash: _oldHash, ...unsigned } = base;
@@ -82,6 +290,35 @@ export class TaskLease {
82
290
  for (const root of roots) this.addRoot(root);
83
291
  }
84
292
 
293
+ /**
294
+ * Restore an already-authenticated Task Lease snapshot.
295
+ *
296
+ * This method validates state shape, exact mission identity and lineage but
297
+ * does not authenticate where the snapshot came from. Persisted authority
298
+ * should be loaded through an authenticated store such as
299
+ * JsonFileTaskLeaseStore rather than from arbitrary caller-controlled JSON.
300
+ */
301
+ static restore({ mission, snapshot } = {}) {
302
+ const resolvedMission = assertMission(mission);
303
+ const state = validateSnapshot(resolvedMission, snapshot);
304
+ const lease = new TaskLease({
305
+ mission: resolvedMission,
306
+ lease_id: state.lease_id,
307
+ request: state.request,
308
+ roots: [],
309
+ bindings: state.bindings,
310
+ expires_at: state.expires_at,
311
+ created_at: state.created_at
312
+ });
313
+ lease.status = state.status;
314
+ lease.completed_at = state.completed_at;
315
+ lease.completion_reason = state.completion_reason;
316
+ lease.facts = new Map(
317
+ [...state.facts.entries()].map(([factId, fact]) => [factId, structuredClone(fact)])
318
+ );
319
+ return lease;
320
+ }
321
+
85
322
  addRoot({ fact_id, kind = 'opaque', value, source = 'human' } = {}) {
86
323
  if (!fact_id) throw new Error('root fact_id is required');
87
324
  if (value === undefined) throw new Error('root value is required');
@@ -245,9 +482,10 @@ export class TaskLease {
245
482
  version: '0.1',
246
483
  lease_id: this.lease_id,
247
484
  mission_id: this.mission.mission_id,
485
+ mission_hash: hashObject(this.mission),
248
486
  principal_id: this.mission.principal.id,
249
487
  agent_id: this.mission.agent.id,
250
- request: this.request,
488
+ request: structuredClone(this.request),
251
489
  status: this.status,
252
490
  created_at: this.created_at,
253
491
  expires_at: this.expires_at,
@@ -341,3 +579,7 @@ export class TaskLease {
341
579
  export function createTaskLease(options) {
342
580
  return new TaskLease(options);
343
581
  }
582
+
583
+ export function restoreTaskLease(options) {
584
+ return TaskLease.restore(options);
585
+ }