@nullsquare/agent-authority 0.4.4 → 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/README.md +16 -5
- package/ROADMAP.md +30 -11
- package/docs/durable-task-leases.md +336 -0
- package/docs/npm-release.md +7 -5
- package/docs/transport-invariance.md +46 -10
- package/package.json +3 -2
- package/src/durable-task-lease.js +185 -0
- package/src/storage.js +298 -9
- package/src/task-lease.js +243 -1
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
|
+
}
|