@agentunion/fastaun 0.5.19 → 0.5.20

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 (99) hide show
  1. package/CHANGELOG.md +129 -78
  2. package/_packed_docs/CHANGELOG.md +129 -78
  3. package/_packed_docs/INDEX.md +201 -195
  4. package/_packed_docs/KITE_DOCS_GUIDE.md +93 -90
  5. package/_packed_docs/agent.md/SCHEMA.md +7 -7
  6. 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
  7. package/_packed_docs/protocol/06-/346/234/215/345/212/241/345/215/217/350/256/256.md +1072 -1072
  8. package/_packed_docs/protocol/10-Group-/345/255/220/345/215/217/350/256/256.md +676 -676
  9. package/_packed_docs/protocol/11-Storage-/345/255/220/345/215/217/350/256/256.md +8 -12
  10. package/_packed_docs/protocol/Ownership/345/215/217/350/256/256/345/256/232/344/271/211.md +443 -0
  11. package/_packed_docs/protocol/Ownership/345/215/217/350/256/256/345/256/232/344/271/2112.0.md +161 -0
  12. package/_packed_docs/protocol/Ownership/345/215/217/350/256/256/345/256/232/344/271/2112.1.md +658 -0
  13. package/_packed_docs/protocol/Ownership/345/215/217/350/256/256/345/256/232/344/271/2113.0.md +233 -0
  14. package/_packed_docs/protocol/agent.md/SCHEMA.md +1 -1
  15. package/_packed_docs/sdk/03-/346/240/270/345/277/203/346/246/202/345/277/265.md +1 -1
  16. package/_packed_docs/sdk/04-/350/277/236/346/216/245/344/270/216/350/256/244/350/257/201.md +67 -63
  17. package/_packed_docs/sdk/05-E2EE/345/212/240/345/257/206/351/200/232/344/277/241.md +1 -1
  18. package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +35 -8
  19. package/_packed_docs/sdk/08-/346/234/200/344/275/263/345/256/236/350/267/265.md +3 -3
  20. package/_packed_docs/sdk/09-group-rpc-manual.md +258 -254
  21. package/_packed_docs/sdk/09-message-rpc-manual.md +7 -4
  22. package/_packed_docs/sdk/09-storage-rpc-manual.md +18 -26
  23. package/_packed_docs/sdk/AUN_DOCS_GUIDE.md +61 -52
  24. package/_packed_docs/sdk/INDEX.md +92 -80
  25. package/_packed_docs/sdk/Ownership-SDK/345/256/236/347/216/260/346/214/207/345/215/227.md +169 -0
  26. package/_packed_docs/sdk/Ownership-SDK/347/274/226/346/216/222/346/214/207/345/215/227.md +349 -0
  27. package/_packed_docs/sdk/Ownership-SDK/347/274/226/346/216/222/346/214/207/345/215/2272.1.md +922 -0
  28. package/_packed_docs/sdk/Ownership-SDK/351/233/206/346/210/220/346/214/207/345/215/227.md +270 -0
  29. 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 +300 -0
  30. 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 +382 -0
  31. 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
  32. package/_packed_docs/sdk/Ownership2.1/350/257/246/347/273/206/350/256/276/350/256/241.md +476 -0
  33. package/_packed_docs/sdk/Ownership/347/256/241/347/220/206/344/270/223/351/242/230.md +559 -0
  34. package/_packed_docs/sdk/ProtectedHeaders/347/224/250/346/263/225/344/270/223/351/242/230.md +299 -0
  35. 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
  36. 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
  37. 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
  38. 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
  39. 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
  40. 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
  41. 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
  42. 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
  43. package/dist/agent-md-schema.d.ts +13 -1
  44. package/dist/agent-md-schema.js +61 -33
  45. package/dist/agent-md-schema.js.map +1 -1
  46. package/dist/agent-md.d.ts +8 -1
  47. package/dist/agent-md.js +243 -119
  48. package/dist/agent-md.js.map +1 -1
  49. package/dist/aid-store.d.ts +29 -0
  50. package/dist/aid-store.js +179 -34
  51. package/dist/aid-store.js.map +1 -1
  52. package/dist/aid.js +18 -13
  53. package/dist/aid.js.map +1 -1
  54. package/dist/auth.js +74 -2
  55. package/dist/auth.js.map +1 -1
  56. package/dist/cert-utils.d.ts +43 -1
  57. package/dist/cert-utils.js +144 -56
  58. package/dist/cert-utils.js.map +1 -1
  59. package/dist/client/delivery.d.ts +9 -0
  60. package/dist/client/delivery.js +179 -11
  61. package/dist/client/delivery.js.map +1 -1
  62. package/dist/client/rpc-pipeline.d.ts +1 -0
  63. package/dist/client/rpc-pipeline.js +125 -82
  64. package/dist/client/rpc-pipeline.js.map +1 -1
  65. package/dist/client/v2-e2ee.js +95 -89
  66. package/dist/client/v2-e2ee.js.map +1 -1
  67. package/dist/client.js +48 -19
  68. package/dist/client.js.map +1 -1
  69. package/dist/events.d.ts +5 -3
  70. package/dist/events.js +25 -8
  71. package/dist/events.js.map +1 -1
  72. package/dist/facades.js +5 -5
  73. package/dist/facades.js.map +1 -1
  74. package/dist/index.d.ts +2 -1
  75. package/dist/index.js.map +1 -1
  76. package/dist/keystore/aid-db.d.ts +1 -1
  77. package/dist/keystore/aid-db.js +11 -2
  78. package/dist/keystore/aid-db.js.map +1 -1
  79. package/dist/keystore/index.d.ts +1 -1
  80. package/dist/keystore/local-token-store.d.ts +1 -1
  81. package/dist/keystore/local-token-store.js +2 -2
  82. package/dist/keystore/local-token-store.js.map +1 -1
  83. package/dist/org-protocol.js +77 -13
  84. package/dist/org-protocol.js.map +1 -1
  85. package/dist/ownership-v21.d.ts +26 -8
  86. package/dist/ownership-v21.js +147 -70
  87. package/dist/ownership-v21.js.map +1 -1
  88. package/dist/ownership-workflow.d.ts +5 -0
  89. package/dist/ownership-workflow.js +366 -56
  90. package/dist/ownership-workflow.js.map +1 -1
  91. package/dist/tools/cross-sdk-agent.d.ts +2 -1
  92. package/dist/tools/cross-sdk-agent.js +109 -6
  93. package/dist/tools/cross-sdk-agent.js.map +1 -1
  94. package/dist/transport.d.ts +1 -0
  95. package/dist/transport.js +10 -0
  96. package/dist/transport.js.map +1 -1
  97. package/dist/version.d.ts +1 -1
  98. package/dist/version.js +1 -1
  99. package/package.json +1 -1
@@ -36,15 +36,15 @@ export function parseOwnershipMessage(content, context) {
36
36
  throw new ValidationError('ownership message contains unknown or missing fields');
37
37
  if (message.type !== OWNERSHIP_SDK_MESSAGE_TYPE)
38
38
  throw ownershipFailure('unsupported_protocol', 'legacy ownership control is unsupported');
39
- if (!OWNERSHIP_UUID_V4_RE.test(message.operation_id))
39
+ if (typeof message.operation_id !== 'string' || !OWNERSHIP_UUID_V4_RE.test(message.operation_id))
40
40
  throw new ValidationError('operation_id must be a UUIDv4');
41
41
  if (!['bind', 'release', 'transfer'].includes(message.operation))
42
42
  throw new ValidationError('ownership operation is invalid');
43
43
  ownershipAid(message.sub_aid, 'sub_aid');
44
44
  ownershipAid(message.owner_aid, 'owner_aid');
45
45
  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');
46
+ if (ownershipInteger(message.target_relation_version, 'target_relation_version') !== message.base_relation_version + (message.operation === 'release' ? 0 : 1))
47
+ throw new ValidationError('ownership target version does not match the operation');
48
48
  if (message.operation === 'transfer') {
49
49
  if (ownershipAid(message.new_owner_aid, 'new_owner_aid') === message.owner_aid)
50
50
  throw new ValidationError('transfer target must differ from current owner');
@@ -107,7 +107,7 @@ export function parseOwnershipMessage(content, context) {
107
107
  if (ns.protocol !== '2.1')
108
108
  throw ownershipFailure('unsupported_protocol', 'NS ownership protocol is unavailable');
109
109
  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));
110
+ const target = message.operation === 'release' ? ns.lock === 'unlocked' && !ns.owner_aid && (ns.relation_version === message.target_relation_version || ns.relation_version === 0) : ns.relation_version === message.target_relation_version && ns.lock === 'locked' && ns.owner_aid === targetOwner(message);
111
111
  if (['relation.confirmed', 'sub.cleaned'].includes(message.action) ? !target : !base && !target)
112
112
  throw ownershipFailure('conflict', 'ownership current NS relation does not match this task');
113
113
  }
@@ -154,7 +154,7 @@ export class OwnershipWorkflow {
154
154
  return this.start('transfer', ownershipAid(subAid, 'sub_aid'), this.local(), ownershipAid(newOwnerAid, 'new_owner_aid'));
155
155
  }
156
156
  async resumeRegistration(ownerAid, operationId) {
157
- if (!OWNERSHIP_UUID_V4_RE.test(operationId))
157
+ if (typeof operationId !== 'string' || !OWNERSHIP_UUID_V4_RE.test(operationId))
158
158
  throw ownershipFailure('invalid', 'registration operation_id is invalid');
159
159
  return this.start('bind', this.local(), ownershipAid(ownerAid, 'owner_aid'), undefined, operationId);
160
160
  }
@@ -220,21 +220,13 @@ export class OwnershipWorkflow {
220
220
  if (!['bind.request', 'transfer.request'].includes(message.action) && !(message.operation === 'release' && message.action === 'relation.confirmed'))
221
221
  return true;
222
222
  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
223
  }
232
224
  if (message.action === 'sub.cleaned') {
233
225
  const began = Date.now();
234
226
  const before = await this.readNs(message.sub_aid), after = await this.readNs(message.sub_aid);
235
227
  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
228
  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)
229
+ if (!before.exists || before.relation_version !== 0 || before.lock !== 'unlocked' || before.owner_aid || !before.etag)
238
230
  return true;
239
231
  const file = parseOwnershipFile(before.content, 'agent', { expectedSubAid: message.sub_aid });
240
232
  if (Object.hasOwn(file.frontmatter, 'owner') || Object.hasOwn(file.frontmatter, 'ownership'))
@@ -253,15 +245,23 @@ export class OwnershipWorkflow {
253
245
  });
254
246
  }
255
247
  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
- });
248
+ const failed = [];
249
+ for (const snapshot of this.list(true)) {
250
+ try {
251
+ const record = this.decode(snapshot).record;
252
+ await this.runSerialized(record.sub_aid, async () => {
253
+ const handle = this.load(record.operation_id);
254
+ await this.drive(handle);
255
+ if (handle.record.request_verified && handle.record.local_decision === 'pending' && handle.record.status === 'pending' && handle.record.operation !== 'release')
256
+ this.emit(handle.record, 'ownership.request');
257
+ });
258
+ }
259
+ catch (error) {
260
+ failed.push(`${snapshot.operation_id}: ${error instanceof Error ? error.message : String(error)}`);
261
+ }
264
262
  }
263
+ if (failed.length)
264
+ throw ownershipFailure('persistence_unavailable', failed.join('; '));
265
265
  }
266
266
  local() { return ownershipAid(this.deps.currentAid(), 'local_aid'); }
267
267
  requireApplicable(subAid) {
@@ -273,9 +273,9 @@ export class OwnershipWorkflow {
273
273
  throw ownershipFailure('persistence_unavailable', 'atomic ownership task storage is unavailable');
274
274
  return this.deps.store;
275
275
  }
276
- list() {
276
+ list(isolateErrors = false) {
277
277
  try {
278
- return this.store().listOwnershipTasks(this.local());
278
+ return this.store().listOwnershipTasks(this.local(), isolateErrors);
279
279
  }
280
280
  catch (error) {
281
281
  throw ownershipFailure('persistence_unavailable', error instanceof Error ? error.message : String(error));
@@ -293,7 +293,7 @@ export class OwnershipWorkflow {
293
293
  decode(snapshot) {
294
294
  const record = snapshot.task;
295
295
  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')
296
+ 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
297
  throw new Error('invalid ownership task');
298
298
  ownershipAid(record.sub_aid, 'sub_aid');
299
299
  ownershipAid(record.owner_aid, 'owner_aid');
@@ -304,22 +304,53 @@ export class OwnershipWorkflow {
304
304
  }
305
305
  else if (record.new_owner_aid !== undefined)
306
306
  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)
307
+ if (ownershipInteger(record.target_relation_version, 'target_relation_version') !== ownershipInteger(record.base_relation_version, 'base_relation_version', 0) + (record.operation === 'release' ? 0 : 1) || !participants(record).includes(record.initiator_aid) || record.operation !== 'bind' && record.initiator_aid !== record.owner_aid)
308
308
  throw new Error('invalid fixed ownership context');
309
309
  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
310
  throw new Error('ownership approval or pending state is corrupt');
311
311
  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
312
  throw new Error('ownership initiator or approval participants are corrupt');
313
+ if (record.request_verified) {
314
+ const base = record.base_agent_md;
315
+ if (!base || base.exists !== true || typeof base.raw !== 'string' || base.sha256 !== ownershipSha256(base.raw) || typeof base.etag !== 'string' || !base.etag)
316
+ throw new Error('ownership original agent.md evidence is corrupt');
317
+ const statement = parseOwnershipFile(base.raw, 'agent', { expectedSubAid: record.sub_aid }).statement ?? null;
318
+ if (JSON.stringify(statement) !== JSON.stringify(base.statement) || (statement?.relation_version ?? 0) !== record.base_relation_version)
319
+ throw new Error('ownership original statement is corrupt');
320
+ const owner = record.base_owner_md;
321
+ if (!owner || typeof owner.exists !== 'boolean' || typeof owner.raw !== 'string' || owner.sha256 !== (owner.exists ? ownershipSha256(owner.raw) : '') || !owner.exists && owner.raw !== '')
322
+ throw new Error('ownership original owner.md evidence is corrupt');
323
+ if (owner.exists) {
324
+ const declaration = parseOwnershipFile(owner.raw, 'owner', { expectedSubAid: record.sub_aid, expectedOwnerAid: statement?.owner_aid ?? record.owner_aid });
325
+ if (declaration.statement?.relation_version !== record.base_relation_version)
326
+ throw new Error('ownership original owner statement is corrupt');
327
+ }
328
+ }
329
+ for (const item of record.inbox)
330
+ parseOwnershipMessage(item.payload, { authenticated_from_aid: item.from, local_aid: this.local(), task: record });
331
+ for (const key of record.received) {
332
+ if (typeof key !== 'string' || key.split('|').length !== 3)
333
+ throw new Error('ownership received key is corrupt');
334
+ const [from, operationId, action] = key.split('|');
335
+ parseOwnershipMessage({ type: OWNERSHIP_SDK_MESSAGE_TYPE, operation_id: operationId, operation: record.operation, action,
336
+ sub_aid: record.sub_aid, owner_aid: record.owner_aid, ...(record.operation === 'transfer' ? { new_owner_aid: record.new_owner_aid } : {}),
337
+ base_relation_version: record.base_relation_version, target_relation_version: record.target_relation_version }, { authenticated_from_aid: from, local_aid: this.local(), task: record });
338
+ }
339
+ for (const item of record.outbox)
340
+ this.validateOutgoing(record, item);
313
341
  if (record.status === 'completed' && (record.verified_relation_version !== record.target_relation_version || record.operation !== 'release' && record.local_decision !== 'accept'))
314
342
  throw new Error('ownership completion evidence or local approval is corrupt');
315
343
  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
344
  if (transferEvidenceRequired && (!/^[0-9a-f]{64}$/.test(record.authorization_sha256 ?? '') || !Number.isSafeInteger(record.expires_at) || (record.expires_at ?? 0) <= 0))
317
345
  throw new Error('ownership transfer authorization evidence is corrupt');
318
346
  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))
347
+ 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
348
  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))
349
+ if (key === 'agent' && !(typeof saved.condition.if_match === 'string' && saved.condition.if_match || saved.condition.if_none_match === '*'))
322
350
  throw new Error('ownership original file condition is corrupt');
351
+ const original = saved.original_owner;
352
+ 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 !== ''))
353
+ throw new Error('ownership original owner material is corrupt');
323
354
  const owner = ['agent', 'owner'].includes(key) ? targetOwner(record) : record.owner_aid;
324
355
  const file = parseOwnershipFile(saved.raw, key === 'agent' ? 'agent' : 'owner', { expectedSubAid: record.sub_aid, expectedOwnerAid: owner });
325
356
  const version = ['release', 'transfer', 'cleanup'].includes(key) ? record.base_relation_version : record.target_relation_version;
@@ -339,7 +370,8 @@ export class OwnershipWorkflow {
339
370
  }
340
371
  if (record.status === 'completed') {
341
372
  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)))
373
+ const cleaned = record.operation === 'release' && evidence?.status === 'unbound' && evidence.relation_version === 0 && record.cleanup_status === 'cleaned' && Boolean(this.cleanupEvidence(record, evidence));
374
+ if (!evidence || evidence.sub_aid !== record.sub_aid || !cleaned && (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_sha256 !== 'string' || record.operation !== 'release' && !/^[0-9a-f]{64}$/.test(evidence.owner_md_sha256))
343
375
  throw new Error('ownership target acceptance evidence is corrupt');
344
376
  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
377
  throw new Error('ownership target file evidence is missing');
@@ -445,8 +477,18 @@ export class OwnershipWorkflow {
445
477
  await this.recordError(active, error);
446
478
  return ownershipTaskResult(record);
447
479
  }
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));
480
+ 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);
481
+ if (base && record.request_verified) {
482
+ try {
483
+ await this.assertBaseMaterial(active, ns);
484
+ }
485
+ catch (error) {
486
+ if (!(error instanceof StateError) || error.stringCode !== 'conflict')
487
+ throw error;
488
+ base = false;
489
+ }
490
+ }
491
+ const target = record.operation === 'release' ? ns.lock === 'unlocked' && !ns.owner_aid && (ns.relation_version === record.target_relation_version || ns.relation_version === 0) : ns.relation_version === record.target_relation_version && ns.lock === 'locked' && ns.owner_aid === targetOwner(record);
450
492
  if (!base && !target) {
451
493
  await this.recordError(active, ownershipFailure('conflict', 'the operation was superseded by a different relation result'));
452
494
  const result = ownershipTaskResult(record);
@@ -470,13 +512,16 @@ export class OwnershipWorkflow {
470
512
  throw ownershipFailure('unavailable', 'initial agent.md is not published', true);
471
513
  if (operation === 'bind' && parseOwnershipFile(ns.content, 'agent', { expectedSubAid: subAid }).frontmatter.type === 'human')
472
514
  throw ownershipFailure('invalid', 'human cannot declare an owner');
515
+ if (operation === 'bind' && parseOwnershipFile(ns.content, 'agent', { expectedSubAid: subAid }).statement?.owner_aid === ownerAid)
516
+ throw ownershipFailure('conflict', 'clear the released agent.md statement before binding the same owner');
473
517
  if (operation !== 'bind') {
474
518
  const consensus = await this.deps.files.checkConsensus(subAid);
475
519
  if (consensus.status !== 'consensus' || consensus.owner_aid !== ownerAid || consensus.relation_version !== ns.relation_version)
476
520
  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
521
  }
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');
522
+ const targetVersion = ownershipInteger(ns.relation_version + (operation === 'release' ? 0 : 1), 'target_relation_version');
523
+ 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');
524
+ await this.captureBase(record, ns);
480
525
  if (operation !== 'release')
481
526
  for (const to of participants(record))
482
527
  if (to !== this.local())
@@ -509,6 +554,7 @@ export class OwnershipWorkflow {
509
554
  const ns = await this.readNs(record.sub_aid);
510
555
  if (ns.relation_version !== record.base_relation_version || (record.operation === 'bind' ? ns.lock !== 'unlocked' : ns.lock !== 'locked' || ns.owner_aid !== record.owner_aid))
511
556
  throw ownershipFailure('conflict', 'the original relation changed before approval');
557
+ await this.assertBaseMaterial(handle, ns);
512
558
  record.local_decision = decision;
513
559
  record.approvals[this.local()] = decision;
514
560
  this.enqueue(record, record.initiator_aid, `${record.operation}.${decision === 'accept' ? 'accept' : 'reject'}`, reason);
@@ -540,19 +586,42 @@ export class OwnershipWorkflow {
540
586
  record.outbox.push({ to, payload });
541
587
  record.notices.push(key);
542
588
  }
589
+ validateOutgoing(record, item) {
590
+ try {
591
+ const message = parseOwnershipMessage(item.payload, { authenticated_from_aid: this.local(), local_aid: item.to, task: { ...record, local_decision: 'accept' } });
592
+ const rejected = ['bind.reject', 'transfer.reject'].includes(message.action);
593
+ if (message.action !== 'operation.stopped' && record.local_decision !== (rejected ? 'reject' : 'accept') || record.approvals[this.local()] !== record.local_decision)
594
+ throw new Error('ownership outgoing action lacks local approval');
595
+ if (['bind.request', 'transfer.request'].includes(message.action) && record.initiator_aid !== this.local())
596
+ throw new Error('ownership outgoing request is not from the initiator');
597
+ if ((message.action === 'transfer.ready' || message.action === 'owner.ready' && record.operation === 'bind') && !participants(record).every((aid) => record.approvals[aid] === 'accept'))
598
+ throw new Error('ownership outgoing action lacks participant approvals');
599
+ }
600
+ catch (error) {
601
+ throw ownershipFailure('persistence_unavailable', error instanceof Error ? error.message : String(error));
602
+ }
603
+ }
543
604
  async flush(handle) {
544
605
  while (handle.record.outbox.length) {
545
606
  const message = handle.record.outbox[0];
546
- const ns = await this.readNs(handle.record.sub_aid);
607
+ this.validateOutgoing(handle.record, message);
547
608
  const record = handle.record;
609
+ if (['rejected', 'stopped', 'error'].includes(record.status) && !['bind.reject', 'transfer.reject', 'operation.stopped'].includes(message.payload.action)) {
610
+ record.outbox.shift();
611
+ await this.save(handle);
612
+ continue;
613
+ }
614
+ const ns = await this.readNs(record.sub_aid);
548
615
  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));
616
+ const target = record.operation === 'release' ? ns.lock === 'unlocked' && !ns.owner_aid && (ns.relation_version === record.target_relation_version || ns.relation_version === 0) : ns.relation_version === record.target_relation_version && ns.lock === 'locked' && ns.owner_aid === targetOwner(record);
550
617
  const requiresTarget = ['relation.confirmed', 'sub.cleaned'].includes(message.payload.action);
551
618
  if (requiresTarget ? !target : !base) {
552
619
  record.outbox.shift();
553
620
  await this.save(handle);
554
621
  continue;
555
622
  }
623
+ if (base && record.request_verified)
624
+ await this.assertBaseMaterial(handle, ns);
556
625
  if (message.payload.action === 'transfer.ready' && record.status !== 'completed') {
557
626
  const expiresAt = record.files.transfer?.expires_at;
558
627
  if (typeof expiresAt !== 'number' || !Number.isSafeInteger(expiresAt) || expiresAt <= 0)
@@ -567,19 +636,77 @@ export class OwnershipWorkflow {
567
636
  }
568
637
  async readNs(subAid) {
569
638
  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)
639
+ const file = ns.exists ? await this.deps.files.verifyFile(ns.content, 'agent', subAid) : undefined;
640
+ if (ns.relation_version !== (file?.statement?.relation_version ?? 0))
572
641
  throw ownershipFailure('unavailable', 'NS relation metadata does not match agent.md', true);
573
642
  const currentOwner = ns.lock === 'locked' ? file?.statement?.owner_aid : null;
574
643
  if (ns.lock === 'locked' && (!file?.statement || file.statement.relation_version !== ns.relation_version) || ns.owner_aid !== undefined && ns.owner_aid !== currentOwner)
575
644
  throw ownershipFailure('unavailable', 'NS owner metadata and signed agent.md do not agree', true);
576
645
  return { ...ns, owner_aid: currentOwner };
577
646
  }
647
+ async captureBase(record, ns) {
648
+ const statement = parseOwnershipFile(ns.content, 'agent', { expectedSubAid: record.sub_aid }).statement ?? null;
649
+ record.base_agent_md = { exists: ns.exists, raw: ns.content, sha256: ownershipSha256(ns.content), etag: ns.etag, statement };
650
+ if (!statement) {
651
+ record.base_owner_md = { exists: false, raw: '', sha256: '' };
652
+ return;
653
+ }
654
+ const owner = await this.deps.files.readOwner(statement.owner_aid, record.sub_aid);
655
+ if ((record.operation === 'transfer' || record.operation === 'release' && ns.lock === 'locked') && !owner.exists)
656
+ throw ownershipFailure('unavailable', 'original owner.md is unavailable', true);
657
+ if (owner.exists)
658
+ await this.deps.files.verifyFile(owner.content, 'owner', record.sub_aid, statement.owner_aid);
659
+ record.base_owner_md = { exists: owner.exists, raw: owner.content, sha256: owner.exists ? ownershipSha256(owner.content) : '' };
660
+ }
661
+ async assertBaseMaterial(handle, ns) {
662
+ const record = handle.record;
663
+ this.assertAgentBaseMaterial(record, ns);
664
+ const original = record.base_owner_md;
665
+ if (!original)
666
+ throw ownershipFailure('persistence_unavailable', 'original owner.md evidence is missing');
667
+ const originalOwnerAid = record.base_agent_md.statement?.owner_aid;
668
+ if (!originalOwnerAid)
669
+ return;
670
+ const current = await this.deps.files.readOwner(originalOwnerAid, record.sub_aid);
671
+ if (current.exists === original.exists && current.content === original.raw && (current.exists ? current.sha256 : '') === original.sha256)
672
+ return;
673
+ if (!current.exists)
674
+ throw ownershipFailure('conflict', 'the original owner.md was replaced');
675
+ const key = record.operation === 'bind' ? 'owner' : record.operation === 'release' ? 'release' : 'transfer';
676
+ const saved = record.files[key];
677
+ if (saved && saved.raw === current.content && saved.hash === current.sha256)
678
+ return;
679
+ if (record.operation !== 'bind' && this.local() !== record.owner_aid && record.local_decision === 'accept') {
680
+ const owner = await this.deps.files.verifyFile(current.content, 'owner', record.sub_aid, originalOwnerAid);
681
+ const previous = original.exists ? parseOwnershipFile(original.raw, 'owner', { expectedSubAid: record.sub_aid, expectedOwnerAid: originalOwnerAid }) : undefined;
682
+ const sameStatement = previous?.statement && Object.keys(previous.statement).every((field) => previous.statement[field] === owner.statement?.[field]);
683
+ 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);
684
+ if (allowed && !saved) {
685
+ if (record.operation === 'transfer')
686
+ await this.deps.files.requireTransfer(this.fileTask(handle));
687
+ record.files[key] = { raw: current.content, hash: current.sha256, condition: {}, original_owner: original, ...(owner.transfer ? { expires_at: owner.transfer.expires_at } : {}) };
688
+ await this.save(handle);
689
+ return;
690
+ }
691
+ }
692
+ throw ownershipFailure('conflict', 'the original owner statement was replaced');
693
+ }
694
+ assertAgentBaseMaterial(record, ns) {
695
+ const base = record.base_agent_md;
696
+ if (!base)
697
+ throw ownershipFailure('persistence_unavailable', 'original agent.md evidence is missing');
698
+ const statement = parseOwnershipFile(ns.content, 'agent', { expectedSubAid: record.sub_aid }).statement ?? null;
699
+ 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;
700
+ if (ns.exists !== base.exists || !same)
701
+ throw ownershipFailure('conflict', 'the original ownership statement was replaced');
702
+ }
578
703
  async processInbox(handle) {
579
704
  const record = handle.record;
580
705
  while (record.inbox.length) {
581
706
  const item = record.inbox[0];
582
707
  const ns = await this.readNs(record.sub_aid);
708
+ if (record.request_verified && ns.relation_version === record.base_relation_version && !['relation.confirmed', 'sub.cleaned'].includes(item.payload.action))
709
+ await this.assertBaseMaterial(handle, ns);
583
710
  try {
584
711
  parseOwnershipMessage(item.payload, { authenticated_from_aid: item.from, local_aid: this.local(), task: record, ns });
585
712
  }
@@ -596,11 +723,60 @@ export class OwnershipWorkflow {
596
723
  continue;
597
724
  }
598
725
  const action = item.payload.action;
599
- if (['bind.request', 'transfer.request'].includes(action))
726
+ if (['bind.request', 'transfer.request'].includes(action)) {
727
+ if (!record.request_verified)
728
+ await this.captureBase(record, ns);
600
729
  record.request_verified = true;
730
+ }
601
731
  if (['bind.accept', 'transfer.accept', 'bind.reject', 'transfer.reject'].includes(action)) {
602
732
  if (record.approvals[item.from] === 'pending' && record.status === 'pending') {
603
733
  const rejected = action.endsWith('.reject');
734
+ if (rejected) {
735
+ try {
736
+ if (await this.confirm(handle)) {
737
+ record.inbox.shift();
738
+ record.received.push(`${item.from}|${item.payload.operation_id}|${action}`);
739
+ await this.save(handle);
740
+ continue;
741
+ }
742
+ const observed = await this.deps.files.checkConsensus(record.sub_aid, { owner_aid: targetOwner(record), relation_version: record.target_relation_version });
743
+ const targetReached = observed.status === 'consensus' && observed.owner_aid === targetOwner(record) && observed.relation_version === record.target_relation_version;
744
+ if (targetReached) {
745
+ record.code = 'unavailable';
746
+ record.reason = 'ownership target is committed but local evidence is incomplete';
747
+ record.retryable = true;
748
+ record.inbox.shift();
749
+ record.received.push(`${item.from}|${item.payload.operation_id}|${action}`);
750
+ await this.save(handle);
751
+ continue;
752
+ }
753
+ }
754
+ catch (error) {
755
+ if (error instanceof StateError && error.stringCode === 'persistence_unavailable')
756
+ throw error;
757
+ try {
758
+ const ns = await this.readNs(record.sub_aid);
759
+ if (ns.relation_version === record.target_relation_version && ns.lock === 'locked' && ns.owner_aid === targetOwner(record)) {
760
+ record.code = 'unavailable';
761
+ record.reason = 'ownership target is committed but local evidence is incomplete';
762
+ record.retryable = true;
763
+ record.inbox.shift();
764
+ record.received.push(`${item.from}|${item.payload.operation_id}|${action}`);
765
+ await this.save(handle);
766
+ continue;
767
+ }
768
+ }
769
+ catch (observationError) {
770
+ if (observationError instanceof StateError && observationError.stringCode === 'persistence_unavailable')
771
+ throw observationError;
772
+ }
773
+ record.code = error instanceof StateError ? error.stringCode : 'unavailable';
774
+ record.reason = error instanceof Error ? error.message : String(error);
775
+ record.retryable = true;
776
+ await this.save(handle);
777
+ throw error;
778
+ }
779
+ }
604
780
  record.approvals[item.from] = rejected ? 'reject' : 'accept';
605
781
  if (rejected) {
606
782
  record.status = 'rejected';
@@ -625,8 +801,30 @@ export class OwnershipWorkflow {
625
801
  if (!file.statement)
626
802
  record.cleanup_status = 'cleaned';
627
803
  }
628
- if (record.operation === 'release')
804
+ if (record.operation === 'release' && !record.request_verified) {
805
+ if (ns.relation_version === 0) {
806
+ const authorizationId = this.cleanupEvidence(record, { agent_md_etag: ns.etag, agent_md_sha256: ownershipSha256(ns.content), owner_md_sha256: '' }, ns.content);
807
+ if (!authorizationId)
808
+ throw ownershipFailure('conflict', 'no local cleanup evidence can verify the release request');
809
+ const authorization = this.load(authorizationId).record;
810
+ const saved = authorization.files.agent;
811
+ const statement = parseOwnershipFile(saved.raw, 'agent', { expectedSubAid: record.sub_aid }).statement;
812
+ const etag = saved.published_etag ?? authorization.completion_evidence?.agent_md_etag;
813
+ if (!etag)
814
+ throw ownershipFailure('persistence_unavailable', 'historical approved agent.md ETag is missing');
815
+ record.base_agent_md = { exists: true, raw: saved.raw, sha256: saved.hash, etag, statement };
816
+ const owner = await this.deps.files.readOwner(record.owner_aid, record.sub_aid);
817
+ if (owner.exists)
818
+ await this.deps.files.verifyFile(owner.content, 'owner', record.sub_aid, record.owner_aid);
819
+ record.base_owner_md = { exists: owner.exists, raw: owner.content, sha256: owner.exists ? ownershipSha256(owner.content) : '' };
820
+ record.cleanup_authorization_id = authorizationId;
821
+ if (!(await this.confirm(handle)))
822
+ throw ownershipFailure('conflict', 'no local cleanup evidence can verify the release request');
823
+ }
824
+ else
825
+ await this.captureBase(record, ns);
629
826
  record.request_verified = true;
827
+ }
630
828
  record.inbox.shift();
631
829
  record.received.push(`${item.from}|${item.payload.operation_id}|${action}`);
632
830
  const request = record.request_verified && record.local_decision === 'pending' && record.operation !== 'release' && !record.request_notified;
@@ -645,7 +843,7 @@ export class OwnershipWorkflow {
645
843
  const record = handle.record;
646
844
  const task = { operation_id: record.operation_id, operation: record.operation, sub_aid: record.sub_aid, owner_aid: record.owner_aid,
647
845
  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,
846
+ 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,
649
847
  persist: async () => {
650
848
  record.authorization_sha256 = task.authorization_sha256;
651
849
  record.expires_at = task.expires_at;
@@ -703,6 +901,7 @@ export class OwnershipWorkflow {
703
901
  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
902
  throw ownershipFailure('conflict', 'the original relation was replaced');
705
903
  }
904
+ await this.assertBaseMaterial(handle, ns);
706
905
  if (record.expires_at !== undefined && record.expires_at <= Math.trunc(Date.now() / 1000))
707
906
  throw ownershipFailure('conflict', 'transfer authorization expired');
708
907
  const approved = participants(record).every((name) => record.approvals[name] === 'accept');
@@ -722,8 +921,11 @@ export class OwnershipWorkflow {
722
921
  }
723
922
  if (record.operation === 'transfer') {
724
923
  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')
924
+ if (original.exists && (await this.deps.files.verifyFile(original.content, 'owner', record.sub_aid, record.owner_aid)).action === 'declare') {
925
+ if (record.authorization_sha256 !== undefined)
926
+ throw ownershipFailure('conflict', 'transfer authorization was withdrawn');
726
927
  return;
928
+ }
727
929
  await this.deps.files.requireTransfer(this.fileTask(handle));
728
930
  }
729
931
  if (this.local() === targetOwner(record)) {
@@ -753,16 +955,67 @@ export class OwnershipWorkflow {
753
955
  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
956
  if (result.status === 'invalid' || result.status === 'unavailable')
755
957
  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));
958
+ if (record.operation === 'release' && record.status === 'completed' && result.status === 'unbound' && result.relation_version === 0)
959
+ return true;
960
+ let cleaned = false;
961
+ if (record.operation === 'release' && result.status === 'unbound' && result.relation_version === 0) {
962
+ const began = Date.now();
963
+ const ns = await this.readNs(record.sub_aid);
964
+ if (!ns.exists || !ns.etag)
965
+ throw ownershipFailure('conflict', 'current signed cleanup document is missing');
966
+ if (ns.etag !== result.agent_md_etag || ownershipSha256(ns.content) !== result.agent_md_sha256)
967
+ throw ownershipFailure('unavailable', 'agent.md changed during cleanup confirmation', true);
968
+ if (record.local_roles.includes('owner') && record.files.release) {
969
+ const source = record.files.release, owner = await this.deps.files.readOwner(record.owner_aid, record.sub_aid);
970
+ if (owner.exists && owner.content === source.raw && owner.sha256 === source.hash) {
971
+ await this.deps.files.verifyFile(owner.content, 'owner', record.sub_aid, record.owner_aid);
972
+ const after = await this.readNs(record.sub_aid);
973
+ if (after.etag !== ns.etag || after.content !== ns.content || after.relation_version !== 0 || after.lock !== 'unlocked')
974
+ throw ownershipFailure('unavailable', 'agent.md changed during release confirmation', true);
975
+ result.owner_md_sha256 = owner.sha256;
976
+ }
977
+ }
978
+ const authorization = this.cleanupEvidence(record, result, ns.content);
979
+ if (!authorization)
980
+ throw ownershipFailure('conflict', 'no local cleanup original can confirm this release task');
981
+ if (Date.now() - began > 30_000)
982
+ throw ownershipFailure('unavailable', 'cleanup confirmation verification window elapsed', true);
983
+ if (authorization !== record.operation_id)
984
+ record.cleanup_authorization_id = authorization;
985
+ record.local_decision = 'accept';
986
+ record.approvals[this.local()] = 'accept';
987
+ record.cleanup_status = 'cleaned';
988
+ cleaned = true;
989
+ }
990
+ const reached = cleaned || result.relation_version === record.target_relation_version && (record.operation === 'release' ? result.status === 'released' && result.lock === 'unlocked' : result.status === 'consensus' && result.owner_aid === targetOwner(record));
757
991
  if (!reached)
758
992
  return false;
993
+ if (record.operation === 'release' && !cleaned) {
994
+ const ns = await this.readNs(record.sub_aid);
995
+ const statement = parseOwnershipFile(ns.content, 'agent', { expectedSubAid: record.sub_aid }).statement;
996
+ if (ns.etag !== result.agent_md_etag || ownershipSha256(ns.content) !== result.agent_md_sha256)
997
+ throw ownershipFailure('unavailable', 'agent.md changed during release confirmation', true);
998
+ if (statement?.owner_aid !== record.owner_aid || statement.relation_version !== record.base_relation_version)
999
+ return false;
1000
+ if (record.base_agent_md)
1001
+ this.assertAgentBaseMaterial(record, ns);
1002
+ if (record.files.release && result.owner_md_sha256 && record.files.release.hash !== result.owner_md_sha256)
1003
+ throw ownershipFailure('conflict', 'current release material does not belong to this task');
1004
+ }
759
1005
  if (record.operation === 'release' && record.local_roles.includes('owner') && !record.files.release)
760
1006
  throw ownershipFailure('conflict', 'local release file evidence is missing');
761
1007
  if (record.operation === 'transfer' && record.local_roles.includes('owner') && !record.files.transfer)
762
1008
  throw ownershipFailure('conflict', 'local transfer file evidence is missing');
763
1009
  if (record.operation === 'release' && this.local() === record.sub_aid && record.local_decision !== 'accept') {
1010
+ const authorization = await this.cleanupAuthorization(record);
1011
+ if (authorization) {
1012
+ record.local_decision = 'accept';
1013
+ record.approvals[this.local()] = 'accept';
1014
+ record.cleanup_authorization_id = authorization.operation_id;
1015
+ await this.save(handle);
1016
+ }
764
1017
  const ns = await this.deps.files.readAgent(record.sub_aid);
765
- if (parseOwnershipFile(ns.content, 'agent', { expectedSubAid: record.sub_aid }).statement) {
1018
+ if (!authorization && parseOwnershipFile(ns.content, 'agent', { expectedSubAid: record.sub_aid }).statement) {
766
1019
  record.step = 'cleanup';
767
1020
  if (record.cleanup_status !== 'action_required') {
768
1021
  record.cleanup_status = 'action_required';
@@ -774,12 +1027,12 @@ export class OwnershipWorkflow {
774
1027
  }
775
1028
  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
1029
  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');
1030
+ 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)
1031
+ throw ownershipFailure('conflict', 'published agent.md ETag changed');
779
1032
  if (record.status !== 'completed') {
780
1033
  record.status = 'completed';
781
1034
  record.step = 'confirmed';
782
- record.verified_relation_version = result.relation_version;
1035
+ record.verified_relation_version = record.target_relation_version;
783
1036
  record.completion_evidence = { ...result };
784
1037
  delete record.code;
785
1038
  delete record.reason;
@@ -794,15 +1047,58 @@ export class OwnershipWorkflow {
794
1047
  }
795
1048
  return true;
796
1049
  }
1050
+ cleanupEvidence(record, evidence, raw) {
1051
+ const release = record.files.release;
1052
+ if (record.local_roles.includes('owner') && release && release.hash === evidence.owner_md_sha256)
1053
+ return record.operation_id;
1054
+ if (!record.local_roles.includes('sub'))
1055
+ return undefined;
1056
+ 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));
1057
+ if (matches(record.files.agent))
1058
+ return record.operation_id;
1059
+ for (const snapshot of this.list(true)) {
1060
+ if (!['bind', 'transfer'].includes(String(snapshot.task.operation)) || record.cleanup_authorization_id && snapshot.operation_id !== record.cleanup_authorization_id)
1061
+ continue;
1062
+ let candidate;
1063
+ try {
1064
+ candidate = this.decode(snapshot).record;
1065
+ }
1066
+ catch (error) {
1067
+ if (snapshot.task.sub_aid === record.sub_aid)
1068
+ throw error;
1069
+ else
1070
+ continue;
1071
+ }
1072
+ 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))
1073
+ continue;
1074
+ const statement = candidate.files.agent && parseOwnershipFile(candidate.files.agent.raw, 'agent', { expectedSubAid: record.sub_aid }).statement;
1075
+ 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])))
1076
+ return candidate.operation_id;
1077
+ }
1078
+ return undefined;
1079
+ }
797
1080
  async resumeSubCleanup(handle, observed) {
798
1081
  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)
1082
+ if (!['bind', 'transfer'].includes(record.operation) || !record.local_roles.includes('sub') || record.local_decision !== 'accept' || record.release_cleanup?.cleaned)
1083
+ return;
1084
+ if (record.release_cleanup && observed.status === 'unbound' && observed.relation_version === 0) {
1085
+ const saved = record.release_cleanup.files.agent;
1086
+ const ns = await this.readNs(record.sub_aid);
1087
+ 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)
1088
+ throw ownershipFailure('conflict', 'historical cleanup original does not match current material');
1089
+ saved.published_etag = ns.etag;
1090
+ record.release_cleanup.cleaned = true;
1091
+ await this.save(handle);
1092
+ return;
1093
+ }
1094
+ if (observed.status !== 'released' || observed.relation_version !== record.target_relation_version)
800
1095
  return;
801
1096
  const ns = await this.readNs(record.sub_aid);
802
1097
  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
1098
  throw ownershipFailure('unavailable', 'NS changed during historical cleanup observation', true);
804
1099
  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))
1100
+ const original = record.files.agent && parseOwnershipFile(record.files.agent.raw, 'agent', { expectedSubAid: record.sub_aid }).statement;
1101
+ if (!statement || !original || Object.keys(original).some((field) => original[field] !== statement[field]))
806
1102
  throw ownershipFailure('conflict', 'historical cleanup fields belong to another relation');
807
1103
  if (!record.release_cleanup) {
808
1104
  record.release_cleanup = { files: {}, cleaned: false };
@@ -810,16 +1106,26 @@ export class OwnershipWorkflow {
810
1106
  }
811
1107
  await this.deps.files.revokeOwneraid({ operation: 'release', sub_aid: record.sub_aid, owner_aid: targetOwner(record),
812
1108
  base_relation_version: record.target_relation_version, target_relation_version: observed.relation_version,
813
- files: record.release_cleanup.files, persist: () => this.save(handle) });
1109
+ files: record.release_cleanup.files, expected_agent_statement: original, persist: () => this.save(handle) });
814
1110
  record.release_cleanup.cleaned = true;
815
1111
  await this.save(handle);
816
1112
  this.emit(record, 'ownership.progress');
817
1113
  }
818
- cleanupAuthorization(task) {
819
- for (const snapshot of this.list()) {
1114
+ async cleanupAuthorization(task) {
1115
+ const ns = await this.readNs(task.sub_aid);
1116
+ const statement = ns.exists ? parseOwnershipFile(ns.content, 'agent', { expectedSubAid: task.sub_aid }).statement : undefined;
1117
+ if (!statement)
1118
+ return undefined;
1119
+ for (const snapshot of this.list(true)) {
1120
+ if (snapshot.task.sub_aid !== task.sub_aid)
1121
+ continue;
820
1122
  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;
1123
+ 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) {
1124
+ const original = parseOwnershipFile(candidate.files.agent.raw, 'agent', { expectedSubAid: task.sub_aid }).statement;
1125
+ const fixed = task.base_agent_md?.statement;
1126
+ if (original && Object.keys(original).every((field) => original[field] === statement[field]) && (!fixed || Object.keys(fixed).every((field) => fixed[field] === original[field])))
1127
+ return candidate;
1128
+ }
823
1129
  }
824
1130
  return undefined;
825
1131
  }
@@ -829,12 +1135,14 @@ export class OwnershipWorkflow {
829
1135
  return;
830
1136
  if (record.operation === 'release' && this.local() === record.sub_aid && record.cleanup_status !== 'cleaned') {
831
1137
  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) {
1138
+ if (ns.exists && ns.lock === 'unlocked' && ns.relation_version === 0 && !parseOwnershipFile(ns.content, 'agent', { expectedSubAid: record.sub_aid }).statement) {
1139
+ if (!this.cleanupEvidence(record, { agent_md_sha256: ownershipSha256(ns.content), agent_md_etag: ns.etag, owner_md_sha256: '' }, ns.content))
1140
+ throw ownershipFailure('conflict', 'current cleanup does not match a locally saved original');
833
1141
  record.cleanup_status = 'cleaned';
834
1142
  await this.save(handle);
835
1143
  }
836
1144
  else {
837
- const authorization = this.cleanupAuthorization(record);
1145
+ const authorization = await this.cleanupAuthorization(record);
838
1146
  if (!authorization || record.cleanup_authorization_id && authorization.operation_id !== record.cleanup_authorization_id) {
839
1147
  if (record.cleanup_status !== 'action_required') {
840
1148
  record.cleanup_status = 'action_required';
@@ -845,7 +1153,9 @@ export class OwnershipWorkflow {
845
1153
  else {
846
1154
  record.cleanup_authorization_id = authorization.operation_id;
847
1155
  await this.save(handle);
848
- await this.deps.files.revokeOwneraid(this.fileTask(handle));
1156
+ const task = this.fileTask(handle);
1157
+ task.expected_agent_statement = parseOwnershipFile(authorization.files.agent.raw, 'agent', { expectedSubAid: record.sub_aid }).statement;
1158
+ await this.deps.files.revokeOwneraid(task);
849
1159
  record.cleanup_status = 'cleaned';
850
1160
  this.enqueue(record, record.owner_aid, 'sub.cleaned');
851
1161
  await this.save(handle);