@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.
Files changed (129) hide show
  1. package/CHANGELOG.md +176 -78
  2. package/_packed_docs/CHANGELOG.md +176 -78
  3. package/_packed_docs/INDEX.md +288 -269
  4. package/_packed_docs/KITE_DOCS_GUIDE.md +101 -95
  5. package/_packed_docs/agent.md/SCHEMA.md +7 -7
  6. 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
  7. 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
  8. 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
  9. 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
  10. 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
  11. package/_packed_docs/protocol/06-/346/234/215/345/212/241/345/215/217/350/256/256.md +372 -331
  12. package/_packed_docs/protocol/08-AUN-E2EE.md +17 -12
  13. package/_packed_docs/protocol/10-Group-/345/255/220/345/215/217/350/256/256.md +676 -676
  14. package/_packed_docs/protocol/11-Storage-/345/255/220/345/215/217/350/256/256.md +8 -12
  15. package/_packed_docs/protocol/Ownership/345/215/217/350/256/256/345/256/232/344/271/211.md +445 -0
  16. package/_packed_docs/protocol/Ownership/345/215/217/350/256/256/345/256/232/344/271/2112.0.md +161 -0
  17. package/_packed_docs/protocol/Ownership/345/215/217/350/256/256/345/256/232/344/271/2112.1.md +666 -0
  18. package/_packed_docs/protocol/Ownership/345/215/217/350/256/256/345/256/232/344/271/2113.0.md +233 -0
  19. package/_packed_docs/protocol/agent.md/SCHEMA.md +1 -1
  20. 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
  21. 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
  22. package/_packed_docs/sdk/01-/345/277/253/351/200/237/345/274/200/345/247/213.md +1 -1
  23. package/_packed_docs/sdk/02-WebSocket/345/215/217/350/256/256.md +5 -1
  24. package/_packed_docs/sdk/03-/346/240/270/345/277/203/346/246/202/345/277/265.md +64 -64
  25. package/_packed_docs/sdk/04-/350/277/236/346/216/245/344/270/216/350/256/244/350/257/201.md +70 -63
  26. package/_packed_docs/sdk/05-E2EE/345/212/240/345/257/206/351/200/232/344/277/241.md +43 -40
  27. package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +179 -19
  28. package/_packed_docs/sdk/08-/346/234/200/344/275/263/345/256/236/350/267/265.md +50 -49
  29. package/_packed_docs/sdk/09-group-rpc-manual.md +261 -254
  30. package/_packed_docs/sdk/09-message-rpc-manual.md +253 -160
  31. package/_packed_docs/sdk/09-storage-rpc-manual.md +18 -26
  32. 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
  33. 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
  34. package/_packed_docs/sdk/AUN_DOCS_GUIDE.md +105 -92
  35. package/_packed_docs/sdk/INDEX.md +197 -167
  36. package/_packed_docs/sdk/Ownership-SDK/345/256/236/347/216/260/346/214/207/345/215/227.md +169 -0
  37. package/_packed_docs/sdk/Ownership-SDK/347/274/226/346/216/222/346/214/207/345/215/227.md +349 -0
  38. package/_packed_docs/sdk/Ownership-SDK/347/274/226/346/216/222/346/214/207/345/215/2272.1.md +930 -0
  39. package/_packed_docs/sdk/Ownership-SDK/351/233/206/346/210/220/346/214/207/345/215/227.md +270 -0
  40. 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
  41. 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
  42. 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
  43. package/_packed_docs/sdk/Ownership2.1/350/257/246/347/273/206/350/256/276/350/256/241.md +480 -0
  44. package/_packed_docs/sdk/Ownership/347/256/241/347/220/206/344/270/223/351/242/230.md +559 -0
  45. package/_packed_docs/sdk/ProtectedHeaders/347/224/250/346/263/225/344/270/223/351/242/230.md +299 -0
  46. 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
  47. 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
  48. 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
  49. 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
  50. 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
  51. 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
  52. 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
  53. 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
  54. package/dist/agent-md-schema.d.ts +13 -1
  55. package/dist/agent-md-schema.js +63 -33
  56. package/dist/agent-md-schema.js.map +1 -1
  57. package/dist/agent-md.d.ts +30 -3
  58. package/dist/agent-md.js +293 -152
  59. package/dist/agent-md.js.map +1 -1
  60. package/dist/aid-store.d.ts +42 -0
  61. package/dist/aid-store.js +224 -44
  62. package/dist/aid-store.js.map +1 -1
  63. package/dist/aid.js +18 -13
  64. package/dist/aid.js.map +1 -1
  65. package/dist/auth.js +78 -2
  66. package/dist/auth.js.map +1 -1
  67. package/dist/cert-utils.d.ts +44 -1
  68. package/dist/cert-utils.js +157 -56
  69. package/dist/cert-utils.js.map +1 -1
  70. package/dist/client/delivery.d.ts +68 -1
  71. package/dist/client/delivery.js +1196 -135
  72. package/dist/client/delivery.js.map +1 -1
  73. package/dist/client/rpc-pipeline.d.ts +5 -1
  74. package/dist/client/rpc-pipeline.js +240 -111
  75. package/dist/client/rpc-pipeline.js.map +1 -1
  76. package/dist/client/v2-e2ee.d.ts +12 -2
  77. package/dist/client/v2-e2ee.js +842 -379
  78. package/dist/client/v2-e2ee.js.map +1 -1
  79. package/dist/client.d.ts +26 -2
  80. package/dist/client.js +420 -75
  81. package/dist/client.js.map +1 -1
  82. package/dist/config.d.ts +4 -0
  83. package/dist/config.js +1 -0
  84. package/dist/config.js.map +1 -1
  85. package/dist/events.d.ts +23 -4
  86. package/dist/events.js +171 -15
  87. package/dist/events.js.map +1 -1
  88. package/dist/facades.d.ts +43 -1
  89. package/dist/facades.js +368 -12
  90. package/dist/facades.js.map +1 -1
  91. package/dist/index.d.ts +7 -4
  92. package/dist/index.js +1 -1
  93. package/dist/index.js.map +1 -1
  94. package/dist/keystore/aid-db.d.ts +7 -2
  95. package/dist/keystore/aid-db.js +194 -43
  96. package/dist/keystore/aid-db.js.map +1 -1
  97. package/dist/keystore/index.d.ts +15 -1
  98. package/dist/keystore/local-token-store.d.ts +10 -2
  99. package/dist/keystore/local-token-store.js +81 -16
  100. package/dist/keystore/local-token-store.js.map +1 -1
  101. package/dist/org-protocol.d.ts +45 -0
  102. package/dist/org-protocol.js +829 -22
  103. package/dist/org-protocol.js.map +1 -1
  104. package/dist/ownership-v21.d.ts +40 -9
  105. package/dist/ownership-v21.js +174 -79
  106. package/dist/ownership-v21.js.map +1 -1
  107. package/dist/ownership-workflow.d.ts +22 -0
  108. package/dist/ownership-workflow.js +861 -66
  109. package/dist/ownership-workflow.js.map +1 -1
  110. package/dist/secret-store/file-store.js +84 -0
  111. package/dist/secret-store/file-store.js.map +1 -1
  112. package/dist/seq-tracker.d.ts +1 -0
  113. package/dist/seq-tracker.js +2 -2
  114. package/dist/seq-tracker.js.map +1 -1
  115. package/dist/tools/cross-sdk-agent.d.ts +67 -3
  116. package/dist/tools/cross-sdk-agent.js +575 -42
  117. package/dist/tools/cross-sdk-agent.js.map +1 -1
  118. package/dist/transport.d.ts +4 -0
  119. package/dist/transport.js +108 -44
  120. package/dist/transport.js.map +1 -1
  121. package/dist/types.d.ts +1 -0
  122. package/dist/v2/e2ee/encrypt-group.js +1 -0
  123. package/dist/v2/e2ee/encrypt-group.js.map +1 -1
  124. package/dist/v2/e2ee/encrypt-p2p.js +1 -0
  125. package/dist/v2/e2ee/encrypt-p2p.js.map +1 -1
  126. package/dist/v2/e2ee/types.d.ts +1 -0
  127. package/dist/version.d.ts +1 -1
  128. package/dist/version.js +1 -1
  129. 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
- if (ownershipInteger(message.target_relation_version, 'target_relation_version') !== message.base_relation_version + 1)
47
- throw new ValidationError('ownership target version must be base plus one');
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 = ns.relation_version === message.target_relation_version && (message.operation === 'release' ? ns.lock === 'unlocked' && !ns.owner_aid : ns.lock === 'locked' && ns.owner_aid === targetOwner(message));
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 : this.load(String(body.operation_id))?.record });
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: handle.record });
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 !== message.target_relation_version || before.lock !== 'unlocked' || before.owner_aid || !before.etag)
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
- for (const snapshot of this.list()) {
257
- const record = this.decode(snapshot).record;
258
- await this.runSerialized(record.sub_aid, async () => {
259
- const handle = this.load(record.operation_id);
260
- await this.drive(handle);
261
- if (handle.record.request_verified && handle.record.local_decision === 'pending' && handle.record.status === 'pending' && handle.record.operation !== 'release')
262
- this.emit(handle.record, 'ownership.request');
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 (record.protocol !== '2.1' || !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')
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
- if (ownershipInteger(record.target_relation_version, 'target_relation_version') !== ownershipInteger(record.base_relation_version, 'base_relation_version', 0) + 1 || !participants(record).includes(record.initiator_aid) || record.operation !== 'bind' && record.initiator_aid !== record.owner_aid)
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.published_revision !== undefined && (typeof saved.published_revision !== 'string' || !saved.published_revision))
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' ? !(typeof saved.condition.if_match === 'string' && saved.condition.if_match || saved.condition.if_none_match === '*') : !(typeof saved.condition.expected_revision === 'string' && saved.condition.expected_revision))
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
- if (!evidence || evidence.sub_aid !== record.sub_aid || evidence.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_revision !== 'string' || typeof evidence.owner_md_sha256 !== 'string' || record.operation !== 'release' && (!evidence.owner_md_revision || !/^[0-9a-f]{64}$/.test(evidence.owner_md_sha256)))
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
- throw ownershipFailure('persistence_unavailable', error instanceof Error ? error.message : String(error));
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
- const active = this.decode(snapshot), record = active.record;
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
- 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);
449
- const target = ns.relation_version === record.target_relation_version && (record.operation === 'release' ? ns.lock === 'unlocked' && !ns.owner_aid : ns.lock === 'locked' && ns.owner_aid === targetOwner(record));
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('unavailable', 'initial agent.md is not published', true);
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: ns.relation_version + 1 }, this.local(), 'accept');
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
- const ns = await this.readNs(handle.record.sub_aid);
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 = ns.relation_version === record.target_relation_version && (record.operation === 'release' ? ns.lock === 'unlocked' && !ns.owner_aid : ns.lock === 'locked' && ns.owner_aid === targetOwner(record));
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 && !(ns.lock === 'unlocked' && ns.relation_version > 0) ? await this.deps.files.verifyFile(ns.content, 'agent', subAid) : undefined;
571
- if (ns.lock === 'unlocked' && ns.relation_version === 0 && file?.statement)
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 (['bind.request', 'transfer.request'].includes(action))
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
- const reached = result.relation_version === record.target_relation_version && (record.operation === 'release' ? result.status === 'released' && result.lock === 'unlocked' : result.status === 'consensus' && result.owner_aid === targetOwner(record));
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' && (this.local() === record.sub_aid && record.files.agent?.published_revision !== undefined && record.files.agent.published_revision !== result.agent_md_etag || this.local() === targetOwner(record) && record.files.owner?.published_revision !== undefined && record.files.owner.published_revision !== result.owner_md_revision))
778
- throw ownershipFailure('conflict', 'published ownership file revision changed');
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 = result.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' || observed.status !== 'released' || observed.relation_version !== record.target_relation_version + 1 || record.release_cleanup?.cleaned)
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
- if (statement && (statement.owner_aid !== targetOwner(record) || statement.relation_version !== record.target_relation_version))
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
- for (const snapshot of this.list()) {
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
- return candidate;
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 === record.target_relation_version && !parseOwnershipFile(ns.content, 'agent', { expectedSubAid: record.sub_aid }).statement) {
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
- await this.deps.files.revokeOwneraid(this.fileTask(handle));
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.verified_relation_version !== undefined)
863
- payload.relation_version = record.verified_relation_version;
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)