@agentunion/fastaun 0.5.19 → 0.5.21
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/CHANGELOG.md +176 -78
- package/_packed_docs/CHANGELOG.md +176 -78
- package/_packed_docs/INDEX.md +288 -269
- package/_packed_docs/KITE_DOCS_GUIDE.md +101 -95
- package/_packed_docs/agent.md/SCHEMA.md +7 -7
- package/_packed_docs/agent.md/working/AID/347/261/273/345/236/213-owner/345/205/263/347/263/273/344/270/216/346/215/242owner/347/274/226/346/216/222/346/226/271/346/241/210.md +530 -0
- package/_packed_docs/agent.md/working/AID/347/261/273/345/236/213-owner/345/205/263/347/263/273/345/256/214/346/225/264/350/256/276/350/256/241/344/270/216/345/256/236/346/226/275/350/256/241/345/210/222.md +685 -0
- package/_packed_docs/agent.md/working/agent.md-schema/344/270/216/347/255/276/345/217/221/351/252/214/347/255/276/346/265/201/347/250/213/346/276/204/346/270/205.md +349 -0
- package/_packed_docs/agent.md/working//346/234/254/345/234/260Docker/346/265/213/350/257/225Bug/346/270/205/345/215/225.md +40 -0
- package/_packed_docs/agent.md//350/277/234/347/250/213agent.md/347/274/223/345/255/230/344/270/216etag/351/200/217/344/274/240/346/226/271/346/241/210.md +11 -11
- package/_packed_docs/protocol/06-/346/234/215/345/212/241/345/215/217/350/256/256.md +372 -331
- package/_packed_docs/protocol/08-AUN-E2EE.md +17 -12
- package/_packed_docs/protocol/10-Group-/345/255/220/345/215/217/350/256/256.md +676 -676
- package/_packed_docs/protocol/11-Storage-/345/255/220/345/215/217/350/256/256.md +8 -12
- package/_packed_docs/protocol/Ownership/345/215/217/350/256/256/345/256/232/344/271/211.md +445 -0
- package/_packed_docs/protocol/Ownership/345/215/217/350/256/256/345/256/232/344/271/2112.0.md +161 -0
- package/_packed_docs/protocol/Ownership/345/215/217/350/256/256/345/256/232/344/271/2112.1.md +666 -0
- package/_packed_docs/protocol/Ownership/345/215/217/350/256/256/345/256/232/344/271/2113.0.md +233 -0
- package/_packed_docs/protocol/agent.md/SCHEMA.md +1 -1
- package/_packed_docs/sdk/0.5.21-SDK/345/272/224/347/224/250/351/233/206/346/210/220/346/214/207/345/215/227.md +368 -0
- package/_packed_docs/sdk/0.5.21-SDK/345/274/200/345/217/221/344/270/216/347/247/273/346/244/215/346/214/207/345/215/227.md +350 -0
- package/_packed_docs/sdk/01-/345/277/253/351/200/237/345/274/200/345/247/213.md +1 -1
- package/_packed_docs/sdk/02-WebSocket/345/215/217/350/256/256.md +5 -1
- package/_packed_docs/sdk/03-/346/240/270/345/277/203/346/246/202/345/277/265.md +64 -64
- package/_packed_docs/sdk/04-/350/277/236/346/216/245/344/270/216/350/256/244/350/257/201.md +70 -63
- package/_packed_docs/sdk/05-E2EE/345/212/240/345/257/206/351/200/232/344/277/241.md +43 -40
- package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +179 -19
- package/_packed_docs/sdk/08-/346/234/200/344/275/263/345/256/236/350/267/265.md +50 -49
- package/_packed_docs/sdk/09-group-rpc-manual.md +261 -254
- package/_packed_docs/sdk/09-message-rpc-manual.md +253 -160
- package/_packed_docs/sdk/09-storage-rpc-manual.md +18 -26
- package/_packed_docs/sdk/09-/345/207/255/350/257/201/346/234/215/345/212/241RPC/346/211/213/345/206/214.md +71 -0
- package/_packed_docs/sdk/09-/347/273/204/347/273/207/346/234/215/345/212/241RPC/346/211/213/345/206/214.md +88 -0
- package/_packed_docs/sdk/AUN_DOCS_GUIDE.md +105 -92
- package/_packed_docs/sdk/INDEX.md +197 -167
- package/_packed_docs/sdk/Ownership-SDK/345/256/236/347/216/260/346/214/207/345/215/227.md +169 -0
- package/_packed_docs/sdk/Ownership-SDK/347/274/226/346/216/222/346/214/207/345/215/227.md +349 -0
- package/_packed_docs/sdk/Ownership-SDK/347/274/226/346/216/222/346/214/207/345/215/2272.1.md +930 -0
- package/_packed_docs/sdk/Ownership-SDK/351/233/206/346/210/220/346/214/207/345/215/227.md +270 -0
- package/_packed_docs/sdk/Ownership2.1-SDK/345/272/224/347/224/250/351/233/206/346/210/220/346/214/207/345/215/227.md +363 -0
- package/_packed_docs/sdk/Ownership2.1-SDK/345/274/200/345/217/221/344/270/216/347/247/273/346/244/215/346/214/207/345/215/227.md +553 -0
- package/_packed_docs/sdk/Ownership2.1-TDD/345/210/206/351/230/266/346/256/265/345/256/236/346/226/275/350/256/241/345/210/222.md +708 -0
- package/_packed_docs/sdk/Ownership2.1/350/257/246/347/273/206/350/256/276/350/256/241.md +480 -0
- package/_packed_docs/sdk/Ownership/347/256/241/347/220/206/344/270/223/351/242/230.md +559 -0
- package/_packed_docs/sdk/ProtectedHeaders/347/224/250/346/263/225/344/270/223/351/242/230.md +299 -0
- package/_packed_docs/sdk//345/207/255/350/257/201/344/270/216/347/273/204/347/273/207/346/234/215/345/212/241SDK/345/272/224/347/224/250/351/233/206/346/210/220/346/214/207/345/215/227.md +173 -0
- package/_packed_docs/sdk//345/207/255/350/257/201/344/270/216/347/273/204/347/273/207/346/234/215/345/212/241SDK/345/274/200/345/217/221/344/270/216/347/247/273/346/244/215/346/214/207/345/215/227.md +211 -0
- package/_packed_docs//350/267/250/350/256/276/345/244/207/345/267/262/350/257/273/346/234/252/350/257/273SDK/345/256/236/347/216/260/347/247/273/346/244/215/346/214/207/345/215/227.md +331 -0
- package/_packed_docs//350/267/250/350/256/276/345/244/207/345/267/262/350/257/273/346/234/252/350/257/273SDK/351/233/206/346/210/220/346/214/207/345/215/227.md +207 -0
- package/_packed_docs//350/267/250/350/256/276/345/244/207/345/267/262/350/257/273/346/234/252/350/257/273/344/273/243/347/240/201/345/256/241/346/237/245/346/212/245/345/221/212.md +252 -0
- package/_packed_docs//350/267/250/350/256/276/345/244/207/345/267/262/350/257/273/346/234/252/350/257/273/345/256/236/346/226/275/350/256/241/345/210/222.md +156 -0
- package/_packed_docs//350/267/250/350/256/276/345/244/207/345/267/262/350/257/273/346/234/252/350/257/273/346/226/271/346/241/210.md +273 -0
- package/_packed_docs//350/267/250/350/256/276/345/244/207/345/267/262/350/257/273/346/234/252/350/257/273/350/257/246/347/273/206/350/256/276/350/256/241.md +211 -0
- package/dist/agent-md-schema.d.ts +13 -1
- package/dist/agent-md-schema.js +63 -33
- package/dist/agent-md-schema.js.map +1 -1
- package/dist/agent-md.d.ts +30 -3
- package/dist/agent-md.js +293 -152
- package/dist/agent-md.js.map +1 -1
- package/dist/aid-store.d.ts +42 -0
- package/dist/aid-store.js +224 -44
- package/dist/aid-store.js.map +1 -1
- package/dist/aid.js +18 -13
- package/dist/aid.js.map +1 -1
- package/dist/auth.js +78 -2
- package/dist/auth.js.map +1 -1
- package/dist/cert-utils.d.ts +44 -1
- package/dist/cert-utils.js +157 -56
- package/dist/cert-utils.js.map +1 -1
- package/dist/client/delivery.d.ts +68 -1
- package/dist/client/delivery.js +1196 -135
- package/dist/client/delivery.js.map +1 -1
- package/dist/client/rpc-pipeline.d.ts +5 -1
- package/dist/client/rpc-pipeline.js +240 -111
- package/dist/client/rpc-pipeline.js.map +1 -1
- package/dist/client/v2-e2ee.d.ts +12 -2
- package/dist/client/v2-e2ee.js +842 -379
- package/dist/client/v2-e2ee.js.map +1 -1
- package/dist/client.d.ts +26 -2
- package/dist/client.js +420 -75
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +4 -0
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/events.d.ts +23 -4
- package/dist/events.js +171 -15
- package/dist/events.js.map +1 -1
- package/dist/facades.d.ts +43 -1
- package/dist/facades.js +368 -12
- package/dist/facades.js.map +1 -1
- package/dist/index.d.ts +7 -4
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/keystore/aid-db.d.ts +7 -2
- package/dist/keystore/aid-db.js +194 -43
- package/dist/keystore/aid-db.js.map +1 -1
- package/dist/keystore/index.d.ts +15 -1
- package/dist/keystore/local-token-store.d.ts +10 -2
- package/dist/keystore/local-token-store.js +81 -16
- package/dist/keystore/local-token-store.js.map +1 -1
- package/dist/org-protocol.d.ts +45 -0
- package/dist/org-protocol.js +829 -22
- package/dist/org-protocol.js.map +1 -1
- package/dist/ownership-v21.d.ts +40 -9
- package/dist/ownership-v21.js +174 -79
- package/dist/ownership-v21.js.map +1 -1
- package/dist/ownership-workflow.d.ts +22 -0
- package/dist/ownership-workflow.js +861 -66
- package/dist/ownership-workflow.js.map +1 -1
- package/dist/secret-store/file-store.js +84 -0
- package/dist/secret-store/file-store.js.map +1 -1
- package/dist/seq-tracker.d.ts +1 -0
- package/dist/seq-tracker.js +2 -2
- package/dist/seq-tracker.js.map +1 -1
- package/dist/tools/cross-sdk-agent.d.ts +67 -3
- package/dist/tools/cross-sdk-agent.js +575 -42
- package/dist/tools/cross-sdk-agent.js.map +1 -1
- package/dist/transport.d.ts +4 -0
- package/dist/transport.js +108 -44
- package/dist/transport.js.map +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/v2/e2ee/encrypt-group.js +1 -0
- package/dist/v2/e2ee/encrypt-group.js.map +1 -1
- package/dist/v2/e2ee/encrypt-p2p.js +1 -0
- package/dist/v2/e2ee/encrypt-p2p.js.map +1 -1
- package/dist/v2/e2ee/types.d.ts +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -3,6 +3,13 @@ import { isScalar, parseDocument } from 'yaml';
|
|
|
3
3
|
import { StateError, ValidationError } from './errors.js';
|
|
4
4
|
import { OWNERSHIP_UUID_V4_RE, ownershipAid, ownershipFailure, ownershipInteger, ownershipSha256, parseOwnershipFile, requireOwnershipApplicable } from './ownership-v21.js';
|
|
5
5
|
export const OWNERSHIP_SDK_MESSAGE_TYPE = 'aun.ownership.sdk.v1';
|
|
6
|
+
class OwnershipTaskDecodeError extends StateError {
|
|
7
|
+
operationId;
|
|
8
|
+
constructor(operationId, reason) {
|
|
9
|
+
super(reason, { stringCode: 'persistence_unavailable' });
|
|
10
|
+
this.operationId = operationId;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
6
13
|
function participants(task) {
|
|
7
14
|
return [...new Set([task.owner_aid, task.sub_aid, ...(task.new_owner_aid ? [task.new_owner_aid] : [])])];
|
|
8
15
|
}
|
|
@@ -36,15 +43,16 @@ export function parseOwnershipMessage(content, context) {
|
|
|
36
43
|
throw new ValidationError('ownership message contains unknown or missing fields');
|
|
37
44
|
if (message.type !== OWNERSHIP_SDK_MESSAGE_TYPE)
|
|
38
45
|
throw ownershipFailure('unsupported_protocol', 'legacy ownership control is unsupported');
|
|
39
|
-
if (!OWNERSHIP_UUID_V4_RE.test(message.operation_id))
|
|
46
|
+
if (typeof message.operation_id !== 'string' || !OWNERSHIP_UUID_V4_RE.test(message.operation_id))
|
|
40
47
|
throw new ValidationError('operation_id must be a UUIDv4');
|
|
41
48
|
if (!['bind', 'release', 'transfer'].includes(message.operation))
|
|
42
49
|
throw new ValidationError('ownership operation is invalid');
|
|
43
50
|
ownershipAid(message.sub_aid, 'sub_aid');
|
|
44
51
|
ownershipAid(message.owner_aid, 'owner_aid');
|
|
45
52
|
ownershipInteger(message.base_relation_version, 'base_relation_version', 0);
|
|
46
|
-
|
|
47
|
-
|
|
53
|
+
const target = ownershipInteger(message.target_relation_version, 'target_relation_version');
|
|
54
|
+
if (target !== message.base_relation_version + 1 && !(message.operation === 'release' && target === message.base_relation_version))
|
|
55
|
+
throw new ValidationError('ownership target version does not match the operation');
|
|
48
56
|
if (message.operation === 'transfer') {
|
|
49
57
|
if (ownershipAid(message.new_owner_aid, 'new_owner_aid') === message.owner_aid)
|
|
50
58
|
throw new ValidationError('transfer target must differ from current owner');
|
|
@@ -107,7 +115,7 @@ export function parseOwnershipMessage(content, context) {
|
|
|
107
115
|
if (ns.protocol !== '2.1')
|
|
108
116
|
throw ownershipFailure('unsupported_protocol', 'NS ownership protocol is unavailable');
|
|
109
117
|
const base = ns.relation_version === message.base_relation_version && (message.operation === 'bind' ? ns.lock === 'unlocked' && !ns.owner_aid : ns.lock === 'locked' && ns.owner_aid === message.owner_aid);
|
|
110
|
-
const target =
|
|
118
|
+
const target = message.operation === 'release' ? ns.lock === 'unlocked' && !ns.owner_aid && (ns.relation_version === message.base_relation_version || ns.relation_version === 0) : ns.relation_version === message.target_relation_version && ns.lock === 'locked' && ns.owner_aid === targetOwner(message);
|
|
111
119
|
if (['relation.confirmed', 'sub.cleaned'].includes(message.action) ? !target : !base && !target)
|
|
112
120
|
throw ownershipFailure('conflict', 'ownership current NS relation does not match this task');
|
|
113
121
|
}
|
|
@@ -154,7 +162,7 @@ export class OwnershipWorkflow {
|
|
|
154
162
|
return this.start('transfer', ownershipAid(subAid, 'sub_aid'), this.local(), ownershipAid(newOwnerAid, 'new_owner_aid'));
|
|
155
163
|
}
|
|
156
164
|
async resumeRegistration(ownerAid, operationId) {
|
|
157
|
-
if (!OWNERSHIP_UUID_V4_RE.test(operationId))
|
|
165
|
+
if (typeof operationId !== 'string' || !OWNERSHIP_UUID_V4_RE.test(operationId))
|
|
158
166
|
throw ownershipFailure('invalid', 'registration operation_id is invalid');
|
|
159
167
|
return this.start('bind', this.local(), ownershipAid(ownerAid, 'owner_aid'), undefined, operationId);
|
|
160
168
|
}
|
|
@@ -166,6 +174,95 @@ export class OwnershipWorkflow {
|
|
|
166
174
|
throw ownershipFailure('invalid', 'ownership rejection reason must be a string');
|
|
167
175
|
return this.decide(operationId, 'reject', reason);
|
|
168
176
|
}
|
|
177
|
+
async resetOwnershipTask() {
|
|
178
|
+
if (!this.deps.store.loadOwnershipTaskSource || !this.deps.store.resetOwnershipTask)
|
|
179
|
+
throw ownershipFailure('persistence_unavailable', 'atomic ownership task reset storage is unavailable');
|
|
180
|
+
const local = this.local(), check = () => { if (this.local() !== local)
|
|
181
|
+
throw ownershipFailure('conflict', 'ownership reset identity changed'); };
|
|
182
|
+
const reset = [], skipped = [];
|
|
183
|
+
for (const snapshot of this.list(true)) {
|
|
184
|
+
check();
|
|
185
|
+
if (!OWNERSHIP_UUID_V4_RE.test(snapshot.operation_id)) {
|
|
186
|
+
skipped.push(snapshot.operation_id);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
let source = this.taskSource(snapshot.operation_id);
|
|
190
|
+
for (let attempt = 0; source; attempt++) {
|
|
191
|
+
let task;
|
|
192
|
+
try {
|
|
193
|
+
const parsed = JSON.parse(source.source);
|
|
194
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
195
|
+
task = parsed;
|
|
196
|
+
}
|
|
197
|
+
catch { }
|
|
198
|
+
if (task && Object.hasOwn(task, 'task_reset')) {
|
|
199
|
+
try {
|
|
200
|
+
this.decode({ operation_id: source.operation_id, revision: source.revision, task });
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
skipped.push(snapshot.operation_id);
|
|
204
|
+
}
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
let initiator;
|
|
208
|
+
try {
|
|
209
|
+
initiator = ownershipAid(task?.initiator_aid, 'initiator_aid');
|
|
210
|
+
}
|
|
211
|
+
catch { }
|
|
212
|
+
if (!task || !initiator || !['pending', 'completed', 'rejected', 'stopped', 'error'].includes(task.status)) {
|
|
213
|
+
skipped.push(snapshot.operation_id);
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
if (initiator !== local || ['completed', 'rejected', 'stopped'].includes(task.status))
|
|
217
|
+
break;
|
|
218
|
+
let subject;
|
|
219
|
+
try {
|
|
220
|
+
subject = ownershipAid(task.sub_aid, 'sub_aid');
|
|
221
|
+
}
|
|
222
|
+
catch { }
|
|
223
|
+
try {
|
|
224
|
+
const original = source, result = await this.runSerialized(subject ?? source.operation_id, () => { check(); return this.resetSource(original, subject); });
|
|
225
|
+
check();
|
|
226
|
+
if (result.created)
|
|
227
|
+
reset.push(source.operation_id);
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
check();
|
|
232
|
+
if (attempt > 0 || !(error instanceof StateError) || error.stringCode !== 'conflict')
|
|
233
|
+
throw error;
|
|
234
|
+
source = this.taskSource(snapshot.operation_id);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
check();
|
|
239
|
+
return { reset_operation_ids: [...new Set(reset)].sort(), skipped_operation_ids: [...new Set(skipped)].sort() };
|
|
240
|
+
}
|
|
241
|
+
async resetTask(operationId) {
|
|
242
|
+
if (typeof operationId !== 'string' || !OWNERSHIP_UUID_V4_RE.test(operationId))
|
|
243
|
+
throw ownershipFailure('invalid', 'ownership operation_id is invalid');
|
|
244
|
+
const source = this.taskSource(operationId);
|
|
245
|
+
if (!source)
|
|
246
|
+
throw ownershipFailure('operation_not_found', 'no local ownership task matches operation_id');
|
|
247
|
+
let task = {};
|
|
248
|
+
try {
|
|
249
|
+
const parsed = JSON.parse(source.source);
|
|
250
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
251
|
+
task = parsed;
|
|
252
|
+
}
|
|
253
|
+
catch { }
|
|
254
|
+
if (Object.hasOwn(task, 'task_reset')) {
|
|
255
|
+
this.decode({ operation_id: operationId, revision: source.revision, task });
|
|
256
|
+
return { operation_id: operationId, reset: true };
|
|
257
|
+
}
|
|
258
|
+
let subject;
|
|
259
|
+
try {
|
|
260
|
+
subject = ownershipAid(task.sub_aid, 'sub_aid');
|
|
261
|
+
}
|
|
262
|
+
catch { }
|
|
263
|
+
await this.runSerialized(subject ?? operationId, () => this.resetSource(source, subject));
|
|
264
|
+
return { operation_id: operationId, reset: true };
|
|
265
|
+
}
|
|
169
266
|
async checkOwnershipConsensus(subAid = this.local()) {
|
|
170
267
|
return this.deps.files.checkConsensus(ownershipAid(subAid, 'sub_aid'));
|
|
171
268
|
}
|
|
@@ -188,8 +285,12 @@ export class OwnershipWorkflow {
|
|
|
188
285
|
return true;
|
|
189
286
|
let message;
|
|
190
287
|
try {
|
|
288
|
+
const saved = this.load(String(body.operation_id));
|
|
289
|
+
if (saved && this.isReset(saved.record))
|
|
290
|
+
return true;
|
|
291
|
+
const task = saved && !saved.record.base_agent_md && saved.record.request_verified && !['completed', 'stopped', 'rejected'].includes(saved.record.status) ? this.legacyTaskView(saved.record) : saved?.record;
|
|
191
292
|
message = parseOwnershipMessage(content, { authenticated_from_aid: from, local_aid: this.local(),
|
|
192
|
-
task: ['bind.request', 'transfer.request'].includes(String(body.action)) || body.operation === 'release' && body.action === 'relation.confirmed' ? undefined :
|
|
293
|
+
task: task ?? (['bind.request', 'transfer.request'].includes(String(body.action)) || body.operation === 'release' && body.action === 'relation.confirmed' ? undefined : saved?.record) });
|
|
193
294
|
}
|
|
194
295
|
catch (error) {
|
|
195
296
|
if (error instanceof StateError && error.stringCode === 'persistence_unavailable')
|
|
@@ -209,32 +310,26 @@ export class OwnershipWorkflow {
|
|
|
209
310
|
return this.runSerialized(message.sub_aid, async () => {
|
|
210
311
|
let handle = this.load(message.operation_id);
|
|
211
312
|
if (handle) {
|
|
313
|
+
const task = !handle.record.base_agent_md && handle.record.request_verified && !['completed', 'stopped', 'rejected'].includes(handle.record.status) ? this.legacyTaskView(handle.record) : handle.record;
|
|
212
314
|
try {
|
|
213
|
-
parseOwnershipMessage(content, { authenticated_from_aid: from, local_aid: this.local(), task
|
|
315
|
+
parseOwnershipMessage(content, { authenticated_from_aid: from, local_aid: this.local(), task });
|
|
214
316
|
}
|
|
215
317
|
catch {
|
|
216
318
|
return true;
|
|
217
319
|
}
|
|
320
|
+
await this.upgradeLegacy(handle);
|
|
218
321
|
}
|
|
219
322
|
else {
|
|
220
323
|
if (!['bind.request', 'transfer.request'].includes(message.action) && !(message.operation === 'release' && message.action === 'relation.confirmed'))
|
|
221
324
|
return true;
|
|
222
325
|
handle = { record: this.newRecord(message, from, 'pending'), revision: null };
|
|
223
|
-
if (message.operation === 'release') {
|
|
224
|
-
const authorization = this.cleanupAuthorization(handle.record);
|
|
225
|
-
if (authorization) {
|
|
226
|
-
handle.record.local_decision = 'accept';
|
|
227
|
-
handle.record.approvals[this.local()] = 'accept';
|
|
228
|
-
handle.record.cleanup_authorization_id = authorization.operation_id;
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
326
|
}
|
|
232
327
|
if (message.action === 'sub.cleaned') {
|
|
233
328
|
const began = Date.now();
|
|
234
329
|
const before = await this.readNs(message.sub_aid), after = await this.readNs(message.sub_aid);
|
|
235
330
|
if (Date.now() - began > 30_000 || before.exists !== after.exists || before.etag !== after.etag || before.relation_version !== after.relation_version || before.lock !== after.lock || before.owner_aid !== after.owner_aid || ownershipSha256(before.content) !== ownershipSha256(after.content))
|
|
236
331
|
throw ownershipFailure('unavailable', 'NS changed during sub cleanup verification', true);
|
|
237
|
-
if (!before.exists || before.relation_version !==
|
|
332
|
+
if (!before.exists || before.relation_version !== 0 || before.lock !== 'unlocked' || before.owner_aid || !before.etag)
|
|
238
333
|
return true;
|
|
239
334
|
const file = parseOwnershipFile(before.content, 'agent', { expectedSubAid: message.sub_aid });
|
|
240
335
|
if (Object.hasOwn(file.frontmatter, 'owner') || Object.hasOwn(file.frontmatter, 'ownership'))
|
|
@@ -253,15 +348,27 @@ export class OwnershipWorkflow {
|
|
|
253
348
|
});
|
|
254
349
|
}
|
|
255
350
|
async resume() {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
351
|
+
const failed = [];
|
|
352
|
+
for (const snapshot of this.list(true)) {
|
|
353
|
+
try {
|
|
354
|
+
const record = this.decode(snapshot).record;
|
|
355
|
+
if (this.isReset(record))
|
|
356
|
+
continue;
|
|
357
|
+
if (!record.base_agent_md && record.request_verified && ['completed', 'stopped', 'rejected'].includes(record.status))
|
|
358
|
+
continue;
|
|
359
|
+
await this.runSerialized(record.sub_aid, async () => {
|
|
360
|
+
const handle = this.load(record.operation_id);
|
|
361
|
+
await this.drive(handle);
|
|
362
|
+
if (handle.record.request_verified && handle.record.local_decision === 'pending' && handle.record.status === 'pending' && handle.record.operation !== 'release')
|
|
363
|
+
this.emit(handle.record, 'ownership.request');
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
failed.push(`${snapshot.operation_id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
368
|
+
}
|
|
264
369
|
}
|
|
370
|
+
if (failed.length)
|
|
371
|
+
throw ownershipFailure('persistence_unavailable', failed.join('; '));
|
|
265
372
|
}
|
|
266
373
|
local() { return ownershipAid(this.deps.currentAid(), 'local_aid'); }
|
|
267
374
|
requireApplicable(subAid) {
|
|
@@ -273,9 +380,9 @@ export class OwnershipWorkflow {
|
|
|
273
380
|
throw ownershipFailure('persistence_unavailable', 'atomic ownership task storage is unavailable');
|
|
274
381
|
return this.deps.store;
|
|
275
382
|
}
|
|
276
|
-
list() {
|
|
383
|
+
list(isolateErrors = false) {
|
|
277
384
|
try {
|
|
278
|
-
return this.store().listOwnershipTasks(this.local());
|
|
385
|
+
return this.store().listOwnershipTasks(this.local(), isolateErrors);
|
|
279
386
|
}
|
|
280
387
|
catch (error) {
|
|
281
388
|
throw ownershipFailure('persistence_unavailable', error instanceof Error ? error.message : String(error));
|
|
@@ -290,11 +397,116 @@ export class OwnershipWorkflow {
|
|
|
290
397
|
throw ownershipFailure('persistence_unavailable', error instanceof Error ? error.message : String(error));
|
|
291
398
|
}
|
|
292
399
|
}
|
|
400
|
+
taskSource(operationId) {
|
|
401
|
+
try {
|
|
402
|
+
if (!this.deps.store.loadOwnershipTaskSource || !this.deps.store.resetOwnershipTask)
|
|
403
|
+
throw new Error('atomic ownership task reset storage is unavailable');
|
|
404
|
+
return this.deps.store.loadOwnershipTaskSource(this.local(), operationId);
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
throw ownershipFailure('persistence_unavailable', error instanceof Error ? error.message : String(error));
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
async resetSource(source, subject) {
|
|
411
|
+
const task = { protocol: '2.1', operation_id: source.operation_id, local_aid: this.local(), status: 'stopped', step: 'reset',
|
|
412
|
+
...(subject ? { sub_aid: subject } : {}), task_reset: { source: source.source, source_sha256: ownershipSha256(source.source), revision: source.revision, reset_at: Math.trunc(Date.now() / 1000) } };
|
|
413
|
+
try {
|
|
414
|
+
const saved = this.deps.store.resetOwnershipTask(this.local(), source.operation_id, source.revision, source.source, task);
|
|
415
|
+
this.decode(saved);
|
|
416
|
+
if (!this.isReset(saved.task))
|
|
417
|
+
throw new Error('ownership reset marker was not saved');
|
|
418
|
+
const committed = this.taskSource(source.operation_id);
|
|
419
|
+
if (!committed || committed.revision !== saved.revision || JSON.stringify(JSON.parse(committed.source)) !== JSON.stringify(saved.task))
|
|
420
|
+
throw new Error('ownership reset marker readback failed');
|
|
421
|
+
return { operation_id: source.operation_id, reset: true, created: true };
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
if (!(error instanceof Error) || !/snapshot|revision conflict|already reset/.test(error.message))
|
|
425
|
+
throw ownershipFailure('persistence_unavailable', error instanceof Error ? error.message : String(error));
|
|
426
|
+
const winner = this.taskSource(source.operation_id);
|
|
427
|
+
if (winner) {
|
|
428
|
+
let parsed;
|
|
429
|
+
try {
|
|
430
|
+
parsed = JSON.parse(winner.source);
|
|
431
|
+
}
|
|
432
|
+
catch { }
|
|
433
|
+
if (parsed && typeof parsed === 'object' && Object.hasOwn(parsed, 'task_reset')) {
|
|
434
|
+
this.decode({ operation_id: winner.operation_id, revision: winner.revision, task: parsed });
|
|
435
|
+
return { operation_id: source.operation_id, reset: true, created: false };
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
throw ownershipFailure(error instanceof Error && /revision|snapshot|conflict/.test(error.message) ? 'conflict' : 'persistence_unavailable', error instanceof Error ? error.message : String(error));
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
isReset(record) { return Object.hasOwn(record, 'task_reset'); }
|
|
442
|
+
async resetUnusableTasks(subAid, ownerAid) {
|
|
443
|
+
for (const snapshot of this.list(true)) {
|
|
444
|
+
if (this.isReset(snapshot.task)) {
|
|
445
|
+
this.decode(snapshot);
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
if (snapshot.task.sub_aid !== subAid)
|
|
449
|
+
continue;
|
|
450
|
+
if (!this.isReset(snapshot.task) && ['completed', 'rejected', 'stopped'].includes(snapshot.task.status))
|
|
451
|
+
continue;
|
|
452
|
+
let record;
|
|
453
|
+
let unusable = false;
|
|
454
|
+
try {
|
|
455
|
+
record = this.decode(snapshot).record;
|
|
456
|
+
}
|
|
457
|
+
catch (error) {
|
|
458
|
+
if (this.isReset(snapshot.task) || !(error instanceof OwnershipTaskDecodeError) || error.operationId !== snapshot.operation_id)
|
|
459
|
+
throw error;
|
|
460
|
+
unusable = true;
|
|
461
|
+
record = snapshot.task;
|
|
462
|
+
}
|
|
463
|
+
if (!unusable && !this.isReset(record) && !record.base_agent_md && record.request_verified && !['completed', 'stopped', 'rejected'].includes(record.status) && record.operation === 'bind' && record.owner_aid === ownerAid && record.initiator_aid === this.local()) {
|
|
464
|
+
try {
|
|
465
|
+
this.legacyTaskView(record);
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
unusable = true;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (!unusable)
|
|
472
|
+
continue;
|
|
473
|
+
const source = this.taskSource(snapshot.operation_id);
|
|
474
|
+
if (!source || source.revision !== snapshot.revision)
|
|
475
|
+
throw ownershipFailure('conflict', 'ownership task advanced before automatic reset');
|
|
476
|
+
let parsed;
|
|
477
|
+
try {
|
|
478
|
+
parsed = JSON.parse(source.source);
|
|
479
|
+
}
|
|
480
|
+
catch { }
|
|
481
|
+
if (!parsed || typeof parsed !== 'object' || parsed.sub_aid !== subAid)
|
|
482
|
+
throw ownershipFailure('persistence_unavailable', 'ownership task subject cannot be established for automatic reset');
|
|
483
|
+
if (Object.hasOwn(parsed, 'task_reset'))
|
|
484
|
+
throw ownershipFailure('persistence_unavailable', 'ownership reset marker is corrupt');
|
|
485
|
+
if (['completed', 'rejected', 'stopped'].includes(parsed.status))
|
|
486
|
+
continue;
|
|
487
|
+
await this.resetSource(source, subAid);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
293
490
|
decode(snapshot) {
|
|
294
491
|
const record = snapshot.task;
|
|
295
492
|
try {
|
|
296
|
-
if (
|
|
493
|
+
if (this.isReset(record)) {
|
|
494
|
+
const task = snapshot.task, reset = task.task_reset;
|
|
495
|
+
if (task.protocol !== '2.1' || task.operation_id !== snapshot.operation_id || !OWNERSHIP_UUID_V4_RE.test(snapshot.operation_id) || task.local_aid !== this.local() || task.status !== 'stopped' || task.step !== 'reset' || Object.keys(task).some((key) => !['protocol', 'operation_id', 'local_aid', 'status', 'step', 'sub_aid', 'task_reset'].includes(key)) || !reset || typeof reset !== 'object' || Array.isArray(reset) || Object.keys(reset).length !== 4 || typeof reset.source !== 'string' || reset.source_sha256 !== ownershipSha256(reset.source) || !Number.isSafeInteger(reset.revision) || Number(reset.revision) < 1 || Number(reset.revision) + 1 !== snapshot.revision || !Number.isSafeInteger(reset.reset_at) || Number(reset.reset_at) <= 0)
|
|
496
|
+
throw new Error('ownership reset marker is corrupt');
|
|
497
|
+
let subject;
|
|
498
|
+
try {
|
|
499
|
+
subject = ownershipAid(JSON.parse(reset.source).sub_aid, 'sub_aid');
|
|
500
|
+
}
|
|
501
|
+
catch { }
|
|
502
|
+
if (task.sub_aid !== subject)
|
|
503
|
+
throw new Error('ownership reset subject changed');
|
|
504
|
+
return { record, revision: snapshot.revision };
|
|
505
|
+
}
|
|
506
|
+
if (record.protocol !== '2.1' || typeof record.operation_id !== 'string' || !OWNERSHIP_UUID_V4_RE.test(record.operation_id) || record.operation_id !== snapshot.operation_id || !['bind', 'release', 'transfer'].includes(record.operation) || !['pending', 'completed', 'rejected', 'stopped', 'error'].includes(record.status) || typeof record.step !== 'string')
|
|
297
507
|
throw new Error('invalid ownership task');
|
|
508
|
+
if (!record.base_agent_md && record.request_verified && !record.legacy_task)
|
|
509
|
+
return { record, revision: snapshot.revision };
|
|
298
510
|
ownershipAid(record.sub_aid, 'sub_aid');
|
|
299
511
|
ownershipAid(record.owner_aid, 'owner_aid');
|
|
300
512
|
ownershipAid(record.initiator_aid, 'initiator_aid');
|
|
@@ -304,22 +516,86 @@ export class OwnershipWorkflow {
|
|
|
304
516
|
}
|
|
305
517
|
else if (record.new_owner_aid !== undefined)
|
|
306
518
|
throw new Error('unexpected transfer target');
|
|
307
|
-
|
|
519
|
+
const baseVersion = ownershipInteger(record.base_relation_version, 'base_relation_version', 0), targetVersion = ownershipInteger(record.target_relation_version, 'target_relation_version');
|
|
520
|
+
if (targetVersion !== baseVersion + 1 && !(record.operation === 'release' && targetVersion === baseVersion) || !participants(record).includes(record.initiator_aid) || record.operation !== 'bind' && record.initiator_aid !== record.owner_aid)
|
|
308
521
|
throw new Error('invalid fixed ownership context');
|
|
309
522
|
if (JSON.stringify(record.local_roles) !== JSON.stringify(localRoles(record, this.local())) || !record.local_roles.length || !['pending', 'accept', 'reject'].includes(record.local_decision) || !record.approvals || participants(record).some((aid) => !['pending', 'accept', 'reject'].includes(record.approvals[aid])) || record.approvals[this.local()] !== record.local_decision || !record.files || typeof record.files !== 'object' || Array.isArray(record.files) || !Array.isArray(record.inbox) || !Array.isArray(record.outbox) || !Array.isArray(record.received) || !Array.isArray(record.notices) || typeof record.request_verified !== 'boolean' || typeof record.request_notified !== 'boolean')
|
|
310
523
|
throw new Error('ownership approval or pending state is corrupt');
|
|
311
524
|
if (Object.keys(record.approvals).length !== participants(record).length || record.approvals[record.initiator_aid] !== 'accept' || record.initiator_aid === this.local() && record.local_decision !== 'accept')
|
|
312
525
|
throw new Error('ownership initiator or approval participants are corrupt');
|
|
526
|
+
if (record.legacy_recovery !== undefined) {
|
|
527
|
+
const recovery = record.legacy_recovery, original = recovery.task, checkpoint = recovery.checkpoint;
|
|
528
|
+
if (!original || record.request_verified !== true || recovery.task_sha256 !== ownershipSha256(JSON.stringify(original)) || !Number.isSafeInteger(recovery.revision) || recovery.revision < 1 || recovery.revision >= snapshot.revision || !checkpoint || !Number.isSafeInteger(checkpoint.observed_at) || checkpoint.observed_at <= 0 || typeof recovery.owner_confirmed !== 'boolean')
|
|
529
|
+
throw new Error('legacy recovery checkpoint is corrupt');
|
|
530
|
+
this.legacyView(original);
|
|
531
|
+
if (['operation_id', 'operation', 'sub_aid', 'owner_aid', 'initiator_aid', 'base_relation_version', 'target_relation_version'].some((key) => original[key] !== record[key]) || JSON.stringify(checkpoint.agent_md) !== JSON.stringify(record.base_agent_md) || original.local_decision !== 'pending' && original.local_decision !== record.local_decision || participants(record).some((name) => original.approvals[name] !== 'pending' && original.approvals[name] !== record.approvals[name]))
|
|
532
|
+
throw new Error('legacy recovery original context or approval changed');
|
|
533
|
+
const owner = checkpoint.owner_md;
|
|
534
|
+
if (!owner || typeof owner.exists !== 'boolean' || typeof owner.raw !== 'string' || owner.sha256 !== (owner.exists ? ownershipSha256(owner.raw) : '') || !owner.exists && owner.raw !== '')
|
|
535
|
+
throw new Error('legacy recovery owner observation is corrupt');
|
|
536
|
+
if (owner.exists) {
|
|
537
|
+
const file = parseOwnershipFile(owner.raw, 'owner', { expectedSubAid: record.sub_aid, expectedOwnerAid: record.owner_aid });
|
|
538
|
+
if (file.action !== 'declare' || file.statement?.relation_version !== 1)
|
|
539
|
+
throw new Error('legacy recovery owner observation changed');
|
|
540
|
+
}
|
|
541
|
+
const originalOwner = original.files.owner, pinned = record.files.owner;
|
|
542
|
+
if ((originalOwner || owner.exists) && (!pinned || pinned.raw !== (originalOwner?.raw ?? owner.raw) || pinned.hash !== (originalOwner?.hash ?? owner.sha256) || JSON.stringify(pinned.original_owner) !== JSON.stringify(owner)) || originalOwner && (!owner.exists || originalOwner.raw !== owner.raw || originalOwner.hash !== owner.sha256))
|
|
543
|
+
throw new Error('legacy recovery owner candidate changed');
|
|
544
|
+
if (this.local() === record.owner_aid ? !recovery.owner_confirmed : recovery.owner_confirmed ? !record.received.includes(`${record.owner_aid}|${record.operation_id}|owner.ready`) : Boolean(record.files.agent) || record.status === 'completed')
|
|
545
|
+
throw new Error('legacy recovery owner confirmation is corrupt');
|
|
546
|
+
}
|
|
547
|
+
if (record.legacy_task) {
|
|
548
|
+
const recovery = record.legacy_task, original = recovery.task;
|
|
549
|
+
if (!Number.isSafeInteger(recovery.revision) || recovery.revision < 1 || recovery.revision >= snapshot.revision || recovery.task_sha256 !== ownershipSha256(JSON.stringify(original)))
|
|
550
|
+
throw new Error('legacy task source is corrupt');
|
|
551
|
+
this.priorView(original);
|
|
552
|
+
if (['operation_id', 'operation', 'sub_aid', 'owner_aid', 'new_owner_aid', 'initiator_aid', 'base_relation_version', 'target_relation_version'].some((key) => original[key] !== record[key]) || Object.entries(original.approvals).some(([aid, decision]) => decision !== 'pending' && record.approvals[aid] !== decision) || JSON.stringify(recovery.checkpoint.agent_md) !== JSON.stringify(record.base_agent_md ?? null) || JSON.stringify(recovery.checkpoint.owner_md) !== JSON.stringify(record.base_owner_md ?? null) || !record.base_agent_md && record.status !== 'completed')
|
|
553
|
+
throw new Error('legacy task fixed context changed');
|
|
554
|
+
for (const [key, saved] of Object.entries(original.files))
|
|
555
|
+
if (record.files[key]?.raw !== saved.raw || record.files[key]?.hash !== saved.hash || key === 'agent' && JSON.stringify(record.files[key]?.condition) !== JSON.stringify(saved.condition))
|
|
556
|
+
throw new Error('legacy task signed candidate changed');
|
|
557
|
+
}
|
|
558
|
+
if (record.request_verified && (record.base_agent_md || !record.legacy_task)) {
|
|
559
|
+
const base = record.base_agent_md;
|
|
560
|
+
if (!base || typeof base.exists !== 'boolean' || typeof base.raw !== 'string' || base.sha256 !== ownershipSha256(base.raw) || typeof base.etag !== 'string' || (base.exists ? !base.etag : base.raw !== '' || base.etag !== '' || record.operation !== 'bind' || record.base_relation_version !== 0))
|
|
561
|
+
throw new Error('ownership original agent.md evidence is corrupt');
|
|
562
|
+
const statement = base.exists ? parseOwnershipFile(base.raw, 'agent', { expectedSubAid: record.sub_aid }).statement ?? null : null;
|
|
563
|
+
if (JSON.stringify(statement) !== JSON.stringify(base.statement) || (statement?.relation_version ?? 0) !== record.base_relation_version)
|
|
564
|
+
throw new Error('ownership original statement is corrupt');
|
|
565
|
+
const owner = record.base_owner_md;
|
|
566
|
+
if (!owner || typeof owner.exists !== 'boolean' || typeof owner.raw !== 'string' || owner.sha256 !== (owner.exists ? ownershipSha256(owner.raw) : '') || !owner.exists && owner.raw !== '')
|
|
567
|
+
throw new Error('ownership original owner.md evidence is corrupt');
|
|
568
|
+
if (owner.exists) {
|
|
569
|
+
const declaration = parseOwnershipFile(owner.raw, 'owner', { expectedSubAid: record.sub_aid, expectedOwnerAid: statement?.owner_aid ?? record.owner_aid });
|
|
570
|
+
if (declaration.statement?.relation_version !== record.base_relation_version)
|
|
571
|
+
throw new Error('ownership original owner statement is corrupt');
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
for (const item of record.inbox)
|
|
575
|
+
parseOwnershipMessage(item.payload, { authenticated_from_aid: item.from, local_aid: this.local(), task: record });
|
|
576
|
+
for (const key of record.received) {
|
|
577
|
+
if (typeof key !== 'string' || key.split('|').length !== 3)
|
|
578
|
+
throw new Error('ownership received key is corrupt');
|
|
579
|
+
const [from, operationId, action] = key.split('|');
|
|
580
|
+
parseOwnershipMessage({ type: OWNERSHIP_SDK_MESSAGE_TYPE, operation_id: operationId, operation: record.operation, action,
|
|
581
|
+
sub_aid: record.sub_aid, owner_aid: record.owner_aid, ...(record.operation === 'transfer' ? { new_owner_aid: record.new_owner_aid } : {}),
|
|
582
|
+
base_relation_version: record.base_relation_version, target_relation_version: record.target_relation_version }, { authenticated_from_aid: from, local_aid: this.local(), task: record });
|
|
583
|
+
}
|
|
584
|
+
for (const item of record.outbox)
|
|
585
|
+
this.validateOutgoing(record, item);
|
|
313
586
|
if (record.status === 'completed' && (record.verified_relation_version !== record.target_relation_version || record.operation !== 'release' && record.local_decision !== 'accept'))
|
|
314
587
|
throw new Error('ownership completion evidence or local approval is corrupt');
|
|
315
588
|
const transferEvidenceRequired = record.operation === 'transfer' && (record.authorization_sha256 !== undefined || record.expires_at !== undefined || (record.status === 'completed' || ['owner', 'agent'].includes(record.step) || record.files.agent || record.files.owner) && record.local_roles.some((role) => role === 'sub' || role === 'new_owner'));
|
|
316
589
|
if (transferEvidenceRequired && (!/^[0-9a-f]{64}$/.test(record.authorization_sha256 ?? '') || !Number.isSafeInteger(record.expires_at) || (record.expires_at ?? 0) <= 0))
|
|
317
590
|
throw new Error('ownership transfer authorization evidence is corrupt');
|
|
318
591
|
for (const [key, saved] of Object.entries(record.files)) {
|
|
319
|
-
if (!['agent', 'owner', 'release', 'transfer', 'cleanup'].includes(key) || !saved || typeof saved.raw !== 'string' || saved.hash !== ownershipSha256(saved.raw) || !saved.condition || typeof saved.condition !== 'object' || Object.keys(saved.condition).length !== 1 || saved.
|
|
592
|
+
if (!['agent', 'owner', 'release', 'transfer', 'cleanup'].includes(key) || !saved || typeof saved.raw !== 'string' || saved.hash !== ownershipSha256(saved.raw) || !saved.condition || typeof saved.condition !== 'object' || Object.keys(saved.condition).length !== (key === 'agent' ? 1 : 0) || saved.published_etag !== undefined && (key !== 'agent' || typeof saved.published_etag !== 'string' || !saved.published_etag))
|
|
320
593
|
throw new Error('ownership saved file is corrupt');
|
|
321
|
-
if (key === 'agent'
|
|
594
|
+
if (key === 'agent' && !(typeof saved.condition.if_match === 'string' && saved.condition.if_match || saved.condition.if_none_match === '*'))
|
|
322
595
|
throw new Error('ownership original file condition is corrupt');
|
|
596
|
+
const original = saved.original_owner;
|
|
597
|
+
if (key !== 'agent' && key !== 'cleanup' && (!original || typeof original.exists !== 'boolean' || typeof original.raw !== 'string' || original.sha256 !== (original.exists ? ownershipSha256(original.raw) : '') || !original.exists && original.raw !== ''))
|
|
598
|
+
throw new Error('ownership original owner material is corrupt');
|
|
323
599
|
const owner = ['agent', 'owner'].includes(key) ? targetOwner(record) : record.owner_aid;
|
|
324
600
|
const file = parseOwnershipFile(saved.raw, key === 'agent' ? 'agent' : 'owner', { expectedSubAid: record.sub_aid, expectedOwnerAid: owner });
|
|
325
601
|
const version = ['release', 'transfer', 'cleanup'].includes(key) ? record.base_relation_version : record.target_relation_version;
|
|
@@ -339,7 +615,8 @@ export class OwnershipWorkflow {
|
|
|
339
615
|
}
|
|
340
616
|
if (record.status === 'completed') {
|
|
341
617
|
const evidence = record.completion_evidence;
|
|
342
|
-
|
|
618
|
+
const cleaned = record.operation === 'release' && evidence?.status === 'unbound' && evidence.relation_version === 0 && record.cleanup_status === 'cleaned' && Boolean(this.cleanupEvidence(record, evidence));
|
|
619
|
+
if (!evidence || evidence.sub_aid !== record.sub_aid || !cleaned && (evidence.relation_version !== (record.operation === 'release' ? record.base_relation_version : record.target_relation_version) || evidence.status !== (record.operation === 'release' ? 'released' : 'consensus')) || evidence.lock !== (record.operation === 'release' ? 'unlocked' : 'locked') || evidence.owner_aid !== (record.operation === 'release' ? null : targetOwner(record)) || typeof evidence.agent_md_etag !== 'string' || !evidence.agent_md_etag || !/^[0-9a-f]{64}$/.test(evidence.agent_md_sha256) || typeof evidence.owner_md_sha256 !== 'string' || record.operation !== 'release' && !/^[0-9a-f]{64}$/.test(evidence.owner_md_sha256))
|
|
343
620
|
throw new Error('ownership target acceptance evidence is corrupt');
|
|
344
621
|
if (record.operation !== 'release' && (record.local_roles.includes('sub') && record.files.agent?.hash !== evidence.agent_md_sha256 || this.local() === targetOwner(record) && record.files.owner?.hash !== evidence.owner_md_sha256 || record.operation === 'transfer' && record.local_roles.includes('owner') && !record.files.transfer))
|
|
345
622
|
throw new Error('ownership target file evidence is missing');
|
|
@@ -349,8 +626,194 @@ export class OwnershipWorkflow {
|
|
|
349
626
|
return { record, revision: snapshot.revision };
|
|
350
627
|
}
|
|
351
628
|
catch (error) {
|
|
352
|
-
|
|
629
|
+
if (error instanceof StateError && error.stringCode === 'persistence_unavailable')
|
|
630
|
+
throw error;
|
|
631
|
+
throw new OwnershipTaskDecodeError(snapshot.operation_id, error instanceof Error ? error.message : String(error));
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
legacyTaskView(record) {
|
|
635
|
+
return record.operation === 'bind' && record.base_relation_version === 0 && record.initiator_aid === record.sub_aid && Object.keys(record.files ?? {}).every((key) => key === 'owner') ? this.legacyView(record) : this.priorView(record);
|
|
636
|
+
}
|
|
637
|
+
legacyView(record) {
|
|
638
|
+
if (Object.hasOwn(record, 'legacy_recovery') || record.protocol !== '2.1' || record.operation !== 'bind' || record.base_relation_version !== 0 || record.target_relation_version !== 1 || record.initiator_aid !== record.sub_aid || record.request_verified !== true || record.status !== 'pending' && !(record.status === 'error' && record.retryable === true) || record.base_agent_md != null || record.base_owner_md != null || !record.files || typeof record.files !== 'object' || Array.isArray(record.files) || Object.keys(record.files).some((key) => key !== 'owner') || record.local_decision === 'reject' || Object.values(record.approvals ?? {}).includes('reject'))
|
|
639
|
+
throw ownershipFailure('unsupported_protocol', 'legacy ownership task cannot be recovered automatically');
|
|
640
|
+
const view = JSON.parse(JSON.stringify(record));
|
|
641
|
+
view.request_verified = false;
|
|
642
|
+
view.files = {};
|
|
643
|
+
view.inbox = record.inbox?.map((item) => ({ from: item.from, payload: this.legacyMessage(item.payload) }));
|
|
644
|
+
view.outbox = record.outbox?.map((item) => ({ to: item.to, payload: this.legacyMessage(item.payload) }));
|
|
645
|
+
this.decode({ operation_id: record.operation_id, revision: 1, task: view });
|
|
646
|
+
for (const notice of record.notices) {
|
|
647
|
+
if (typeof notice !== 'string' || notice.split('|').length !== 2)
|
|
648
|
+
throw ownershipFailure('unsupported_protocol', 'legacy ownership notice is corrupt');
|
|
649
|
+
const [to, action] = notice.split('|');
|
|
650
|
+
this.validateOutgoing(view, { to: to, payload: { type: OWNERSHIP_SDK_MESSAGE_TYPE, operation_id: record.operation_id, operation: record.operation, action: action, sub_aid: record.sub_aid, owner_aid: record.owner_aid, base_relation_version: 0, target_relation_version: 1 } });
|
|
651
|
+
}
|
|
652
|
+
const saved = record.files.owner;
|
|
653
|
+
if (saved) {
|
|
654
|
+
if (this.local() !== record.owner_aid || record.local_decision !== 'accept' || typeof saved.raw !== 'string' || saved.hash !== ownershipSha256(saved.raw) || !saved.condition || Object.keys(saved.condition).length !== 1 || typeof saved.condition.expected_revision !== 'string' || !saved.condition.expected_revision || saved.published_revision !== undefined && (typeof saved.published_revision !== 'string' || !saved.published_revision))
|
|
655
|
+
throw ownershipFailure('unsupported_protocol', 'legacy owner candidate cannot be recovered automatically');
|
|
656
|
+
const file = parseOwnershipFile(saved.raw, 'owner', { expectedSubAid: record.sub_aid, expectedOwnerAid: record.owner_aid });
|
|
657
|
+
if (file.action !== 'declare' || file.statement?.relation_version !== 1)
|
|
658
|
+
throw ownershipFailure('unsupported_protocol', 'legacy owner candidate does not match this task');
|
|
659
|
+
}
|
|
660
|
+
return view;
|
|
661
|
+
}
|
|
662
|
+
legacyMessage(content) {
|
|
663
|
+
if (!content || typeof content !== 'object' || Array.isArray(content))
|
|
664
|
+
throw ownershipFailure('unsupported_protocol', 'legacy ownership message is corrupt');
|
|
665
|
+
return { ...content };
|
|
666
|
+
}
|
|
667
|
+
async upgradeLegacy(handle) {
|
|
668
|
+
if (Boolean(handle.record.base_agent_md) || !handle.record.request_verified || ['completed', 'stopped', 'rejected'].includes(handle.record.status))
|
|
669
|
+
return;
|
|
670
|
+
if (handle.record.operation !== 'bind' || handle.record.base_relation_version !== 0 || handle.record.initiator_aid !== handle.record.sub_aid || Object.keys(handle.record.files ?? {}).some((key) => key !== 'owner'))
|
|
671
|
+
return this.upgradePrior(handle);
|
|
672
|
+
const original = JSON.parse(JSON.stringify(handle.record));
|
|
673
|
+
const record = this.legacyView(original);
|
|
674
|
+
for (const snapshot of this.list(true)) {
|
|
675
|
+
if (snapshot.operation_id === record.operation_id || snapshot.task.sub_aid !== record.sub_aid)
|
|
676
|
+
continue;
|
|
677
|
+
const other = this.decode(snapshot).record;
|
|
678
|
+
if (!other.base_agent_md && other.request_verified && !['completed', 'stopped', 'rejected'].includes(other.status))
|
|
679
|
+
this.legacyTaskView(other);
|
|
680
|
+
if (other.status === 'pending' || other.status === 'error' && other.retryable)
|
|
681
|
+
throw ownershipFailure('conflict', 'a different active ownership intent already exists');
|
|
353
682
|
}
|
|
683
|
+
const ns = await this.readNs(record.sub_aid);
|
|
684
|
+
if ((ns.exists ? parseOwnershipFile(ns.content, 'agent', { expectedSubAid: record.sub_aid }).frontmatter.type : record.sub_aid === this.local() ? this.deps.currentType?.() : undefined) === 'human')
|
|
685
|
+
throw ownershipFailure('invalid', 'human cannot declare an owner');
|
|
686
|
+
if (ns.lock === 'locked' && ns.owner_aid === record.owner_aid && ns.relation_version === record.target_relation_version)
|
|
687
|
+
return this.upgradePrior(handle);
|
|
688
|
+
if (ns.lock !== 'unlocked' || ns.relation_version !== 0 || ns.owner_aid || ns.exists && parseOwnershipFile(ns.content, 'agent', { expectedSubAid: record.sub_aid }).statement)
|
|
689
|
+
throw ownershipFailure('conflict', 'legacy ownership relation has changed');
|
|
690
|
+
const current = await this.deps.files.readOwner(record.owner_aid, record.sub_aid);
|
|
691
|
+
if (current.exists) {
|
|
692
|
+
const file = await this.deps.files.verifyFile(current.content, 'owner', record.sub_aid, record.owner_aid);
|
|
693
|
+
if (file.action !== 'declare' || file.statement?.relation_version !== 1)
|
|
694
|
+
throw ownershipFailure('conflict', 'legacy owner declaration has changed');
|
|
695
|
+
}
|
|
696
|
+
const candidate = original.files.owner;
|
|
697
|
+
if (candidate) {
|
|
698
|
+
await this.deps.files.verifyFile(candidate.raw, 'owner', record.sub_aid, record.owner_aid);
|
|
699
|
+
if (!current.exists || current.content !== candidate.raw || current.sha256 !== candidate.hash)
|
|
700
|
+
throw ownershipFailure('conflict', 'legacy owner candidate was replaced');
|
|
701
|
+
if (candidate.published_revision !== undefined && current.revision !== candidate.published_revision)
|
|
702
|
+
throw ownershipFailure(current.revision ? 'conflict' : 'unavailable', 'legacy published owner revision changed', !current.revision);
|
|
703
|
+
}
|
|
704
|
+
else if (this.local() === record.owner_aid && current.exists)
|
|
705
|
+
throw ownershipFailure('unsupported_protocol', 'legacy owner declaration lacks a local task candidate');
|
|
706
|
+
const owner = { exists: current.exists, raw: current.content, sha256: current.exists ? ownershipSha256(current.content) : '', ...(candidate && current.revision ? { revision: current.revision } : {}) };
|
|
707
|
+
record.status = 'pending';
|
|
708
|
+
record.step = 'approval';
|
|
709
|
+
record.retryable = true;
|
|
710
|
+
record.request_verified = true;
|
|
711
|
+
delete record.code;
|
|
712
|
+
delete record.reason;
|
|
713
|
+
record.inbox = [];
|
|
714
|
+
record.received = [];
|
|
715
|
+
record.outbox = [];
|
|
716
|
+
record.notices = [];
|
|
717
|
+
await this.captureBase(record, ns);
|
|
718
|
+
if (candidate || current.exists && this.local() === record.sub_aid)
|
|
719
|
+
record.files.owner = { raw: candidate?.raw ?? current.content, hash: candidate?.hash ?? current.sha256, condition: {}, original_owner: owner };
|
|
720
|
+
record.legacy_recovery = { task: original, revision: handle.revision, task_sha256: ownershipSha256(JSON.stringify(original)), checkpoint: { observed_at: Math.trunc(Date.now() / 1000), agent_md: record.base_agent_md, owner_md: owner }, owner_confirmed: this.local() === record.owner_aid };
|
|
721
|
+
if (this.local() === record.sub_aid)
|
|
722
|
+
this.enqueue(record, record.owner_aid, 'bind.request');
|
|
723
|
+
else if (record.local_decision === 'accept')
|
|
724
|
+
this.enqueue(record, record.sub_aid, 'bind.accept');
|
|
725
|
+
const after = await this.readNs(record.sub_aid), afterOwner = await this.deps.files.readOwner(record.owner_aid, record.sub_aid);
|
|
726
|
+
if (after.exists !== ns.exists || after.content !== ns.content || after.etag !== ns.etag || after.relation_version !== 0 || after.lock !== 'unlocked' || afterOwner.exists !== current.exists || afterOwner.content !== current.content || afterOwner.sha256 !== current.sha256)
|
|
727
|
+
throw ownershipFailure('conflict', 'legacy recovery observations changed');
|
|
728
|
+
const recovered = { record, revision: handle.revision };
|
|
729
|
+
this.decode({ operation_id: record.operation_id, revision: handle.revision + 1, task: record });
|
|
730
|
+
try {
|
|
731
|
+
await this.save(recovered);
|
|
732
|
+
}
|
|
733
|
+
catch (error) {
|
|
734
|
+
if (error instanceof StateError && error.stringCode === 'persistence_unavailable' && error.message.includes('active ownership task already exists'))
|
|
735
|
+
throw ownershipFailure('conflict', 'a different active ownership intent already exists');
|
|
736
|
+
throw error;
|
|
737
|
+
}
|
|
738
|
+
handle.record = record;
|
|
739
|
+
handle.revision = recovered.revision;
|
|
740
|
+
}
|
|
741
|
+
priorView(original) {
|
|
742
|
+
if (original.base_agent_md != null || original.base_owner_md != null || original.legacy_task || original.legacy_recovery || !original.request_verified || !['pending', 'error'].includes(original.status) || original.status === 'error' && !original.retryable || Object.values(original.approvals ?? {}).includes('reject'))
|
|
743
|
+
throw ownershipFailure('unsupported_protocol', 'legacy task context cannot be recovered');
|
|
744
|
+
const record = structuredClone(original);
|
|
745
|
+
record.request_verified = false;
|
|
746
|
+
for (const [key, saved] of Object.entries(record.files ?? {})) {
|
|
747
|
+
const owner = key === 'owner' ? targetOwner(record) : record.owner_aid;
|
|
748
|
+
if (this.local() !== (key === 'agent' ? record.sub_aid : owner))
|
|
749
|
+
throw ownershipFailure('unsupported_protocol', 'legacy file local role is invalid');
|
|
750
|
+
if (key !== 'agent' && key !== 'cleanup') {
|
|
751
|
+
const condition = saved.condition;
|
|
752
|
+
if (!saved.original_owner && (!condition || Object.keys(condition).length !== 1 || typeof condition.expected_revision !== 'string' || !condition.expected_revision))
|
|
753
|
+
throw ownershipFailure('unsupported_protocol', 'legacy owner condition is invalid');
|
|
754
|
+
saved.condition = {};
|
|
755
|
+
saved.original_owner ??= { exists: false, raw: '', sha256: '' };
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
try {
|
|
759
|
+
this.decode({ operation_id: original.operation_id, revision: 1, task: record });
|
|
760
|
+
}
|
|
761
|
+
catch (error) {
|
|
762
|
+
throw ownershipFailure('unsupported_protocol', error instanceof Error ? error.message : String(error));
|
|
763
|
+
}
|
|
764
|
+
return record;
|
|
765
|
+
}
|
|
766
|
+
async upgradePrior(handle) {
|
|
767
|
+
const original = structuredClone(handle.record), record = this.priorView(original);
|
|
768
|
+
for (const snapshot of this.list(true)) {
|
|
769
|
+
if (snapshot.operation_id === record.operation_id || snapshot.task.sub_aid !== record.sub_aid)
|
|
770
|
+
continue;
|
|
771
|
+
const other = this.decode(snapshot).record;
|
|
772
|
+
if (other.status === 'pending' || other.status === 'error' && other.retryable)
|
|
773
|
+
throw ownershipFailure('conflict', 'a different active ownership intent already exists');
|
|
774
|
+
}
|
|
775
|
+
const ns = await this.readNs(record.sub_aid);
|
|
776
|
+
if ((ns.exists ? parseOwnershipFile(ns.content, 'agent', { expectedSubAid: record.sub_aid }).frontmatter.type : record.sub_aid === this.local() ? this.deps.currentType?.() : undefined) === 'human')
|
|
777
|
+
throw ownershipFailure('invalid', 'human cannot declare an owner');
|
|
778
|
+
const base = ns.relation_version === record.base_relation_version && (record.operation === 'bind' ? ns.lock === 'unlocked' && !ns.owner_aid : ns.lock === 'locked' && ns.owner_aid === record.owner_aid);
|
|
779
|
+
const released = record.operation === 'release' && ns.lock === 'unlocked' && !ns.owner_aid && ns.relation_version === record.base_relation_version;
|
|
780
|
+
const committed = record.operation !== 'release' && ns.lock === 'locked' && ns.owner_aid === targetOwner(record) && ns.relation_version === record.target_relation_version;
|
|
781
|
+
if (!base && !released && !committed)
|
|
782
|
+
throw ownershipFailure('conflict', 'legacy relationship no longer matches this task');
|
|
783
|
+
if (!committed)
|
|
784
|
+
await this.captureBase(record, ns);
|
|
785
|
+
for (const [key, saved] of Object.entries(record.files)) {
|
|
786
|
+
const owner = key === 'owner' || key === 'agent' ? targetOwner(record) : record.owner_aid;
|
|
787
|
+
await this.deps.files.verifyFile(saved.raw, key === 'agent' ? 'agent' : 'owner', record.sub_aid, owner);
|
|
788
|
+
if (key === 'agent')
|
|
789
|
+
continue;
|
|
790
|
+
const current = await this.deps.files.readOwner(owner, record.sub_aid);
|
|
791
|
+
const source = original.files[key];
|
|
792
|
+
const revision = source.condition.expected_revision;
|
|
793
|
+
if (revision && (current.content === saved.raw && source.published_revision !== undefined || !committed && current.content !== saved.raw)) {
|
|
794
|
+
if (!current.revision)
|
|
795
|
+
throw ownershipFailure('unavailable', 'legacy owner original revision is unavailable', true);
|
|
796
|
+
if (current.revision !== (current.content === saved.raw ? source.published_revision : revision))
|
|
797
|
+
throw ownershipFailure('conflict', 'legacy owner original revision changed');
|
|
798
|
+
}
|
|
799
|
+
const currentStatement = current.exists ? parseOwnershipFile(current.content, 'owner', { expectedSubAid: record.sub_aid }).statement : undefined;
|
|
800
|
+
const expected = parseOwnershipFile(saved.raw, 'owner', { expectedSubAid: record.sub_aid }).statement;
|
|
801
|
+
const same = expected && currentStatement && Object.keys(expected).every((field) => expected[field] === currentStatement[field]);
|
|
802
|
+
if (current.content !== saved.raw && (source.published_revision !== undefined || current.exists && (key === 'owner' || !same)) && !committed)
|
|
803
|
+
throw ownershipFailure('conflict', 'legacy signed owner file was replaced');
|
|
804
|
+
saved.original_owner = { exists: current.exists, raw: current.content, sha256: current.exists ? current.sha256 : '', ...(revision && current.revision ? { revision: current.revision } : {}) };
|
|
805
|
+
}
|
|
806
|
+
record.request_verified = true;
|
|
807
|
+
record.legacy_task = { task: original, revision: handle.revision, task_sha256: ownershipSha256(JSON.stringify(original)), checkpoint: { agent_md: record.base_agent_md ?? null, owner_md: record.base_owner_md ?? null } };
|
|
808
|
+
const recovered = { record, revision: handle.revision };
|
|
809
|
+
if (committed) {
|
|
810
|
+
if (!await this.confirm(recovered))
|
|
811
|
+
throw ownershipFailure('conflict', 'legacy committed target lacks matching local signed evidence');
|
|
812
|
+
}
|
|
813
|
+
else
|
|
814
|
+
await this.save(recovered);
|
|
815
|
+
handle.record = record;
|
|
816
|
+
handle.revision = recovered.revision;
|
|
354
817
|
}
|
|
355
818
|
async save(handle) {
|
|
356
819
|
try {
|
|
@@ -422,17 +885,36 @@ export class OwnershipWorkflow {
|
|
|
422
885
|
async start(operation, subAid, ownerAid, newOwnerAid, operationId) {
|
|
423
886
|
this.requireApplicable(subAid);
|
|
424
887
|
return this.runSerialized(subAid, async () => {
|
|
888
|
+
if (operation === 'bind' && !operationId)
|
|
889
|
+
await this.resetUnusableTasks(subAid, ownerAid);
|
|
425
890
|
const existing = operationId ? this.load(operationId) : null;
|
|
426
891
|
if (existing) {
|
|
892
|
+
if (this.isReset(existing.record))
|
|
893
|
+
throw ownershipFailure('conflict', 'ownership task was reset');
|
|
894
|
+
await this.upgradeLegacy(existing);
|
|
427
895
|
const record = existing.record;
|
|
896
|
+
if (!record.base_agent_md && record.request_verified)
|
|
897
|
+
throw ownershipFailure('unsupported_protocol', 'legacy ownership task requires explicit migration');
|
|
428
898
|
if (record.operation !== operation || record.sub_aid !== subAid || record.owner_aid !== ownerAid || record.new_owner_aid !== newOwnerAid || record.initiator_aid !== this.local())
|
|
429
899
|
throw ownershipFailure('conflict', 'saved registration task context changed');
|
|
430
900
|
await this.drive(existing);
|
|
431
901
|
return ownershipTaskResult(record);
|
|
432
902
|
}
|
|
433
903
|
let ns;
|
|
434
|
-
for (const snapshot of this.list()) {
|
|
435
|
-
|
|
904
|
+
for (const snapshot of this.list(true)) {
|
|
905
|
+
if (snapshot.task.sub_aid !== subAid)
|
|
906
|
+
continue;
|
|
907
|
+
const active = this.decode(snapshot);
|
|
908
|
+
if (this.isReset(active.record))
|
|
909
|
+
continue;
|
|
910
|
+
if (!active.record.base_agent_md && active.record.request_verified && ['completed', 'stopped', 'rejected'].includes(active.record.status))
|
|
911
|
+
continue;
|
|
912
|
+
if (!active.record.base_agent_md && active.record.request_verified && (active.record.operation !== operation || active.record.owner_aid !== ownerAid || active.record.new_owner_aid !== newOwnerAid || active.record.initiator_aid !== this.local()))
|
|
913
|
+
throw ownershipFailure('conflict', 'a different active ownership intent already exists');
|
|
914
|
+
await this.upgradeLegacy(active);
|
|
915
|
+
const record = active.record;
|
|
916
|
+
if (!record.base_agent_md && record.request_verified)
|
|
917
|
+
throw ownershipFailure('unsupported_protocol', 'legacy ownership task cannot be recovered automatically');
|
|
436
918
|
if (record.sub_aid !== subAid || record.status !== 'pending' && !(record.status === 'error' && record.retryable))
|
|
437
919
|
continue;
|
|
438
920
|
const same = record.operation === operation && record.owner_aid === ownerAid && record.new_owner_aid === newOwnerAid && record.initiator_aid === this.local();
|
|
@@ -445,8 +927,18 @@ export class OwnershipWorkflow {
|
|
|
445
927
|
await this.recordError(active, error);
|
|
446
928
|
return ownershipTaskResult(record);
|
|
447
929
|
}
|
|
448
|
-
|
|
449
|
-
|
|
930
|
+
let base = ns.relation_version === record.base_relation_version && (record.operation === 'bind' ? ns.lock === 'unlocked' && !ns.owner_aid : ns.lock === 'locked' && ns.owner_aid === record.owner_aid);
|
|
931
|
+
if (base && record.request_verified) {
|
|
932
|
+
try {
|
|
933
|
+
await this.assertBaseMaterial(active, ns);
|
|
934
|
+
}
|
|
935
|
+
catch (error) {
|
|
936
|
+
if (!(error instanceof StateError) || error.stringCode !== 'conflict')
|
|
937
|
+
throw error;
|
|
938
|
+
base = false;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
const target = record.operation === 'release' ? ns.lock === 'unlocked' && !ns.owner_aid && (ns.relation_version === record.base_relation_version || ns.relation_version === 0) : ns.relation_version === record.target_relation_version && ns.lock === 'locked' && ns.owner_aid === targetOwner(record);
|
|
450
942
|
if (!base && !target) {
|
|
451
943
|
await this.recordError(active, ownershipFailure('conflict', 'the operation was superseded by a different relation result'));
|
|
452
944
|
const result = ownershipTaskResult(record);
|
|
@@ -466,17 +958,20 @@ export class OwnershipWorkflow {
|
|
|
466
958
|
ns = await this.readNs(subAid);
|
|
467
959
|
if (operation === 'bind' && ns.lock !== 'unlocked')
|
|
468
960
|
throw ownershipFailure('owner_locked', 'the current owner relation is locked');
|
|
469
|
-
if (!ns.exists)
|
|
470
|
-
throw ownershipFailure('
|
|
471
|
-
if (operation === 'bind' && parseOwnershipFile(ns.content, 'agent', { expectedSubAid: subAid }).frontmatter.type === 'human')
|
|
961
|
+
if (!ns.exists && operation !== 'bind')
|
|
962
|
+
throw ownershipFailure('conflict', 'original agent.md is missing');
|
|
963
|
+
if (operation === 'bind' && (ns.exists ? parseOwnershipFile(ns.content, 'agent', { expectedSubAid: subAid }).frontmatter.type : subAid === this.local() ? this.deps.currentType?.() : undefined) === 'human')
|
|
472
964
|
throw ownershipFailure('invalid', 'human cannot declare an owner');
|
|
965
|
+
if (operation === 'bind' && ns.exists && parseOwnershipFile(ns.content, 'agent', { expectedSubAid: subAid }).statement?.owner_aid === ownerAid)
|
|
966
|
+
throw ownershipFailure('conflict', 'clear the released agent.md statement before binding the same owner');
|
|
473
967
|
if (operation !== 'bind') {
|
|
474
968
|
const consensus = await this.deps.files.checkConsensus(subAid);
|
|
475
969
|
if (consensus.status !== 'consensus' || consensus.owner_aid !== ownerAid || consensus.relation_version !== ns.relation_version)
|
|
476
970
|
throw ownershipFailure(consensus.status === 'invalid' ? 'invalid' : consensus.status === 'unavailable' ? consensus.code ?? 'unavailable' : 'forbidden', 'the current original owner consensus cannot be verified', consensus.status === 'unavailable');
|
|
477
971
|
}
|
|
478
|
-
ownershipInteger(ns.relation_version + 1, 'target_relation_version');
|
|
479
|
-
const record = this.newRecord({ operation_id: operationId ?? randomUUID(), operation, sub_aid: subAid, owner_aid: ownerAid, new_owner_aid: newOwnerAid, base_relation_version: ns.relation_version, target_relation_version:
|
|
972
|
+
const targetVersion = ownershipInteger(ns.relation_version + (operation === 'release' ? 0 : 1), 'target_relation_version');
|
|
973
|
+
const record = this.newRecord({ operation_id: operationId ?? randomUUID(), operation, sub_aid: subAid, owner_aid: ownerAid, new_owner_aid: newOwnerAid, base_relation_version: ns.relation_version, target_relation_version: targetVersion }, this.local(), 'accept');
|
|
974
|
+
await this.captureBase(record, ns);
|
|
480
975
|
if (operation !== 'release')
|
|
481
976
|
for (const to of participants(record))
|
|
482
977
|
if (to !== this.local())
|
|
@@ -491,9 +986,14 @@ export class OwnershipWorkflow {
|
|
|
491
986
|
const initial = this.load(operationId);
|
|
492
987
|
if (!initial)
|
|
493
988
|
throw ownershipFailure('operation_not_found', 'no local ownership request matches operation_id');
|
|
989
|
+
if (this.isReset(initial.record))
|
|
990
|
+
throw ownershipFailure('conflict', 'ownership task was reset');
|
|
494
991
|
return this.runSerialized(initial.record.sub_aid, async () => {
|
|
495
992
|
const handle = this.load(operationId);
|
|
993
|
+
await this.upgradeLegacy(handle);
|
|
496
994
|
const record = handle.record;
|
|
995
|
+
if (!record.base_agent_md && record.request_verified)
|
|
996
|
+
throw ownershipFailure('unsupported_protocol', 'legacy ownership task requires explicit migration');
|
|
497
997
|
if (record.operation === 'release' || record.initiator_aid === this.local() || !record.request_verified)
|
|
498
998
|
throw ownershipFailure('forbidden', 'this task is not a verified local approval request');
|
|
499
999
|
if (record.local_decision !== 'pending') {
|
|
@@ -509,6 +1009,7 @@ export class OwnershipWorkflow {
|
|
|
509
1009
|
const ns = await this.readNs(record.sub_aid);
|
|
510
1010
|
if (ns.relation_version !== record.base_relation_version || (record.operation === 'bind' ? ns.lock !== 'unlocked' : ns.lock !== 'locked' || ns.owner_aid !== record.owner_aid))
|
|
511
1011
|
throw ownershipFailure('conflict', 'the original relation changed before approval');
|
|
1012
|
+
await this.assertBaseMaterial(handle, ns);
|
|
512
1013
|
record.local_decision = decision;
|
|
513
1014
|
record.approvals[this.local()] = decision;
|
|
514
1015
|
this.enqueue(record, record.initiator_aid, `${record.operation}.${decision === 'accept' ? 'accept' : 'reject'}`, reason);
|
|
@@ -540,19 +1041,42 @@ export class OwnershipWorkflow {
|
|
|
540
1041
|
record.outbox.push({ to, payload });
|
|
541
1042
|
record.notices.push(key);
|
|
542
1043
|
}
|
|
1044
|
+
validateOutgoing(record, item) {
|
|
1045
|
+
try {
|
|
1046
|
+
const message = parseOwnershipMessage(item.payload, { authenticated_from_aid: this.local(), local_aid: item.to, task: { ...record, local_decision: 'accept' } });
|
|
1047
|
+
const rejected = ['bind.reject', 'transfer.reject'].includes(message.action);
|
|
1048
|
+
if (message.action !== 'operation.stopped' && record.local_decision !== (rejected ? 'reject' : 'accept') || record.approvals[this.local()] !== record.local_decision)
|
|
1049
|
+
throw new Error('ownership outgoing action lacks local approval');
|
|
1050
|
+
if (['bind.request', 'transfer.request'].includes(message.action) && record.initiator_aid !== this.local())
|
|
1051
|
+
throw new Error('ownership outgoing request is not from the initiator');
|
|
1052
|
+
if ((message.action === 'transfer.ready' || message.action === 'owner.ready' && record.operation === 'bind') && !participants(record).every((aid) => record.approvals[aid] === 'accept'))
|
|
1053
|
+
throw new Error('ownership outgoing action lacks participant approvals');
|
|
1054
|
+
}
|
|
1055
|
+
catch (error) {
|
|
1056
|
+
throw new OwnershipTaskDecodeError(record.operation_id, error instanceof Error ? error.message : String(error));
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
543
1059
|
async flush(handle) {
|
|
544
1060
|
while (handle.record.outbox.length) {
|
|
545
1061
|
const message = handle.record.outbox[0];
|
|
546
|
-
|
|
1062
|
+
this.validateOutgoing(handle.record, message);
|
|
547
1063
|
const record = handle.record;
|
|
1064
|
+
if (['rejected', 'stopped', 'error'].includes(record.status) && !['bind.reject', 'transfer.reject', 'operation.stopped'].includes(message.payload.action)) {
|
|
1065
|
+
record.outbox.shift();
|
|
1066
|
+
await this.save(handle);
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
const ns = await this.readNs(record.sub_aid);
|
|
548
1070
|
const base = ns.relation_version === record.base_relation_version && (record.operation === 'bind' ? ns.lock === 'unlocked' && !ns.owner_aid : ns.lock === 'locked' && ns.owner_aid === record.owner_aid);
|
|
549
|
-
const target =
|
|
1071
|
+
const target = record.operation === 'release' ? ns.lock === 'unlocked' && !ns.owner_aid && (ns.relation_version === record.base_relation_version || ns.relation_version === 0) : ns.relation_version === record.target_relation_version && ns.lock === 'locked' && ns.owner_aid === targetOwner(record);
|
|
550
1072
|
const requiresTarget = ['relation.confirmed', 'sub.cleaned'].includes(message.payload.action);
|
|
551
1073
|
if (requiresTarget ? !target : !base) {
|
|
552
1074
|
record.outbox.shift();
|
|
553
1075
|
await this.save(handle);
|
|
554
1076
|
continue;
|
|
555
1077
|
}
|
|
1078
|
+
if (base && record.request_verified)
|
|
1079
|
+
await this.assertBaseMaterial(handle, ns);
|
|
556
1080
|
if (message.payload.action === 'transfer.ready' && record.status !== 'completed') {
|
|
557
1081
|
const expiresAt = record.files.transfer?.expires_at;
|
|
558
1082
|
if (typeof expiresAt !== 'number' || !Number.isSafeInteger(expiresAt) || expiresAt <= 0)
|
|
@@ -560,6 +1084,7 @@ export class OwnershipWorkflow {
|
|
|
560
1084
|
if (expiresAt <= Math.trunc(Date.now() / 1000))
|
|
561
1085
|
throw ownershipFailure('conflict', 'transfer authorization expired');
|
|
562
1086
|
}
|
|
1087
|
+
this.assertActive(handle);
|
|
563
1088
|
await this.deps.send(message.to, message.payload);
|
|
564
1089
|
handle.record.outbox.shift();
|
|
565
1090
|
await this.save(handle);
|
|
@@ -567,19 +1092,77 @@ export class OwnershipWorkflow {
|
|
|
567
1092
|
}
|
|
568
1093
|
async readNs(subAid) {
|
|
569
1094
|
const ns = await this.deps.files.readAgent(subAid);
|
|
570
|
-
const file = ns.exists
|
|
571
|
-
if (ns.
|
|
1095
|
+
const file = ns.exists ? await this.deps.files.verifyFile(ns.content, 'agent', subAid) : undefined;
|
|
1096
|
+
if (ns.relation_version !== (file?.statement?.relation_version ?? 0))
|
|
572
1097
|
throw ownershipFailure('unavailable', 'NS relation metadata does not match agent.md', true);
|
|
573
1098
|
const currentOwner = ns.lock === 'locked' ? file?.statement?.owner_aid : null;
|
|
574
1099
|
if (ns.lock === 'locked' && (!file?.statement || file.statement.relation_version !== ns.relation_version) || ns.owner_aid !== undefined && ns.owner_aid !== currentOwner)
|
|
575
1100
|
throw ownershipFailure('unavailable', 'NS owner metadata and signed agent.md do not agree', true);
|
|
576
1101
|
return { ...ns, owner_aid: currentOwner };
|
|
577
1102
|
}
|
|
1103
|
+
async captureBase(record, ns) {
|
|
1104
|
+
const statement = ns.exists ? parseOwnershipFile(ns.content, 'agent', { expectedSubAid: record.sub_aid }).statement ?? null : null;
|
|
1105
|
+
record.base_agent_md = { exists: ns.exists, raw: ns.content, sha256: ownershipSha256(ns.content), etag: ns.etag, statement };
|
|
1106
|
+
if (!statement) {
|
|
1107
|
+
record.base_owner_md = { exists: false, raw: '', sha256: '' };
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
const owner = await this.deps.files.readOwner(statement.owner_aid, record.sub_aid);
|
|
1111
|
+
if ((record.operation === 'transfer' || record.operation === 'release' && ns.lock === 'locked') && !owner.exists)
|
|
1112
|
+
throw ownershipFailure('unavailable', 'original owner.md is unavailable', true);
|
|
1113
|
+
if (owner.exists)
|
|
1114
|
+
await this.deps.files.verifyFile(owner.content, 'owner', record.sub_aid, statement.owner_aid);
|
|
1115
|
+
record.base_owner_md = { exists: owner.exists, raw: owner.content, sha256: owner.exists ? ownershipSha256(owner.content) : '' };
|
|
1116
|
+
}
|
|
1117
|
+
async assertBaseMaterial(handle, ns) {
|
|
1118
|
+
const record = handle.record;
|
|
1119
|
+
this.assertAgentBaseMaterial(record, ns);
|
|
1120
|
+
const original = record.base_owner_md;
|
|
1121
|
+
if (!original)
|
|
1122
|
+
throw ownershipFailure('persistence_unavailable', 'original owner.md evidence is missing');
|
|
1123
|
+
const originalOwnerAid = record.base_agent_md.statement?.owner_aid;
|
|
1124
|
+
if (!originalOwnerAid)
|
|
1125
|
+
return;
|
|
1126
|
+
const current = await this.deps.files.readOwner(originalOwnerAid, record.sub_aid);
|
|
1127
|
+
if (current.exists === original.exists && current.content === original.raw && (current.exists ? current.sha256 : '') === original.sha256)
|
|
1128
|
+
return;
|
|
1129
|
+
if (!current.exists)
|
|
1130
|
+
throw ownershipFailure('conflict', 'the original owner.md was replaced');
|
|
1131
|
+
const key = record.operation === 'bind' ? 'owner' : record.operation === 'release' ? 'release' : 'transfer';
|
|
1132
|
+
const saved = record.files[key];
|
|
1133
|
+
if (saved && saved.raw === current.content && saved.hash === current.sha256)
|
|
1134
|
+
return;
|
|
1135
|
+
if (record.operation !== 'bind' && this.local() !== record.owner_aid && record.local_decision === 'accept') {
|
|
1136
|
+
const owner = await this.deps.files.verifyFile(current.content, 'owner', record.sub_aid, originalOwnerAid);
|
|
1137
|
+
const previous = original.exists ? parseOwnershipFile(original.raw, 'owner', { expectedSubAid: record.sub_aid, expectedOwnerAid: originalOwnerAid }) : undefined;
|
|
1138
|
+
const sameStatement = previous?.statement && Object.keys(previous.statement).every((field) => previous.statement[field] === owner.statement?.[field]);
|
|
1139
|
+
const allowed = sameStatement && (record.operation === 'release' ? owner.action === 'release' : owner.action === 'transfer_owner' && owner.transfer?.new_owner_aid === record.new_owner_aid && owner.transfer?.target_relation_version === record.target_relation_version);
|
|
1140
|
+
if (allowed && !saved) {
|
|
1141
|
+
if (record.operation === 'transfer')
|
|
1142
|
+
await this.deps.files.requireTransfer(this.fileTask(handle));
|
|
1143
|
+
record.files[key] = { raw: current.content, hash: current.sha256, condition: {}, original_owner: original, ...(owner.transfer ? { expires_at: owner.transfer.expires_at } : {}) };
|
|
1144
|
+
await this.save(handle);
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
throw ownershipFailure('conflict', 'the original owner statement was replaced');
|
|
1149
|
+
}
|
|
1150
|
+
assertAgentBaseMaterial(record, ns) {
|
|
1151
|
+
const base = record.base_agent_md;
|
|
1152
|
+
if (!base || typeof base.raw !== 'string')
|
|
1153
|
+
throw ownershipFailure('persistence_unavailable', 'original agent.md evidence is missing');
|
|
1154
|
+
const statement = ns.exists ? parseOwnershipFile(ns.content, 'agent', { expectedSubAid: record.sub_aid }).statement ?? null : null;
|
|
1155
|
+
const same = base.statement ? Boolean(statement && Object.keys(base.statement).every((key) => base.statement[key] === statement[key])) : statement === null && ns.content === base.raw && ownershipSha256(ns.content) === base.sha256;
|
|
1156
|
+
if (ns.exists !== base.exists || !same)
|
|
1157
|
+
throw ownershipFailure('conflict', 'the original ownership statement was replaced');
|
|
1158
|
+
}
|
|
578
1159
|
async processInbox(handle) {
|
|
579
1160
|
const record = handle.record;
|
|
580
1161
|
while (record.inbox.length) {
|
|
581
1162
|
const item = record.inbox[0];
|
|
582
1163
|
const ns = await this.readNs(record.sub_aid);
|
|
1164
|
+
if (record.request_verified && ns.relation_version === record.base_relation_version && !['relation.confirmed', 'sub.cleaned'].includes(item.payload.action))
|
|
1165
|
+
await this.assertBaseMaterial(handle, ns);
|
|
583
1166
|
try {
|
|
584
1167
|
parseOwnershipMessage(item.payload, { authenticated_from_aid: item.from, local_aid: this.local(), task: record, ns });
|
|
585
1168
|
}
|
|
@@ -596,11 +1179,71 @@ export class OwnershipWorkflow {
|
|
|
596
1179
|
continue;
|
|
597
1180
|
}
|
|
598
1181
|
const action = item.payload.action;
|
|
599
|
-
if (
|
|
1182
|
+
if (action === 'owner.ready' && record.legacy_recovery && record.status === 'pending') {
|
|
1183
|
+
const owner = await this.deps.files.readOwner(record.owner_aid, record.sub_aid);
|
|
1184
|
+
if (!owner.exists)
|
|
1185
|
+
throw ownershipFailure(record.files.owner ? 'conflict' : 'unavailable', 'legacy owner confirmation declaration is missing', !record.files.owner);
|
|
1186
|
+
const file = await this.deps.files.verifyFile(owner.content, 'owner', record.sub_aid, record.owner_aid);
|
|
1187
|
+
if (file.action !== 'declare' || file.statement?.relation_version !== record.target_relation_version || record.files.owner && (record.files.owner.raw !== owner.content || record.files.owner.hash !== owner.sha256))
|
|
1188
|
+
throw ownershipFailure('conflict', 'legacy owner confirmation declaration changed');
|
|
1189
|
+
if (!record.files.owner)
|
|
1190
|
+
record.files.owner = { raw: owner.content, hash: owner.sha256, condition: {}, original_owner: { exists: true, raw: owner.content, sha256: owner.sha256 } };
|
|
1191
|
+
record.legacy_recovery.owner_confirmed = true;
|
|
1192
|
+
}
|
|
1193
|
+
if (['bind.request', 'transfer.request'].includes(action)) {
|
|
1194
|
+
if (!record.request_verified)
|
|
1195
|
+
await this.captureBase(record, ns);
|
|
600
1196
|
record.request_verified = true;
|
|
1197
|
+
}
|
|
601
1198
|
if (['bind.accept', 'transfer.accept', 'bind.reject', 'transfer.reject'].includes(action)) {
|
|
602
1199
|
if (record.approvals[item.from] === 'pending' && record.status === 'pending') {
|
|
603
1200
|
const rejected = action.endsWith('.reject');
|
|
1201
|
+
if (rejected) {
|
|
1202
|
+
try {
|
|
1203
|
+
if (await this.confirm(handle)) {
|
|
1204
|
+
record.inbox.shift();
|
|
1205
|
+
record.received.push(`${item.from}|${item.payload.operation_id}|${action}`);
|
|
1206
|
+
await this.save(handle);
|
|
1207
|
+
continue;
|
|
1208
|
+
}
|
|
1209
|
+
const observed = await this.deps.files.checkConsensus(record.sub_aid, { owner_aid: targetOwner(record), relation_version: record.target_relation_version });
|
|
1210
|
+
const targetReached = observed.status === 'consensus' && observed.owner_aid === targetOwner(record) && observed.relation_version === record.target_relation_version;
|
|
1211
|
+
if (targetReached) {
|
|
1212
|
+
record.code = 'unavailable';
|
|
1213
|
+
record.reason = 'ownership target is committed but local evidence is incomplete';
|
|
1214
|
+
record.retryable = true;
|
|
1215
|
+
record.inbox.shift();
|
|
1216
|
+
record.received.push(`${item.from}|${item.payload.operation_id}|${action}`);
|
|
1217
|
+
await this.save(handle);
|
|
1218
|
+
continue;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
catch (error) {
|
|
1222
|
+
if (error instanceof StateError && error.stringCode === 'persistence_unavailable')
|
|
1223
|
+
throw error;
|
|
1224
|
+
try {
|
|
1225
|
+
const ns = await this.readNs(record.sub_aid);
|
|
1226
|
+
if (ns.relation_version === record.target_relation_version && ns.lock === 'locked' && ns.owner_aid === targetOwner(record)) {
|
|
1227
|
+
record.code = 'unavailable';
|
|
1228
|
+
record.reason = 'ownership target is committed but local evidence is incomplete';
|
|
1229
|
+
record.retryable = true;
|
|
1230
|
+
record.inbox.shift();
|
|
1231
|
+
record.received.push(`${item.from}|${item.payload.operation_id}|${action}`);
|
|
1232
|
+
await this.save(handle);
|
|
1233
|
+
continue;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
catch (observationError) {
|
|
1237
|
+
if (observationError instanceof StateError && observationError.stringCode === 'persistence_unavailable')
|
|
1238
|
+
throw observationError;
|
|
1239
|
+
}
|
|
1240
|
+
record.code = error instanceof StateError ? error.stringCode : 'unavailable';
|
|
1241
|
+
record.reason = error instanceof Error ? error.message : String(error);
|
|
1242
|
+
record.retryable = true;
|
|
1243
|
+
await this.save(handle);
|
|
1244
|
+
throw error;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
604
1247
|
record.approvals[item.from] = rejected ? 'reject' : 'accept';
|
|
605
1248
|
if (rejected) {
|
|
606
1249
|
record.status = 'rejected';
|
|
@@ -625,8 +1268,30 @@ export class OwnershipWorkflow {
|
|
|
625
1268
|
if (!file.statement)
|
|
626
1269
|
record.cleanup_status = 'cleaned';
|
|
627
1270
|
}
|
|
628
|
-
if (record.operation === 'release')
|
|
1271
|
+
if (record.operation === 'release' && !record.request_verified) {
|
|
1272
|
+
if (ns.relation_version === 0) {
|
|
1273
|
+
const authorizationId = this.cleanupEvidence(record, { agent_md_etag: ns.etag, agent_md_sha256: ownershipSha256(ns.content), owner_md_sha256: '' }, ns.content);
|
|
1274
|
+
if (!authorizationId)
|
|
1275
|
+
throw ownershipFailure('conflict', 'no local cleanup evidence can verify the release request');
|
|
1276
|
+
const authorization = this.load(authorizationId).record;
|
|
1277
|
+
const saved = authorization.files.agent;
|
|
1278
|
+
const statement = parseOwnershipFile(saved.raw, 'agent', { expectedSubAid: record.sub_aid }).statement;
|
|
1279
|
+
const etag = saved.published_etag ?? authorization.completion_evidence?.agent_md_etag;
|
|
1280
|
+
if (!etag)
|
|
1281
|
+
throw ownershipFailure('persistence_unavailable', 'historical approved agent.md ETag is missing');
|
|
1282
|
+
record.base_agent_md = { exists: true, raw: saved.raw, sha256: saved.hash, etag, statement };
|
|
1283
|
+
const owner = await this.deps.files.readOwner(record.owner_aid, record.sub_aid);
|
|
1284
|
+
if (owner.exists)
|
|
1285
|
+
await this.deps.files.verifyFile(owner.content, 'owner', record.sub_aid, record.owner_aid);
|
|
1286
|
+
record.base_owner_md = { exists: owner.exists, raw: owner.content, sha256: owner.exists ? ownershipSha256(owner.content) : '' };
|
|
1287
|
+
record.cleanup_authorization_id = authorizationId;
|
|
1288
|
+
if (!(await this.confirm(handle)))
|
|
1289
|
+
throw ownershipFailure('conflict', 'no local cleanup evidence can verify the release request');
|
|
1290
|
+
}
|
|
1291
|
+
else
|
|
1292
|
+
await this.captureBase(record, ns);
|
|
629
1293
|
record.request_verified = true;
|
|
1294
|
+
}
|
|
630
1295
|
record.inbox.shift();
|
|
631
1296
|
record.received.push(`${item.from}|${item.payload.operation_id}|${action}`);
|
|
632
1297
|
const request = record.request_verified && record.local_decision === 'pending' && record.operation !== 'release' && !record.request_notified;
|
|
@@ -645,7 +1310,8 @@ export class OwnershipWorkflow {
|
|
|
645
1310
|
const record = handle.record;
|
|
646
1311
|
const task = { operation_id: record.operation_id, operation: record.operation, sub_aid: record.sub_aid, owner_aid: record.owner_aid,
|
|
647
1312
|
new_owner_aid: record.new_owner_aid, base_relation_version: record.base_relation_version, target_relation_version: record.target_relation_version,
|
|
648
|
-
files: record.files, authorization_sha256: record.authorization_sha256, expires_at: record.expires_at,
|
|
1313
|
+
files: record.files, authorization_sha256: record.authorization_sha256, expires_at: record.expires_at, base_agent_md: record.base_agent_md, base_owner_md: record.base_owner_md,
|
|
1314
|
+
assertActive: () => this.assertActive(handle),
|
|
649
1315
|
persist: async () => {
|
|
650
1316
|
record.authorization_sha256 = task.authorization_sha256;
|
|
651
1317
|
record.expires_at = task.expires_at;
|
|
@@ -655,8 +1321,18 @@ export class OwnershipWorkflow {
|
|
|
655
1321
|
} };
|
|
656
1322
|
return task;
|
|
657
1323
|
}
|
|
1324
|
+
assertActive(handle) {
|
|
1325
|
+
const current = this.load(handle.record.operation_id);
|
|
1326
|
+
if (!current || this.isReset(current.record) || current.revision !== handle.revision)
|
|
1327
|
+
throw ownershipFailure('conflict', 'ownership task changed before external action');
|
|
1328
|
+
}
|
|
658
1329
|
async drive(handle) {
|
|
1330
|
+
if (this.isReset(handle.record))
|
|
1331
|
+
return;
|
|
1332
|
+
await this.upgradeLegacy(handle);
|
|
659
1333
|
const record = handle.record;
|
|
1334
|
+
if (!record.base_agent_md && record.request_verified)
|
|
1335
|
+
return;
|
|
660
1336
|
if (record.status === 'error' && record.retryable) {
|
|
661
1337
|
record.status = 'pending';
|
|
662
1338
|
await this.save(handle);
|
|
@@ -703,11 +1379,14 @@ export class OwnershipWorkflow {
|
|
|
703
1379
|
if (ns.relation_version !== record.base_relation_version || record.operation === 'bind' && ns.lock !== 'unlocked' || record.operation === 'transfer' && (ns.lock !== 'locked' || ns.owner_aid !== record.owner_aid)) {
|
|
704
1380
|
throw ownershipFailure('conflict', 'the original relation was replaced');
|
|
705
1381
|
}
|
|
1382
|
+
await this.assertBaseMaterial(handle, ns);
|
|
706
1383
|
if (record.expires_at !== undefined && record.expires_at <= Math.trunc(Date.now() / 1000))
|
|
707
1384
|
throw ownershipFailure('conflict', 'transfer authorization expired');
|
|
708
1385
|
const approved = participants(record).every((name) => record.approvals[name] === 'accept');
|
|
709
1386
|
if (record.operation === 'bind' && !approved || record.operation === 'transfer' && this.local() === record.owner_aid && !approved)
|
|
710
1387
|
return;
|
|
1388
|
+
if (record.legacy_recovery && this.local() === record.sub_aid && !record.legacy_recovery.owner_confirmed)
|
|
1389
|
+
return;
|
|
711
1390
|
delete record.code;
|
|
712
1391
|
delete record.reason;
|
|
713
1392
|
record.retryable = true;
|
|
@@ -722,8 +1401,11 @@ export class OwnershipWorkflow {
|
|
|
722
1401
|
}
|
|
723
1402
|
if (record.operation === 'transfer') {
|
|
724
1403
|
const original = await this.deps.files.readOwner(record.owner_aid, record.sub_aid);
|
|
725
|
-
if (original.exists && (await this.deps.files.verifyFile(original.content, 'owner', record.sub_aid, record.owner_aid)).action === 'declare')
|
|
1404
|
+
if (original.exists && (await this.deps.files.verifyFile(original.content, 'owner', record.sub_aid, record.owner_aid)).action === 'declare') {
|
|
1405
|
+
if (record.authorization_sha256 !== undefined)
|
|
1406
|
+
throw ownershipFailure('conflict', 'transfer authorization was withdrawn');
|
|
726
1407
|
return;
|
|
1408
|
+
}
|
|
727
1409
|
await this.deps.files.requireTransfer(this.fileTask(handle));
|
|
728
1410
|
}
|
|
729
1411
|
if (this.local() === targetOwner(record)) {
|
|
@@ -735,6 +1417,11 @@ export class OwnershipWorkflow {
|
|
|
735
1417
|
await this.flush(handle);
|
|
736
1418
|
}
|
|
737
1419
|
if (this.local() === record.sub_aid) {
|
|
1420
|
+
if (record.legacy_recovery && record.files.owner) {
|
|
1421
|
+
const current = await this.deps.files.readOwner(record.owner_aid, record.sub_aid), pinned = record.files.owner;
|
|
1422
|
+
if (!current.exists || current.content !== pinned.raw || current.sha256 !== pinned.hash)
|
|
1423
|
+
throw ownershipFailure('conflict', 'legacy owner declaration changed before subject publication');
|
|
1424
|
+
}
|
|
738
1425
|
record.step = 'agent';
|
|
739
1426
|
await this.save(handle);
|
|
740
1427
|
await this.deps.files.declareOwneraid(this.fileTask(handle));
|
|
@@ -753,16 +1440,67 @@ export class OwnershipWorkflow {
|
|
|
753
1440
|
const result = observed ?? await this.deps.files.checkConsensus(record.sub_aid, record.operation === 'release' ? undefined : { owner_aid: targetOwner(record), relation_version: record.target_relation_version });
|
|
754
1441
|
if (result.status === 'invalid' || result.status === 'unavailable')
|
|
755
1442
|
throw ownershipFailure(result.code ?? result.status, result.reason ?? 'ownership consensus cannot be verified', result.retryable);
|
|
756
|
-
|
|
1443
|
+
if (record.operation === 'release' && record.status === 'completed' && result.status === 'unbound' && result.relation_version === 0)
|
|
1444
|
+
return true;
|
|
1445
|
+
let cleaned = false;
|
|
1446
|
+
if (record.operation === 'release' && result.status === 'unbound' && result.relation_version === 0) {
|
|
1447
|
+
const began = Date.now();
|
|
1448
|
+
const ns = await this.readNs(record.sub_aid);
|
|
1449
|
+
if (!ns.exists || !ns.etag)
|
|
1450
|
+
throw ownershipFailure('conflict', 'current signed cleanup document is missing');
|
|
1451
|
+
if (ns.etag !== result.agent_md_etag || ownershipSha256(ns.content) !== result.agent_md_sha256)
|
|
1452
|
+
throw ownershipFailure('unavailable', 'agent.md changed during cleanup confirmation', true);
|
|
1453
|
+
if (record.local_roles.includes('owner') && record.files.release) {
|
|
1454
|
+
const source = record.files.release, owner = await this.deps.files.readOwner(record.owner_aid, record.sub_aid);
|
|
1455
|
+
if (owner.exists && owner.content === source.raw && owner.sha256 === source.hash) {
|
|
1456
|
+
await this.deps.files.verifyFile(owner.content, 'owner', record.sub_aid, record.owner_aid);
|
|
1457
|
+
const after = await this.readNs(record.sub_aid);
|
|
1458
|
+
if (after.etag !== ns.etag || after.content !== ns.content || after.relation_version !== 0 || after.lock !== 'unlocked')
|
|
1459
|
+
throw ownershipFailure('unavailable', 'agent.md changed during release confirmation', true);
|
|
1460
|
+
result.owner_md_sha256 = owner.sha256;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
const authorization = this.cleanupEvidence(record, result, ns.content);
|
|
1464
|
+
if (!authorization)
|
|
1465
|
+
throw ownershipFailure('conflict', 'no local cleanup original can confirm this release task');
|
|
1466
|
+
if (Date.now() - began > 30_000)
|
|
1467
|
+
throw ownershipFailure('unavailable', 'cleanup confirmation verification window elapsed', true);
|
|
1468
|
+
if (authorization !== record.operation_id)
|
|
1469
|
+
record.cleanup_authorization_id = authorization;
|
|
1470
|
+
record.local_decision = 'accept';
|
|
1471
|
+
record.approvals[this.local()] = 'accept';
|
|
1472
|
+
record.cleanup_status = 'cleaned';
|
|
1473
|
+
cleaned = true;
|
|
1474
|
+
}
|
|
1475
|
+
const reached = cleaned || result.relation_version === (record.operation === 'release' ? record.base_relation_version : record.target_relation_version) && (record.operation === 'release' ? result.status === 'released' && result.lock === 'unlocked' : result.status === 'consensus' && result.owner_aid === targetOwner(record));
|
|
757
1476
|
if (!reached)
|
|
758
1477
|
return false;
|
|
1478
|
+
if (record.operation === 'release' && !cleaned) {
|
|
1479
|
+
const ns = await this.readNs(record.sub_aid);
|
|
1480
|
+
const statement = parseOwnershipFile(ns.content, 'agent', { expectedSubAid: record.sub_aid }).statement;
|
|
1481
|
+
if (ns.etag !== result.agent_md_etag || ownershipSha256(ns.content) !== result.agent_md_sha256)
|
|
1482
|
+
throw ownershipFailure('unavailable', 'agent.md changed during release confirmation', true);
|
|
1483
|
+
if (statement?.owner_aid !== record.owner_aid || statement.relation_version !== record.base_relation_version)
|
|
1484
|
+
return false;
|
|
1485
|
+
if (record.base_agent_md)
|
|
1486
|
+
this.assertAgentBaseMaterial(record, ns);
|
|
1487
|
+
if (record.files.release && result.owner_md_sha256 && record.files.release.hash !== result.owner_md_sha256)
|
|
1488
|
+
throw ownershipFailure('conflict', 'current release material does not belong to this task');
|
|
1489
|
+
}
|
|
759
1490
|
if (record.operation === 'release' && record.local_roles.includes('owner') && !record.files.release)
|
|
760
1491
|
throw ownershipFailure('conflict', 'local release file evidence is missing');
|
|
761
1492
|
if (record.operation === 'transfer' && record.local_roles.includes('owner') && !record.files.transfer)
|
|
762
1493
|
throw ownershipFailure('conflict', 'local transfer file evidence is missing');
|
|
763
1494
|
if (record.operation === 'release' && this.local() === record.sub_aid && record.local_decision !== 'accept') {
|
|
1495
|
+
const authorization = await this.cleanupAuthorization(record);
|
|
1496
|
+
if (authorization) {
|
|
1497
|
+
record.local_decision = 'accept';
|
|
1498
|
+
record.approvals[this.local()] = 'accept';
|
|
1499
|
+
record.cleanup_authorization_id = authorization.operation_id;
|
|
1500
|
+
await this.save(handle);
|
|
1501
|
+
}
|
|
764
1502
|
const ns = await this.deps.files.readAgent(record.sub_aid);
|
|
765
|
-
if (parseOwnershipFile(ns.content, 'agent', { expectedSubAid: record.sub_aid }).statement) {
|
|
1503
|
+
if (!authorization && parseOwnershipFile(ns.content, 'agent', { expectedSubAid: record.sub_aid }).statement) {
|
|
766
1504
|
record.step = 'cleanup';
|
|
767
1505
|
if (record.cleanup_status !== 'action_required') {
|
|
768
1506
|
record.cleanup_status = 'action_required';
|
|
@@ -774,12 +1512,12 @@ export class OwnershipWorkflow {
|
|
|
774
1512
|
}
|
|
775
1513
|
if (record.operation !== 'release' && (record.local_decision !== 'accept' || this.local() === record.sub_aid && (!record.files.agent || record.files.agent.hash !== result.agent_md_sha256) || this.local() === targetOwner(record) && (!record.files.owner || record.files.owner.hash !== result.owner_md_sha256)))
|
|
776
1514
|
return false;
|
|
777
|
-
if (record.status !== 'completed' && record.operation !== 'release' &&
|
|
778
|
-
throw ownershipFailure('conflict', 'published
|
|
1515
|
+
if (record.status !== 'completed' && record.operation !== 'release' && this.local() === record.sub_aid && record.files.agent?.published_etag !== undefined && record.files.agent.published_etag !== result.agent_md_etag)
|
|
1516
|
+
throw ownershipFailure('conflict', 'published agent.md ETag changed');
|
|
779
1517
|
if (record.status !== 'completed') {
|
|
780
1518
|
record.status = 'completed';
|
|
781
1519
|
record.step = 'confirmed';
|
|
782
|
-
record.verified_relation_version =
|
|
1520
|
+
record.verified_relation_version = record.target_relation_version;
|
|
783
1521
|
record.completion_evidence = { ...result };
|
|
784
1522
|
delete record.code;
|
|
785
1523
|
delete record.reason;
|
|
@@ -794,15 +1532,58 @@ export class OwnershipWorkflow {
|
|
|
794
1532
|
}
|
|
795
1533
|
return true;
|
|
796
1534
|
}
|
|
1535
|
+
cleanupEvidence(record, evidence, raw) {
|
|
1536
|
+
const release = record.files.release;
|
|
1537
|
+
if (record.local_roles.includes('owner') && release && release.hash === evidence.owner_md_sha256)
|
|
1538
|
+
return record.operation_id;
|
|
1539
|
+
if (!record.local_roles.includes('sub'))
|
|
1540
|
+
return undefined;
|
|
1541
|
+
const matches = (saved) => Boolean(saved && saved.hash === evidence.agent_md_sha256 && saved.hash === ownershipSha256(saved.raw) && (saved.published_etag === undefined || saved.published_etag === evidence.agent_md_etag) && (raw === undefined || raw === saved.raw));
|
|
1542
|
+
if (matches(record.files.agent))
|
|
1543
|
+
return record.operation_id;
|
|
1544
|
+
for (const snapshot of this.list(true)) {
|
|
1545
|
+
if (!['bind', 'transfer'].includes(String(snapshot.task.operation)) || record.cleanup_authorization_id && snapshot.operation_id !== record.cleanup_authorization_id)
|
|
1546
|
+
continue;
|
|
1547
|
+
let candidate;
|
|
1548
|
+
try {
|
|
1549
|
+
candidate = this.decode(snapshot).record;
|
|
1550
|
+
}
|
|
1551
|
+
catch (error) {
|
|
1552
|
+
if (snapshot.task.sub_aid === record.sub_aid)
|
|
1553
|
+
throw error;
|
|
1554
|
+
else
|
|
1555
|
+
continue;
|
|
1556
|
+
}
|
|
1557
|
+
if (candidate.local_decision !== 'accept' || !candidate.local_roles.includes('sub') || candidate.sub_aid !== record.sub_aid || targetOwner(candidate) !== record.owner_aid || candidate.target_relation_version !== record.base_relation_version || !matches(candidate.release_cleanup?.files.agent))
|
|
1558
|
+
continue;
|
|
1559
|
+
const statement = candidate.files.agent && parseOwnershipFile(candidate.files.agent.raw, 'agent', { expectedSubAid: record.sub_aid }).statement;
|
|
1560
|
+
if (statement?.owner_aid === record.owner_aid && statement.relation_version === record.base_relation_version && (!record.base_agent_md?.statement || Object.keys(record.base_agent_md.statement).every((field) => record.base_agent_md.statement[field] === statement[field])))
|
|
1561
|
+
return candidate.operation_id;
|
|
1562
|
+
}
|
|
1563
|
+
return undefined;
|
|
1564
|
+
}
|
|
797
1565
|
async resumeSubCleanup(handle, observed) {
|
|
798
1566
|
const record = handle.record;
|
|
799
|
-
if (!['bind', 'transfer'].includes(record.operation) || !record.local_roles.includes('sub') || record.local_decision !== 'accept' ||
|
|
1567
|
+
if (!['bind', 'transfer'].includes(record.operation) || !record.local_roles.includes('sub') || record.local_decision !== 'accept' || record.release_cleanup?.cleaned)
|
|
1568
|
+
return;
|
|
1569
|
+
if (record.release_cleanup && observed.status === 'unbound' && observed.relation_version === 0) {
|
|
1570
|
+
const saved = record.release_cleanup.files.agent;
|
|
1571
|
+
const ns = await this.readNs(record.sub_aid);
|
|
1572
|
+
if (!saved || saved.raw !== ns.content || saved.hash !== observed.agent_md_sha256 || saved.hash !== ownershipSha256(ns.content) || ns.etag !== observed.agent_md_etag || saved.published_etag !== undefined && saved.published_etag !== ns.etag)
|
|
1573
|
+
throw ownershipFailure('conflict', 'historical cleanup original does not match current material');
|
|
1574
|
+
saved.published_etag = ns.etag;
|
|
1575
|
+
record.release_cleanup.cleaned = true;
|
|
1576
|
+
await this.save(handle);
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
if (observed.status !== 'released' || observed.relation_version !== record.target_relation_version)
|
|
800
1580
|
return;
|
|
801
1581
|
const ns = await this.readNs(record.sub_aid);
|
|
802
1582
|
if (!ns.exists || ns.lock !== 'unlocked' || ns.relation_version !== observed.relation_version || ns.etag !== observed.agent_md_etag || ownershipSha256(ns.content) !== observed.agent_md_sha256)
|
|
803
1583
|
throw ownershipFailure('unavailable', 'NS changed during historical cleanup observation', true);
|
|
804
1584
|
const statement = parseOwnershipFile(ns.content, 'agent', { expectedSubAid: record.sub_aid }).statement;
|
|
805
|
-
|
|
1585
|
+
const original = record.files.agent && parseOwnershipFile(record.files.agent.raw, 'agent', { expectedSubAid: record.sub_aid }).statement;
|
|
1586
|
+
if (!statement || !original || Object.keys(original).some((field) => original[field] !== statement[field]))
|
|
806
1587
|
throw ownershipFailure('conflict', 'historical cleanup fields belong to another relation');
|
|
807
1588
|
if (!record.release_cleanup) {
|
|
808
1589
|
record.release_cleanup = { files: {}, cleaned: false };
|
|
@@ -810,16 +1591,26 @@ export class OwnershipWorkflow {
|
|
|
810
1591
|
}
|
|
811
1592
|
await this.deps.files.revokeOwneraid({ operation: 'release', sub_aid: record.sub_aid, owner_aid: targetOwner(record),
|
|
812
1593
|
base_relation_version: record.target_relation_version, target_relation_version: observed.relation_version,
|
|
813
|
-
files: record.release_cleanup.files, persist: () => this.save(handle) });
|
|
1594
|
+
files: record.release_cleanup.files, expected_agent_statement: original, assertActive: () => this.assertActive(handle), persist: () => this.save(handle) });
|
|
814
1595
|
record.release_cleanup.cleaned = true;
|
|
815
1596
|
await this.save(handle);
|
|
816
1597
|
this.emit(record, 'ownership.progress');
|
|
817
1598
|
}
|
|
818
|
-
cleanupAuthorization(task) {
|
|
819
|
-
|
|
1599
|
+
async cleanupAuthorization(task) {
|
|
1600
|
+
const ns = await this.readNs(task.sub_aid);
|
|
1601
|
+
const statement = ns.exists ? parseOwnershipFile(ns.content, 'agent', { expectedSubAid: task.sub_aid }).statement : undefined;
|
|
1602
|
+
if (!statement)
|
|
1603
|
+
return undefined;
|
|
1604
|
+
for (const snapshot of this.list(true)) {
|
|
1605
|
+
if (snapshot.task.sub_aid !== task.sub_aid)
|
|
1606
|
+
continue;
|
|
820
1607
|
const candidate = this.decode(snapshot).record;
|
|
821
|
-
if (['bind', 'transfer'].includes(candidate.operation) && candidate.local_decision === 'accept' && candidate.local_roles.includes('sub') && candidate.sub_aid === task.sub_aid && targetOwner(candidate) === task.owner_aid && candidate.target_relation_version === task.base_relation_version)
|
|
822
|
-
|
|
1608
|
+
if (['bind', 'transfer'].includes(candidate.operation) && candidate.local_decision === 'accept' && candidate.local_roles.includes('sub') && candidate.sub_aid === task.sub_aid && targetOwner(candidate) === task.owner_aid && candidate.target_relation_version === task.base_relation_version && candidate.files.agent) {
|
|
1609
|
+
const original = parseOwnershipFile(candidate.files.agent.raw, 'agent', { expectedSubAid: task.sub_aid }).statement;
|
|
1610
|
+
const fixed = task.base_agent_md?.statement;
|
|
1611
|
+
if (original && Object.keys(original).every((field) => original[field] === statement[field]) && (!fixed || Object.keys(fixed).every((field) => fixed[field] === original[field])))
|
|
1612
|
+
return candidate;
|
|
1613
|
+
}
|
|
823
1614
|
}
|
|
824
1615
|
return undefined;
|
|
825
1616
|
}
|
|
@@ -829,12 +1620,14 @@ export class OwnershipWorkflow {
|
|
|
829
1620
|
return;
|
|
830
1621
|
if (record.operation === 'release' && this.local() === record.sub_aid && record.cleanup_status !== 'cleaned') {
|
|
831
1622
|
const ns = await this.readNs(record.sub_aid);
|
|
832
|
-
if (ns.exists && ns.lock === 'unlocked' && ns.relation_version ===
|
|
1623
|
+
if (ns.exists && ns.lock === 'unlocked' && ns.relation_version === 0 && !parseOwnershipFile(ns.content, 'agent', { expectedSubAid: record.sub_aid }).statement) {
|
|
1624
|
+
if (!this.cleanupEvidence(record, { agent_md_sha256: ownershipSha256(ns.content), agent_md_etag: ns.etag, owner_md_sha256: '' }, ns.content))
|
|
1625
|
+
throw ownershipFailure('conflict', 'current cleanup does not match a locally saved original');
|
|
833
1626
|
record.cleanup_status = 'cleaned';
|
|
834
1627
|
await this.save(handle);
|
|
835
1628
|
}
|
|
836
1629
|
else {
|
|
837
|
-
const authorization = this.cleanupAuthorization(record);
|
|
1630
|
+
const authorization = await this.cleanupAuthorization(record);
|
|
838
1631
|
if (!authorization || record.cleanup_authorization_id && authorization.operation_id !== record.cleanup_authorization_id) {
|
|
839
1632
|
if (record.cleanup_status !== 'action_required') {
|
|
840
1633
|
record.cleanup_status = 'action_required';
|
|
@@ -845,7 +1638,9 @@ export class OwnershipWorkflow {
|
|
|
845
1638
|
else {
|
|
846
1639
|
record.cleanup_authorization_id = authorization.operation_id;
|
|
847
1640
|
await this.save(handle);
|
|
848
|
-
|
|
1641
|
+
const task = this.fileTask(handle);
|
|
1642
|
+
task.expected_agent_statement = parseOwnershipFile(authorization.files.agent.raw, 'agent', { expectedSubAid: record.sub_aid }).statement;
|
|
1643
|
+
await this.deps.files.revokeOwneraid(task);
|
|
849
1644
|
record.cleanup_status = 'cleaned';
|
|
850
1645
|
this.enqueue(record, record.owner_aid, 'sub.cleaned');
|
|
851
1646
|
await this.save(handle);
|
|
@@ -859,8 +1654,8 @@ export class OwnershipWorkflow {
|
|
|
859
1654
|
const payload = { ...ownershipTaskResult(record), local_roles: [...record.local_roles], ...extra };
|
|
860
1655
|
if (event === 'ownership.request')
|
|
861
1656
|
payload.requester_aid = record.initiator_aid;
|
|
862
|
-
if (record.
|
|
863
|
-
payload.relation_version = record.
|
|
1657
|
+
if (record.completion_evidence !== undefined)
|
|
1658
|
+
payload.relation_version = record.completion_evidence.relation_version;
|
|
864
1659
|
queueMicrotask(() => { try {
|
|
865
1660
|
const pending = this.deps.emit(event, payload);
|
|
866
1661
|
if (pending)
|