evolcore 0.0.38 → 0.0.40

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 (125) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +31 -20
  3. package/bin/ec-safe-output.js +14 -2
  4. package/dist/agents/baseagent-availability.js +1 -0
  5. package/dist/agents/claude-runner.js +32 -46
  6. package/dist/agents/codex-runner.js +54 -30
  7. package/dist/agents/ecagent-runner.js +18 -7
  8. package/dist/agents/gemini-runner.js +1 -2
  9. package/dist/agents/runner-types.js +28 -6
  10. package/dist/aun/aid/agentmd.js +21 -0
  11. package/dist/aun/aid/client.js +1 -1
  12. package/dist/aun/aid/control-aid.js +23 -98
  13. package/dist/aun/aid/ownership-operation.js +35 -12
  14. package/dist/aun/group/invite-service.js +6 -0
  15. package/dist/aun/msg/p2p.js +8 -12
  16. package/dist/channels/aun.js +66 -48
  17. package/dist/channels/dingtalk.js +8 -2
  18. package/dist/channels/feishu.js +6 -1
  19. package/dist/channels/wecom.js +7 -3
  20. package/dist/cli/agent-create.js +30 -6
  21. package/dist/cli/agent.js +30 -37
  22. package/dist/cli/aid-bind-command.js +16 -2
  23. package/dist/cli/aun-commands.js +31 -23
  24. package/dist/cli/config.js +15 -18
  25. package/dist/cli/contact.js +9 -12
  26. package/dist/cli/ctl-command.js +3 -3
  27. package/dist/cli/daemon-commands.js +242 -17
  28. package/dist/cli/diag.js +7 -3
  29. package/dist/cli/fs-command.js +5 -4
  30. package/dist/cli/handoff-command.js +11 -11
  31. package/dist/cli/help.js +18 -0
  32. package/dist/cli/index.js +25 -13
  33. package/dist/cli/init-channel.js +2 -1
  34. package/dist/cli/memory-command.js +47 -27
  35. package/dist/cli/model.js +5 -4
  36. package/dist/cli/queue-command.js +3 -3
  37. package/dist/cli/response.js +5 -4
  38. package/dist/cli/restart-monitor.js +35 -13
  39. package/dist/cli/skill-command.js +13 -13
  40. package/dist/cli/task-command.js +3 -3
  41. package/dist/cli/task-context.js +1 -0
  42. package/dist/cli/thread-command.js +5 -3
  43. package/dist/cli/trigger-command.js +3 -3
  44. package/dist/cli/version.js +6 -2
  45. package/dist/config/config-field-policy.js +1 -0
  46. package/dist/contracts/ecweb-service-protocol.js +44 -0
  47. package/dist/core/agent-runtime.js +6 -0
  48. package/dist/core/auth/agent-delegation.js +7 -4
  49. package/dist/core/auth/auth-gateway.js +3 -1
  50. package/dist/core/auth/authorization-audit.js +5 -1
  51. package/dist/core/auth/debug-context.js +5 -1
  52. package/dist/core/auth/operation-catalog.js +17 -1
  53. package/dist/core/bootstrap-service.js +20 -12
  54. package/dist/core/command/cli-intent-parser.js +5 -2
  55. package/dist/core/command/command-handler.js +20 -5
  56. package/dist/core/command/menu-catalog.js +48 -12
  57. package/dist/core/command/menu-handler.js +74 -6
  58. package/dist/core/command/menu-protocol.js +20 -1
  59. package/dist/core/command/slash-gate.js +17 -3
  60. package/dist/core/command/slash-handler.js +90 -7
  61. package/dist/core/event-catalog.js +2 -0
  62. package/dist/core/interaction-router.js +2 -1
  63. package/dist/core/ipc-registration.js +26 -7
  64. package/dist/core/memory/batch-meta.js +2 -0
  65. package/dist/core/memory/consolidation-context.js +42 -51
  66. package/dist/core/memory/consolidation-ops-records.js +39 -7
  67. package/dist/core/memory/consolidation-trigger.js +71 -64
  68. package/dist/core/memory/consolidation.js +226 -20
  69. package/dist/core/memory/memory-writer.js +160 -34
  70. package/dist/core/memory/relation-idle-monitor.js +287 -0
  71. package/dist/core/memory/relation-sweep.js +206 -0
  72. package/dist/core/message/aun-ipc-sender.js +5 -0
  73. package/dist/core/message/message-bridge.js +64 -55
  74. package/dist/core/message/message-log.js +17 -0
  75. package/dist/core/message/message-queue.js +6 -0
  76. package/dist/core/message/outbound.js +70 -0
  77. package/dist/core/message/peer-mode.js +7 -2
  78. package/dist/core/message/response-engine.js +341 -73
  79. package/dist/core/ownership/owner-resolver.js +465 -140
  80. package/dist/core/permission/ec-command-parser.js +19 -3
  81. package/dist/core/permission/protected-paths.js +51 -0
  82. package/dist/core/permission/tool-policy.js +7 -7
  83. package/dist/core/role/runtime-policy.js +64 -0
  84. package/dist/core/session/session-manager.js +25 -5
  85. package/dist/core/system-session/memory.js +3 -18
  86. package/dist/ecweb/basic-settings-service.js +709 -0
  87. package/dist/ecweb/providers-service.js +172 -0
  88. package/dist/index.js +92 -4
  89. package/dist/ipc.js +78 -11
  90. package/dist/stats/db.js +33 -0
  91. package/dist/stats/index.js +2 -2
  92. package/dist/stats/query.js +246 -0
  93. package/dist/stats/writer.js +41 -1
  94. package/dist/trigger/application-service.js +12 -0
  95. package/dist/trigger/authorization.js +2 -2
  96. package/dist/trigger/history.js +36 -0
  97. package/dist/trigger/scheduler.js +3 -0
  98. package/dist/utils/ecweb-supervisor.js +31 -10
  99. package/dist/utils/error-utils.js +8 -4
  100. package/dist/utils/evolcore-version.js +3 -2
  101. package/dist/utils/host-info.js +52 -0
  102. package/dist/utils/welcome.js +18 -24
  103. package/ecagent/dist/harness/agent-harness.js +4 -1
  104. package/kits/docs/evolcore/INDEX.md +1 -1
  105. package/kits/docs/evolcore/agent.md +1 -1
  106. package/kits/docs/evolcore/aid.md +58 -8
  107. package/kits/docs/evolcore/config.md +9 -7
  108. package/kits/docs/evolcore/memory-architecture.md +3 -4
  109. package/kits/docs/evolcore/memory-consolidation.md +28 -7
  110. package/kits/docs/evolcore/memory-tags.md +1 -1
  111. package/kits/docs/evolcore/memory.md +1 -1
  112. package/kits/docs/evolcore/menu-client.md +1 -1
  113. package/kits/docs/evolcore/menu-protocol.md +6 -0
  114. package/kits/docs/evolcore/msg.md +1 -1
  115. package/kits/eck_manifest.json +3 -3
  116. package/kits/rules/04-relation.md +3 -2
  117. package/kits/schemas/agent-config.schema.13.json +5 -1
  118. package/kits/templates/memory-consolidation/generalize.md +32 -4
  119. package/kits/templates/memory-consolidation/notes.md +2 -2
  120. package/kits/templates/memory-consolidation/promote.md +10 -2
  121. package/kits/templates/system-fragments/baseagent.md +5 -0
  122. package/kits/templates/system-fragments/memory.md +12 -3
  123. package/kits/templates/system-fragments/session.md +1 -1
  124. package/kits/{rules/07-memory-and-thread.md → templates/system-fragments/thread.md} +17 -80
  125. package/package.json +3 -3
@@ -1,14 +1,16 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ import { randomUUID } from 'crypto';
3
4
  import { resolvePaths } from '../../paths.js';
4
5
  import { isValidAid } from '../../aun/aid/validation.js';
5
- import { atomicReadJson, atomicWriteJson } from '../../utils/atomic-write.js';
6
+ import { atomicReadJson } from '../../utils/atomic-write.js';
6
7
  import { configuredOwnerList as readConfiguredOwnerList } from '../../config/owner-config.js';
7
8
  import { logger } from '../../utils/logger.js';
8
9
  import { hasTrustedPrincipal } from '../auth/authenticated-actor.js';
9
10
  export const OWNERSHIP_CACHE_MAX_AGE_MS = 30 * 60_000;
10
11
  export const OWNERSHIP_AUTHORIZATION_MAX_AGE_MS = 5 * 60_000;
11
- const OWNERSHIP_PENDING_REFRESH_MS = 5_000;
12
+ // Background refreshes should heal a short outage without retrying on every tool call.
13
+ const OWNERSHIP_BACKGROUND_REFRESH_MIN_INTERVAL_MS = 60_000;
12
14
  const CREATION_BIND_OPERATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
13
15
  /** Only use the task returned by this create's bind call. Never select a latest task. */
14
16
  export function creationOwnershipBinding(task, subjectAid, ownerAid) {
@@ -117,7 +119,12 @@ export class OwnerResolver {
117
119
  if (subject.client !== client || subject.generation !== generation)
118
120
  return;
119
121
  const record = this.record(payload);
120
- if (!record || record.sub_aid !== subjectAid)
122
+ if (!record)
123
+ return;
124
+ this.trackBindingRequest(subjectAid, subject, event, record);
125
+ // When this AID is the prospective owner, the request concerns the
126
+ // remote sub's ownership, not this AID's own Owner authorization.
127
+ if (record.sub_aid !== subjectAid)
121
128
  return;
122
129
  if (typeof record.operation_id === 'string') {
123
130
  const key = JSON.stringify([event, record.operation_id, record.step, record.status,
@@ -129,7 +136,7 @@ export class OwnerResolver {
129
136
  subject.taskEvents.delete(subject.taskEvents.values().next().value);
130
137
  }
131
138
  this.invalidate(subject, 'Ownership changed; fresh consensus is required');
132
- const task = this.task(record);
139
+ const task = this.task(record, event);
133
140
  logger.info(`[ownership] ${event} ${JSON.stringify({ subjectAid, ownerAid: task?.owner_aid,
134
141
  operationId: task?.operation_id, taskStatus: task?.status, step: task?.step,
135
142
  code: task?.code, reason: task?.reason })}`);
@@ -139,10 +146,6 @@ export class OwnerResolver {
139
146
  subject.view.task = task;
140
147
  subject.view.requestStatus = 'submitted';
141
148
  }
142
- try {
143
- this.persist(subjectAid, subject);
144
- }
145
- catch { }
146
149
  this.schedule(subjectAid, subject);
147
150
  }));
148
151
  }
@@ -195,9 +198,6 @@ export class OwnerResolver {
195
198
  if (subject.timer)
196
199
  clearTimeout(subject.timer);
197
200
  subject.timer = undefined;
198
- if (subject.pendingRefreshTimer)
199
- clearTimeout(subject.pendingRefreshTimer);
200
- subject.pendingRefreshTimer = undefined;
201
201
  }
202
202
  subject.view.connected = connected;
203
203
  if (connected)
@@ -211,10 +211,10 @@ export class OwnerResolver {
211
211
  if (subject.timer)
212
212
  clearTimeout(subject.timer);
213
213
  subject.timer = undefined;
214
- if (subject.pendingRefreshTimer)
215
- clearTimeout(subject.pendingRefreshTimer);
216
- subject.pendingRefreshTimer = undefined;
217
214
  subject.taskEvents.clear();
215
+ subject.requests.clear();
216
+ subject.decisionTasks.clear();
217
+ delete subject.view.decisionTask;
218
218
  for (const subscription of subject.subscriptions.splice(0)) {
219
219
  try {
220
220
  subscription.unsubscribe();
@@ -275,6 +275,7 @@ export class OwnerResolver {
275
275
  snapshot(subjectAid) {
276
276
  const subject = this.subject(subjectAid);
277
277
  const view = JSON.parse(JSON.stringify(subject.view));
278
+ view.requests = [...subject.requests.values()].map(request => ({ ...request }));
278
279
  if (view.consensusStatus === 'consensus' && !this.owner(subjectAid)) {
279
280
  view.ownerAid = undefined;
280
281
  view.synchronization = { status: 'unavailable' };
@@ -284,12 +285,6 @@ export class OwnerResolver {
284
285
  && view.checkedAt !== undefined && view.checkedAt <= now
285
286
  ? view.ownerAid && now - view.checkedAt < OWNERSHIP_AUTHORIZATION_MAX_AGE_MS ? 'verified' : 'expired'
286
287
  : 'unavailable';
287
- if (subject.persistenceError) {
288
- view.requestStatus = 'error';
289
- view.reason = subject.persistenceError;
290
- view.synchronization.status = 'unavailable';
291
- view.authorizationStatus = 'unavailable';
292
- }
293
288
  return view;
294
289
  }
295
290
  async refresh(subjectAid, options = {}) {
@@ -298,7 +293,10 @@ export class OwnerResolver {
298
293
  if (subject.timer)
299
294
  clearTimeout(subject.timer);
300
295
  subject.timer = undefined;
301
- const refreshing = subject.refreshing?.epoch === subject.epoch ? subject.refreshing.promise : undefined;
296
+ const inFlight = subject.refreshing?.epoch === subject.epoch ? subject.refreshing : undefined;
297
+ const refreshing = inFlight?.promise;
298
+ const initialEpoch = subject.epoch;
299
+ const initialCheckedAt = subject.view.checkedAt;
302
300
  const maxAgeMs = options.maxAgeMs ?? this.options.lifetimeMs ?? OWNERSHIP_CACHE_MAX_AGE_MS;
303
301
  const isFresh = () => {
304
302
  const { checkedAt, validUntil, consensusStatus } = subject.view;
@@ -308,32 +306,48 @@ export class OwnerResolver {
308
306
  && now - checkedAt < maxAgeMs;
309
307
  };
310
308
  if (!refreshing && !options.force && isFresh()) {
309
+ if (options.allowTransient
310
+ && this.now() - (subject.lastRefreshAttemptAt ?? 0) >= OWNERSHIP_BACKGROUND_REFRESH_MIN_INTERVAL_MS) {
311
+ this.schedule(subjectAid, subject, true);
312
+ }
311
313
  return this.snapshot(subjectAid);
312
314
  }
313
- const promise = refreshing ?? this.refreshSubject(subjectAid, subject);
314
- subject.refreshing = { epoch: subject.epoch, promise };
315
+ const attemptId = inFlight?.attemptId ?? ++subject.query;
316
+ const attemptedAt = inFlight?.attemptedAt ?? this.now();
317
+ const promise = refreshing ?? this.refreshSubject(subjectAid, subject, attemptId, attemptedAt);
318
+ subject.refreshing = { epoch: subject.epoch, attemptId, attemptedAt, promise };
315
319
  try {
316
320
  await promise;
317
- if (!subject.view.connected && subject.view.consensusStatus === 'consensus' && !isFresh()) {
321
+ if (subject.epoch !== initialEpoch && subject.view.consensusStatus === 'consensus'
322
+ && subject.view.checkedAt === initialCheckedAt) {
323
+ this.handleRefreshFailure(subjectAid, subject, Object.assign(new Error('Ownership refresh was interrupted by a connection state change'), { code: 'CLIENT_DISCONNECTED' }), attemptedAt, attemptId);
324
+ }
325
+ if (!subject.view.connected && subject.view.consensusStatus === 'consensus' && !isFresh()
326
+ && subject.view.refreshFailure?.fallbackUsed !== true) {
318
327
  this.invalidate(subject, 'Ownership cache expired while disconnected');
319
328
  }
329
+ if (!options.allowTransient && subject.view.refreshFailure?.attemptId === attemptId
330
+ && subject.view.refreshFailure.fallbackUsed) {
331
+ return this.unavailableSnapshot(subjectAid, subject.view.refreshFailure);
332
+ }
320
333
  return this.snapshot(subjectAid);
321
334
  }
322
335
  finally {
323
336
  if (subject.refreshing?.promise === promise)
324
337
  subject.refreshing = undefined;
325
- this.schedulePendingRefresh(subjectAid, subject);
326
338
  }
327
339
  }
328
- async refreshSubject(subjectAid, subject) {
340
+ async refreshSubject(subjectAid, subject, attemptId, attemptedAt) {
329
341
  const client = subject.client;
342
+ subject.lastRefreshAttemptAt = attemptedAt;
330
343
  if (!client || !subject.view.connected) {
331
- this.invalidate(subject, 'Subject AID has no connected SDK client');
344
+ const reason = 'Subject AID has no connected SDK client';
345
+ this.handleRefreshFailure(subjectAid, subject, { code: 'CLIENT_DISCONNECTED', message: reason }, attemptedAt, attemptId);
332
346
  return this.snapshot(subjectAid);
333
347
  }
334
348
  const generation = subject.generation;
335
349
  const epoch = subject.epoch;
336
- const query = ++subject.query;
350
+ const query = attemptId;
337
351
  let timeout;
338
352
  try {
339
353
  const consensus = await Promise.race([
@@ -352,6 +366,16 @@ export class OwnerResolver {
352
366
  if (consensus.sub_aid !== subjectAid || (consensus.status === 'consensus' && !ownerAid)) {
353
367
  throw new Error('Ownership consensus contains a mismatched subject or invalid owner');
354
368
  }
369
+ if (consensus.status === 'consensus' && consensus.lock !== 'locked') {
370
+ throw Object.assign(new OwnershipError('CONSENSUS_MISMATCH', 'Ownership consensus reports an unlocked relation'), { retryable: false });
371
+ }
372
+ if (consensus.status === 'unavailable' && consensus.retryable === true) {
373
+ const reason = consensus.reason ?? 'Ownership consensus is temporarily unavailable';
374
+ this.handleRefreshFailure(subjectAid, subject, {
375
+ code: consensus.code ?? 'UNAVAILABLE', message: reason, retryable: true,
376
+ }, attemptedAt, attemptId);
377
+ return this.snapshot(subjectAid);
378
+ }
355
379
  const previousOwner = this.owner(subjectAid);
356
380
  const previousStatus = subject.view.consensusStatus;
357
381
  const checkedAt = this.now();
@@ -372,6 +396,7 @@ export class OwnerResolver {
372
396
  || subject.view.consensus?.relation_version !== consensus.relation_version
373
397
  || subject.view.consensus?.lock !== consensus.lock ? 1 : 0),
374
398
  reason: consensus.reason,
399
+ refreshFailure: undefined,
375
400
  synchronization: {
376
401
  status: consensus.status === 'unavailable' || consensus.status === 'not_applicable' ? 'unavailable'
377
402
  : consensus.status === 'invalid' ? 'conflict'
@@ -384,12 +409,12 @@ export class OwnerResolver {
384
409
  this.notify(subjectAid, ownerAid);
385
410
  if (ownerAid === subject.view.requestedOwnerAid && subject.view.requestStatus === 'interrupted') {
386
411
  subject.view.requestStatus = 'submitted';
387
- this.persist(subjectAid, subject);
388
412
  }
389
413
  }
390
414
  catch (error) {
391
415
  if (subject.client === client && subject.generation === generation && subject.epoch === epoch && subject.query === query) {
392
- this.invalidate(subject, error instanceof Error ? error.message : String(error));
416
+ const reason = error instanceof Error ? error.message : String(error);
417
+ this.handleRefreshFailure(subjectAid, subject, error, attemptedAt, attemptId);
393
418
  }
394
419
  }
395
420
  finally {
@@ -398,14 +423,75 @@ export class OwnerResolver {
398
423
  }
399
424
  return this.snapshot(subjectAid);
400
425
  }
426
+ handleRefreshFailure(subjectAid, subject, error, attemptedAt, attemptId) {
427
+ const details = error && typeof error === 'object' ? error : {};
428
+ const reason = error instanceof Error ? error.message
429
+ : typeof details.message === 'string' ? details.message : String(error);
430
+ const code = String(details.stringCode ?? details.code ?? 'OWNERSHIP_REFRESH_FAILED');
431
+ const transient = this.isTransientRefreshFailure(error);
432
+ // Keep the shared cache intact for ordinary callers; strict waiters receive
433
+ // an unavailable view for this attempt and cannot consume the fallback.
434
+ const fallbackUsed = transient && this.canUseTransientAuthorization(subject, attemptedAt);
435
+ if (!fallbackUsed)
436
+ this.invalidate(subject, reason);
437
+ subject.view.refreshFailure = {
438
+ kind: transient ? 'transient' : 'permanent', code, reason, attemptedAt, attemptId, fallbackUsed,
439
+ };
440
+ if (fallbackUsed) {
441
+ logger.warn(`[ownership] transient refresh failure subject=${subjectAid} attempt=${attemptId} code=${code} fallback=true reason=${reason}`);
442
+ }
443
+ else {
444
+ logger.warn(`[ownership] refresh failed subject=${subjectAid} attempt=${attemptId} code=${code} transient=${transient} reason=${reason}`);
445
+ }
446
+ }
447
+ unavailableSnapshot(subjectAid, failure) {
448
+ const view = this.snapshot(subjectAid);
449
+ view.ownerAid = undefined;
450
+ view.consensusStatus = 'unavailable';
451
+ view.authorizationStatus = 'unavailable';
452
+ view.validUntil = undefined;
453
+ view.reason = failure.reason;
454
+ view.synchronization = { status: 'unavailable' };
455
+ return view;
456
+ }
457
+ canUseTransientAuthorization(subject, attemptedAt) {
458
+ const view = subject.view;
459
+ const now = this.now();
460
+ return view.consensusStatus === 'consensus'
461
+ && !!view.ownerAid
462
+ && view.checkedAt !== undefined
463
+ && view.checkedAt <= attemptedAt
464
+ && view.checkedAt <= now
465
+ && now - view.checkedAt < OWNERSHIP_AUTHORIZATION_MAX_AGE_MS
466
+ && (view.validUntil ?? 0) > now;
467
+ }
468
+ isTransientRefreshFailure(error) {
469
+ const details = error && typeof error === 'object' ? error : {};
470
+ const code = String(details.code ?? details.stringCode ?? '').toUpperCase();
471
+ const message = error instanceof Error ? error.message
472
+ : typeof details.message === 'string' ? details.message : String(error);
473
+ if (['INVALID', 'CONFLICT', 'FORBIDDEN', 'NOT_APPLICABLE', 'OWNER_LOCKED', 'TASK_MISMATCH',
474
+ 'CONSENSUS_MISMATCH', 'UNSUPPORTED_PROTOCOL', 'PERSISTENCE_UNAVAILABLE'].includes(code))
475
+ return false;
476
+ if (/projection.*(?:does not match|conflict)|(?:agent|owner)\.md changed (?:while|during)|NS relation changed .*consensus check|digest does not match authoritative observation|Storage material changed during authoritative read/i.test(message))
477
+ return false;
478
+ if (details.retryable === false)
479
+ return false;
480
+ if (details.retryable === true)
481
+ return true;
482
+ return code === 'TIMEOUT' || code === 'NETWORK_ERROR' || code === 'UNAVAILABLE'
483
+ || code === 'CLIENT_DISCONNECTED' || code === 'CONNECTION_FAILED'
484
+ || code === 'GATEWAY_UNAVAILABLE' || code === 'CONNECTION_UNAVAILABLE'
485
+ || code === 'ECONNREFUSED' || code === 'ECONNRESET' || code === 'ETIMEDOUT'
486
+ || code === 'ENETUNREACH' || code === 'EAI_AGAIN' || code === 'ENOTFOUND' || code === 'EHOSTUNREACH'
487
+ || /timed out|timeout|connection reset|connection refused|connection closed|socket hang up|not connected|network error|fetch failed|gateway unavailable|gateway timeout|temporarily unavailable/i.test(message);
488
+ }
401
489
  async bind(subjectAid, ownerAid) {
402
- this.assertLocalSubject(subjectAid);
490
+ this.assertAid(subjectAid);
403
491
  this.assertAid(ownerAid);
404
492
  if (ownerAid === subjectAid)
405
493
  throw new OwnershipError('INVALID_OWNER', 'Subject cannot own itself');
406
494
  const subject = this.subject(subjectAid);
407
- if (subject.persistenceError)
408
- throw new OwnershipError('persistence_unavailable', subject.persistenceError);
409
495
  if (subject.binding) {
410
496
  if (subject.binding.ownerAid !== ownerAid)
411
497
  throw new OwnershipError('BIND_CONFLICT', 'Another owner binding is in progress');
@@ -421,93 +507,261 @@ export class OwnerResolver {
421
507
  subject.binding = undefined;
422
508
  }
423
509
  }
510
+ async decide(subjectAid, operationId, decision, reason) {
511
+ const subject = this.subject(subjectAid);
512
+ const generation = subject.generation;
513
+ logger.info(`[ownership] ${decision} requested local=${subjectAid} operationId=${operationId}`);
514
+ try {
515
+ return await this.withSubjectClient(subjectAid, async (client, attached) => {
516
+ // SDK 0.5.24 can recover an authenticated request from message history;
517
+ // this in-memory projection is only used to detect mismatched results.
518
+ const request = subject.requests.get(operationId) ?? subject.decisionTasks.get(operationId);
519
+ const result = decision === 'accept'
520
+ ? await client.acceptOwnershipRequest(operationId)
521
+ : await client.rejectOwnershipRequest(operationId, reason);
522
+ if (subject.generation !== generation || (attached && subject.client !== client)) {
523
+ throw new OwnershipError('CLIENT_REPLACED', 'Subject SDK client changed during approval; query status before retrying');
524
+ }
525
+ const task = this.bindingTask(result);
526
+ if (!task || (request && !this.sameBindingTask(task, request))) {
527
+ throw new OwnershipError('TASK_MISMATCH', 'SDK returned a different ownership decision task');
528
+ }
529
+ subject.view.decisionTask = task;
530
+ subject.decisionTasks.set(operationId, task);
531
+ subject.requests.delete(operationId);
532
+ delete subject.view.decisionConsensus;
533
+ if (task.sub_aid === subjectAid) {
534
+ this.invalidate(subject, 'Binding decision submitted; fresh consensus is required');
535
+ this.schedule(subjectAid, subject);
536
+ }
537
+ logger.info(`[ownership] ${decision} result ${JSON.stringify(task)}`);
538
+ if (task.status === 'error' || task.status === 'stopped' || (decision === 'accept' && task.status === 'rejected')) {
539
+ throw Object.assign(new OwnershipError(task.code ?? 'OWNERSHIP_DECISION_FAILED', task.reason ?? `Ownership task ${task.status}`), { retryable: task.retryable });
540
+ }
541
+ subject.view.decisionConsensus = await client.checkOwnershipConsensus(task.sub_aid);
542
+ return this.snapshot(subjectAid);
543
+ });
544
+ }
545
+ catch (error) {
546
+ logger.warn(`[ownership] ${decision} failed ${JSON.stringify({
547
+ localAid: subjectAid, operationId, error: error instanceof Error ? error.message : String(error),
548
+ })}`);
549
+ throw error;
550
+ }
551
+ }
552
+ async status(subjectAid) {
553
+ const subject = this.subject(subjectAid);
554
+ const generation = subject.generation;
555
+ return this.withSubjectClient(subjectAid, async (client, attached) => {
556
+ if (attached)
557
+ return this.refresh(subjectAid, { force: true });
558
+ const consensus = await client.checkOwnershipConsensus(subjectAid);
559
+ if (subject.generation !== generation) {
560
+ throw new OwnershipError('CLIENT_REPLACED', 'Subject state changed during status query; retry');
561
+ }
562
+ this.applyConsensus(subjectAid, subject, consensus);
563
+ return this.snapshot(subjectAid);
564
+ });
565
+ }
566
+ async withSubjectClient(subjectAid, operation) {
567
+ const subject = this.subject(subjectAid);
568
+ if (subject.client && subject.view.connected)
569
+ return operation(subject.client, true);
570
+ let store;
571
+ let client;
572
+ try {
573
+ const sdkStore = await import('../../aun/aid/store.js');
574
+ store = await sdkStore.getAidStore({ slotId: `ownership-${randomUUID()}` });
575
+ const loaded = store.load(subjectAid);
576
+ if (!loaded.ok || !loaded.data.aid.isCertValid() || !loaded.data.aid.isPrivateKeyValid()) {
577
+ throw new OwnershipError('SUBJECT_UNAVAILABLE', loaded.ok
578
+ ? 'Subject local certificate or private key is unavailable'
579
+ : `Subject local credentials are unavailable: ${loaded.error.message}`);
580
+ }
581
+ client = await sdkStore.loadClient(store, subjectAid);
582
+ if (client.currentAid?.aid !== subjectAid || !client.currentAid.isCertValid() || !client.currentAid.isPrivateKeyValid()) {
583
+ throw new OwnershipError('SUBJECT_MISMATCH', 'Loaded SDK identity does not match the requested subject');
584
+ }
585
+ await client.connect({
586
+ connection_kind: 'short', short_ttl_ms: 120_000, auto_reconnect: false, background_sync: false,
587
+ connect_timeout: 10, call_timeout: 30,
588
+ });
589
+ return await operation(client, false);
590
+ }
591
+ finally {
592
+ if (client) {
593
+ try {
594
+ await client.close();
595
+ }
596
+ catch { }
597
+ }
598
+ try {
599
+ store?.close();
600
+ }
601
+ catch { }
602
+ }
603
+ }
424
604
  stop() {
425
605
  for (const subjectAid of this.subjects.keys())
426
606
  this.detachClient(subjectAid);
427
607
  this.configuredOwners.clear();
428
608
  }
429
609
  async submitBinding(subjectAid, ownerAid, subject) {
430
- const current = await this.refresh(subjectAid, { force: true });
431
- if (current.consensusStatus === 'consensus') {
432
- if (current.ownerAid === ownerAid)
433
- return current;
434
- throw new OwnershipError('OWNER_ALREADY_BOUND', 'Subject already has a protocol owner; transfer is not supported');
435
- }
436
- const recovering = (subject.view.task?.status === 'pending' || subject.view.requestStatus === 'interrupted')
437
- && subject.view.requestedOwnerAid === ownerAid;
438
- if (!['unbound', 'released'].includes(current.consensusStatus)
439
- && !(recovering && current.consensusStatus === 'single_sided')) {
440
- throw new OwnershipError('CONSENSUS_REQUIRED', 'Binding requires a fresh unbound or released consensus result');
441
- }
442
- if (subject.view.task?.status === 'pending') {
443
- if (subject.view.task.owner_aid !== ownerAid)
444
- throw new OwnershipError('BIND_CONFLICT', 'A different binding task is pending');
445
- }
446
- if (subject.view.requestStatus === 'interrupted' && subject.view.requestedOwnerAid !== ownerAid) {
447
- throw new OwnershipError('BIND_UNCERTAIN', 'Retry the interrupted request with its original owner before changing the target');
448
- }
449
610
  const client = subject.client;
450
- if (!client || !subject.view.connected)
451
- throw new OwnershipError('SUBJECT_UNAVAILABLE', 'Subject AID SDK client is not ready');
452
611
  const generation = subject.generation;
453
612
  subject.view = { ...subject.view, requestedOwnerAid: ownerAid, requestStatus: 'requesting', task: undefined };
454
- this.persist(subjectAid, subject);
455
613
  try {
614
+ const local = await this.tryLocalBind(subjectAid, ownerAid);
615
+ if (local) {
616
+ if (client && (subject.client !== client || subject.generation !== generation)) {
617
+ throw new OwnershipError('CLIENT_REPLACED', 'Subject SDK client changed during local binding');
618
+ }
619
+ subject.view.task = local.task;
620
+ this.applyConsensus(subjectAid, subject, local.consensus);
621
+ subject.view.requestStatus = undefined;
622
+ subject.view.reason = undefined;
623
+ return this.snapshot(subjectAid);
624
+ }
625
+ if (!client || !subject.view.connected)
626
+ throw new OwnershipError('SUBJECT_UNAVAILABLE', 'Subject AID SDK client is not ready');
627
+ const current = await this.refresh(subjectAid, { force: true });
628
+ if (current.consensusStatus === 'consensus') {
629
+ if (current.ownerAid === ownerAid)
630
+ return current;
631
+ throw new OwnershipError('OWNER_ALREADY_BOUND', 'Subject already has a protocol owner; transfer is not supported');
632
+ }
633
+ if (!['unbound', 'released', 'single_sided'].includes(current.consensusStatus)) {
634
+ throw new OwnershipError('CONSENSUS_REQUIRED', 'Binding requires a fresh readable ownership baseline');
635
+ }
456
636
  const task = this.task(await client.bindOwneraid(ownerAid));
457
637
  if (subject.client !== client || subject.generation !== generation) {
458
638
  throw new OwnershipError('CLIENT_REPLACED', 'Subject SDK client changed during binding; the request may have been submitted');
459
639
  }
460
640
  if (!task || task.sub_aid !== subjectAid || task.owner_aid !== ownerAid || task.operation !== 'bind') {
461
- throw new OwnershipError('TASK_MISMATCH', 'SDK returned a binding task for a different subject or owner');
641
+ throw new OwnershipError('TASK_MISMATCH', 'SDK returned a binding result for a different subject or owner');
462
642
  }
463
643
  subject.view.task = task;
464
- subject.view.requestStatus = 'submitted';
465
- this.persist(subjectAid, subject);
644
+ subject.view.requestStatus = task.status === 'error' ? 'error' : 'submitted';
645
+ subject.view.reason = task.reason;
646
+ if (task.status === 'error' || task.status === 'rejected' || task.status === 'stopped') {
647
+ throw Object.assign(new OwnershipError(task.code ?? 'BIND_FAILED', task.reason ?? `Binding ended with ${task.status}`), {
648
+ retryable: task.retryable,
649
+ });
650
+ }
466
651
  this.invalidate(subject, 'Binding submitted; waiting for explicit owner approval and fresh consensus');
467
652
  this.schedule(subjectAid, subject);
468
653
  return this.snapshot(subjectAid);
469
654
  }
470
655
  catch (error) {
471
- if (subject.client === client && subject.generation === generation) {
656
+ if (!client || (subject.client === client && subject.generation === generation)) {
472
657
  subject.view.requestStatus = 'interrupted';
473
658
  subject.view.reason = error instanceof Error ? error.message : String(error);
474
- this.persist(subjectAid, subject);
475
659
  }
476
660
  throw error;
477
661
  }
478
662
  }
479
- subject(subjectAid) {
480
- this.assertAid(subjectAid);
481
- let subject = this.subjects.get(subjectAid);
482
- if (subject)
483
- return subject;
484
- let saved = null;
485
- let persistenceError;
663
+ async tryLocalBind(subjectAid, ownerAid) {
664
+ let store;
665
+ let ownerClient;
666
+ let subClient;
667
+ let invoked = false;
486
668
  try {
487
- const filename = this.statePath(subjectAid);
488
- saved = atomicReadJson(filename);
489
- const savedTask = saved?.task !== undefined ? this.task(saved.task) : undefined;
490
- if ((saved === null && fs.existsSync(filename)) || (saved && (saved.version !== 1 || saved.subjectAid !== subjectAid
491
- || (saved.requestedOwnerAid !== undefined && (typeof saved.requestedOwnerAid !== 'string' || !isValidAid(saved.requestedOwnerAid)))
492
- || (saved.task !== undefined && (!savedTask || savedTask.sub_aid !== subjectAid || savedTask.owner_aid !== saved.requestedOwnerAid))
493
- || (saved.requestStatus !== undefined && !['requesting', 'submitted', 'interrupted', 'error'].includes(String(saved.requestStatus)))))) {
494
- throw new Error('Invalid persisted ownership task');
669
+ const sdkStore = await import('../../aun/aid/store.js');
670
+ store = await sdkStore.getAidStore({ slotId: sdkStore.SLOT.cli });
671
+ const ownerIdentity = store.load(ownerAid);
672
+ const subIdentity = store.load(subjectAid);
673
+ if (!ownerIdentity.ok || !subIdentity.ok
674
+ || !ownerIdentity.data.aid.isPrivateKeyValid() || !subIdentity.data.aid.isPrivateKeyValid())
675
+ return undefined;
676
+ ownerClient = await sdkStore.loadClient(store, ownerAid);
677
+ subClient = await sdkStore.loadClient(store, subjectAid);
678
+ if (typeof ownerClient.bindLocalSubaid !== 'function'
679
+ || ownerClient.getType() !== 'agent' || subClient.getType() !== 'agent')
680
+ return undefined;
681
+ invoked = true;
682
+ const task = this.task(await ownerClient.bindLocalSubaid(subClient));
683
+ if (!task || task.operation !== 'bind' || task.sub_aid !== subjectAid || task.owner_aid !== ownerAid
684
+ || task.status !== 'completed') {
685
+ throw Object.assign(new OwnershipError(task?.code ?? 'LOCAL_BIND_FAILED', task?.reason ?? 'Local binding did not complete'), { retryable: task?.retryable });
686
+ }
687
+ const consensus = await ownerClient.checkOwnershipConsensus(subjectAid);
688
+ if (consensus.status !== 'consensus' || consensus.owner_aid !== ownerAid
689
+ || consensus.sub_aid !== subjectAid || consensus.relation_version !== task.target_relation_version
690
+ || consensus.lock !== 'locked') {
691
+ throw new OwnershipError('CONSENSUS_REQUIRED', 'Local binding did not produce the expected fresh ownership consensus');
495
692
  }
693
+ return { task, consensus };
496
694
  }
497
695
  catch (error) {
498
- saved = null;
499
- persistenceError = `Ownership task storage is unavailable: ${error instanceof Error ? error.message : String(error)}`;
696
+ if (!invoked) {
697
+ logger.debug(`[ownership] local identity pair unavailable for ${subjectAid}/${ownerAid}: ${error instanceof Error ? error.message : String(error)}`);
698
+ return undefined;
699
+ }
700
+ throw error;
500
701
  }
501
- const task = saved?.subjectAid === subjectAid ? this.task(saved.task) : undefined;
702
+ finally {
703
+ if (ownerClient) {
704
+ try {
705
+ await ownerClient.close();
706
+ }
707
+ catch { }
708
+ }
709
+ if (subClient) {
710
+ try {
711
+ await subClient.close();
712
+ }
713
+ catch { }
714
+ }
715
+ try {
716
+ store?.close();
717
+ }
718
+ catch { }
719
+ }
720
+ }
721
+ applyConsensus(subjectAid, subject, consensus) {
722
+ if (consensus.sub_aid !== subjectAid)
723
+ throw new OwnershipError('TASK_MISMATCH', 'Ownership consensus subject does not match');
724
+ const ownerAid = consensus.status === 'consensus' && typeof consensus.owner_aid === 'string'
725
+ && isValidAid(consensus.owner_aid) && consensus.owner_aid !== subjectAid ? consensus.owner_aid : undefined;
726
+ if (consensus.status === 'consensus' && !ownerAid)
727
+ throw new OwnershipError('TASK_MISMATCH', 'Ownership consensus owner is invalid');
728
+ const previousOwner = this.owner(subjectAid);
729
+ const previousStatus = subject.view.consensusStatus;
730
+ const checkedAt = this.now();
731
+ const pending = subject.view.task?.status === 'pending' || subject.view.requestStatus === 'requesting';
732
+ subject.view = {
733
+ ...subject.view, ownerAid,
734
+ relation: consensus.status === 'consensus' || consensus.status === 'unbound' || consensus.status === 'released'
735
+ ? { consensusStatus: consensus.status, ownerAid, checkedAt, relationVersion: consensus.relation_version }
736
+ : subject.view.relation,
737
+ authorizationStatus: ownerAid ? 'verified' : 'unavailable', consensusStatus: consensus.status,
738
+ consensus: JSON.parse(JSON.stringify(consensus)),
739
+ checkedAt, validUntil: checkedAt + (this.options.lifetimeMs ?? OWNERSHIP_CACHE_MAX_AGE_MS),
740
+ revision: subject.view.revision + (previousOwner !== ownerAid || subject.view.consensusStatus !== consensus.status
741
+ || subject.view.consensus?.relation_version !== consensus.relation_version
742
+ || subject.view.consensus?.lock !== consensus.lock ? 1 : 0),
743
+ reason: consensus.reason,
744
+ synchronization: {
745
+ status: consensus.status === 'unavailable' || consensus.status === 'not_applicable' ? 'unavailable'
746
+ : consensus.status === 'invalid' ? 'conflict'
747
+ : ownerAid && subject.view.requestedOwnerAid && subject.view.requestedOwnerAid !== ownerAid ? 'conflict'
748
+ : consensus.status === 'single_sided' || (!ownerAid && pending) ? 'pending' : 'synced',
749
+ runtimeOwnerAid: ownerAid,
750
+ },
751
+ };
752
+ if (previousOwner !== ownerAid || previousStatus !== consensus.status)
753
+ this.notify(subjectAid, ownerAid);
754
+ }
755
+ subject(subjectAid) {
756
+ this.assertAid(subjectAid);
757
+ let subject = this.subjects.get(subjectAid);
758
+ if (subject)
759
+ return subject;
502
760
  subject = {
503
- generation: 0, epoch: 0, query: 0, subscriptions: [], taskEvents: new Set(), persistenceError,
761
+ generation: 0, epoch: 0, query: 0, subscriptions: [], taskEvents: new Set(),
762
+ requests: new Map(), decisionTasks: new Map(),
504
763
  view: {
505
764
  subjectAid, consensusStatus: 'unavailable', authorizationStatus: 'unavailable', connected: false, revision: 0,
506
- task: task?.sub_aid === subjectAid ? task : undefined,
507
- requestedOwnerAid: saved?.subjectAid === subjectAid && typeof saved.requestedOwnerAid === 'string'
508
- && isValidAid(saved.requestedOwnerAid) ? saved.requestedOwnerAid : undefined,
509
- requestStatus: saved?.requestStatus === 'requesting' || saved?.requestStatus === 'interrupted' ? 'interrupted'
510
- : task ? 'submitted' : undefined,
511
765
  synchronization: { status: 'unavailable' },
512
766
  },
513
767
  };
@@ -519,7 +773,8 @@ export class OwnerResolver {
519
773
  subject.epoch += 1;
520
774
  subject.view = {
521
775
  ...subject.view, ownerAid: undefined, consensusStatus: 'unavailable', authorizationStatus: 'unavailable', validUntil: undefined,
522
- revision: subject.view.revision + 1, reason, synchronization: { status: 'unavailable' },
776
+ revision: subject.view.revision + 1, reason, refreshFailure: undefined,
777
+ synchronization: { status: 'unavailable' },
523
778
  };
524
779
  if (previousOwner)
525
780
  this.notify(subject.view.subjectAid);
@@ -544,63 +799,17 @@ export class OwnerResolver {
544
799
  }
545
800
  }
546
801
  }
547
- schedule(subjectAid, subject) {
802
+ schedule(subjectAid, subject, allowTransient = true) {
548
803
  if (subject.timer || !subject.client || !subject.view.connected)
549
804
  return;
550
805
  subject.timer = setTimeout(() => {
551
806
  subject.timer = undefined;
552
- void this.refresh(subjectAid, { force: true }).catch(error => {
807
+ void this.refresh(subjectAid, { force: true, allowTransient }).catch(error => {
553
808
  logger.warn(`[ownership] Event refresh failed for ${subjectAid}: ${error instanceof Error ? error.message : String(error)}`);
554
809
  });
555
810
  }, 0);
556
811
  subject.timer.unref?.();
557
812
  }
558
- schedulePendingRefresh(subjectAid, subject) {
559
- // The owner's final publication can become readable after the last SDK
560
- // event. A cached unbound/single-sided observation must not strand an
561
- // approved creation in ownerpending until the next connection or command.
562
- // Only fresh SDK consensus can authorize the existing owner-bound handler;
563
- // this never resubmits a binding or infers ownership from task completion.
564
- const needsRefresh = subject.client && subject.view.connected
565
- && subject.view.requestedOwnerAid && subject.view.consensusStatus !== 'consensus'
566
- && (subject.view.task?.status === 'pending' || subject.view.task?.status === 'completed'
567
- || subject.view.requestStatus === 'requesting' || subject.view.requestStatus === 'interrupted');
568
- if (!needsRefresh) {
569
- if (subject.pendingRefreshTimer)
570
- clearTimeout(subject.pendingRefreshTimer);
571
- subject.pendingRefreshTimer = undefined;
572
- return;
573
- }
574
- if (subject.pendingRefreshTimer)
575
- return;
576
- subject.pendingRefreshTimer = setTimeout(() => {
577
- subject.pendingRefreshTimer = undefined;
578
- void this.refresh(subjectAid, { force: true }).catch(error => {
579
- logger.warn(`[ownership] Pending consensus refresh failed for ${subjectAid}: ${error instanceof Error ? error.message : String(error)}`);
580
- });
581
- }, OWNERSHIP_PENDING_REFRESH_MS);
582
- subject.pendingRefreshTimer.unref?.();
583
- }
584
- statePath(subjectAid) {
585
- return path.join(this.options.stateDirectory?.() ?? path.join(resolvePaths().root, 'data', 'ownership'), `${subjectAid}.json`);
586
- }
587
- persist(subjectAid, subject) {
588
- if (!subject.persistenceError) {
589
- try {
590
- const filename = this.statePath(subjectAid);
591
- fs.mkdirSync(path.dirname(filename), { recursive: true });
592
- atomicWriteJson(filename, {
593
- version: 1, subjectAid, requestedOwnerAid: subject.view.requestedOwnerAid,
594
- requestStatus: subject.view.requestStatus, task: subject.view.task,
595
- });
596
- return;
597
- }
598
- catch (error) {
599
- subject.persistenceError = `Ownership task storage is unavailable: ${error instanceof Error ? error.message : String(error)}`;
600
- }
601
- }
602
- throw new OwnershipError('persistence_unavailable', subject.persistenceError);
603
- }
604
813
  now() {
605
814
  return this.options.now?.() ?? Date.now();
606
815
  }
@@ -639,15 +848,107 @@ export class OwnerResolver {
639
848
  record(value) {
640
849
  return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
641
850
  }
642
- task(value) {
851
+ trackBindingRequest(localAid, subject, event, record) {
852
+ const requestAction = record.action === 'bind.request' || record.action === 'transfer.request';
853
+ if (event === 'ownership.request'
854
+ && (record.operation !== 'bind' || (record.action !== undefined && !requestAction)))
855
+ return;
856
+ const task = this.bindingTask(event === 'ownership.request'
857
+ ? { ...record, status: 'pending', step: 'approval' }
858
+ : record, event);
859
+ if (!task || (task.sub_aid !== localAid && task.owner_aid !== localAid))
860
+ return;
861
+ const request = subject.requests.get(task.operation_id);
862
+ const decisionTask = subject.decisionTasks.get(task.operation_id);
863
+ if ((request && !this.sameBindingTask(task, request))
864
+ || (decisionTask && !this.sameBindingTask(task, decisionTask)))
865
+ return;
866
+ if (event === 'ownership.request') {
867
+ const requester = record.requester_aid;
868
+ const remote = task.sub_aid === localAid ? task.owner_aid : task.sub_aid;
869
+ if (task.status !== 'pending' || task.step !== 'approval' || requester !== remote || decisionTask)
870
+ return;
871
+ subject.requests.set(task.operation_id, {
872
+ ...task, requester_aid: remote, local_role: task.sub_aid === localAid ? 'sub' : 'owner',
873
+ });
874
+ logger.info(`[ownership] inbound binding ${JSON.stringify({
875
+ localAid, operationId: task.operation_id, subAid: task.sub_aid, ownerAid: task.owner_aid,
876
+ })}`);
877
+ }
878
+ else {
879
+ const awaitingApproval = task.step === 'approval'
880
+ && (task.status === 'pending' || (task.status === 'error' && task.retryable === true))
881
+ && !['ownership.completed', 'ownership.rejected', 'ownership.stopped'].includes(event);
882
+ if (request && awaitingApproval)
883
+ subject.requests.set(task.operation_id, { ...request, ...task });
884
+ else
885
+ subject.requests.delete(task.operation_id);
886
+ }
887
+ const retryableDecision = task.step !== 'approval'
888
+ && (task.status === 'pending' || (task.status === 'error' && task.retryable === true))
889
+ && !['ownership.completed', 'ownership.rejected', 'ownership.stopped'].includes(event);
890
+ if (decisionTask || retryableDecision) {
891
+ subject.decisionTasks.set(task.operation_id, task);
892
+ subject.view.decisionTask = task;
893
+ }
894
+ else if (subject.view.decisionTask?.operation_id === task.operation_id) {
895
+ subject.view.decisionTask = task;
896
+ }
897
+ }
898
+ sameBindingTask(left, right) {
899
+ return left.operation_id === right.operation_id && left.sub_aid === right.sub_aid
900
+ && left.owner_aid === right.owner_aid && left.new_owner_aid === right.new_owner_aid
901
+ && left.base_relation_version === right.base_relation_version
902
+ && left.target_relation_version === right.target_relation_version;
903
+ }
904
+ /** Only public task fields are exposed; raw SDK records remain SDK-owned. */
905
+ bindingTask(value, event) {
906
+ const task = this.task(value, event);
907
+ if (!task || task.operation !== 'bind' || !CREATION_BIND_OPERATION_ID.test(task.operation_id)
908
+ || task.sub_aid === task.owner_aid || !Number.isSafeInteger(task.base_relation_version)
909
+ || task.base_relation_version < 0 || !Number.isSafeInteger(task.target_relation_version)
910
+ || task.target_relation_version !== task.base_relation_version + 1)
911
+ return undefined;
912
+ if (task.new_owner_aid !== undefined)
913
+ return undefined;
914
+ return {
915
+ operation_id: task.operation_id, operation: task.operation, sub_aid: task.sub_aid, owner_aid: task.owner_aid,
916
+ ...(task.new_owner_aid ? { new_owner_aid: task.new_owner_aid } : {}),
917
+ base_relation_version: task.base_relation_version, target_relation_version: task.target_relation_version,
918
+ status: task.status, step: task.step,
919
+ ...(typeof task.code === 'string' ? { code: task.code } : {}),
920
+ ...(typeof task.reason === 'string' ? { reason: task.reason } : {}),
921
+ ...(typeof task.retryable === 'boolean' ? { retryable: task.retryable } : {}),
922
+ };
923
+ }
924
+ task(value, event) {
643
925
  const record = this.record(value);
926
+ const baseRelationVersion = record?.base_relation_version;
927
+ const targetRelationVersion = record?.target_relation_version;
644
928
  if (!record || typeof record.operation_id !== 'string' || !record.operation_id
645
- || record.operation !== 'bind' || typeof record.sub_aid !== 'string' || !isValidAid(record.sub_aid)
929
+ || !['bind', 'transfer'].includes(String(record.operation)) || typeof record.sub_aid !== 'string' || !isValidAid(record.sub_aid)
646
930
  || typeof record.owner_aid !== 'string' || !isValidAid(record.owner_aid)
647
- || !['pending', 'completed', 'rejected', 'stopped', 'error'].includes(String(record.status))
648
- || typeof record.step !== 'string')
931
+ || (record.new_owner_aid !== undefined && (typeof record.new_owner_aid !== 'string' || !isValidAid(record.new_owner_aid)))
932
+ || typeof baseRelationVersion !== 'number' || !Number.isSafeInteger(baseRelationVersion) || baseRelationVersion < 0
933
+ || typeof targetRelationVersion !== 'number' || !Number.isSafeInteger(targetRelationVersion)
934
+ || targetRelationVersion !== baseRelationVersion + 1)
649
935
  return undefined;
650
- return JSON.parse(JSON.stringify(record));
936
+ const status = ['pending', 'completed', 'rejected', 'stopped', 'error'].includes(String(record.status))
937
+ ? record.status
938
+ : event === 'ownership.rejected' ? 'rejected'
939
+ : event === 'ownership.completed' ? 'completed' : 'pending';
940
+ const step = typeof record.step === 'string' ? record.step
941
+ : event === 'ownership.request' ? 'approval' : 'local_actions';
942
+ return {
943
+ operation_id: record.operation_id, operation: record.operation,
944
+ sub_aid: record.sub_aid, owner_aid: record.owner_aid,
945
+ ...(typeof record.new_owner_aid === 'string' ? { new_owner_aid: record.new_owner_aid } : {}),
946
+ base_relation_version: baseRelationVersion, target_relation_version: targetRelationVersion,
947
+ status, step,
948
+ ...(typeof record.code === 'string' ? { code: record.code } : {}),
949
+ ...(typeof record.reason === 'string' ? { reason: record.reason } : {}),
950
+ ...(typeof record.retryable === 'boolean' ? { retryable: record.retryable } : {}),
951
+ };
651
952
  }
652
953
  }
653
954
  export const ownerResolver = new OwnerResolver();
@@ -662,10 +963,17 @@ export async function refreshOwnershipForAuthorization(subjectAid, options = {})
662
963
  const ownershipSubjectAid = daemonAid && subjectAid === `${daemonAid}#daemon` ? daemonAid : subjectAid;
663
964
  const subjects = new Set([ownershipSubjectAid, ...(!ownershipSubjectAid || options.includeDaemon !== false ? [daemonAid] : [])]
664
965
  .filter((aid) => !!aid));
665
- await Promise.all([...subjects].map(aid => ownerResolver.refresh(aid, {
966
+ const views = await Promise.all([...subjects].map(aid => ownerResolver.refresh(aid, {
666
967
  maxAgeMs: options.maxAgeMs ?? OWNERSHIP_AUTHORIZATION_MAX_AGE_MS,
667
968
  force: options.force,
969
+ allowTransient: options.allowTransient,
668
970
  })));
971
+ if (!options.allowTransient) {
972
+ const failure = views.find(view => view.consensusStatus === 'unavailable' && view.refreshFailure);
973
+ if (failure?.refreshFailure) {
974
+ throw new OwnershipError('OWNER_REFRESH_FAILED', `Ownership refresh failed (${failure.refreshFailure.code}): ${failure.refreshFailure.reason}`);
975
+ }
976
+ }
669
977
  }
670
978
  export async function assertOwnershipAuthorization(context) {
671
979
  const authorization = context?.executionPermission;
@@ -673,15 +981,32 @@ export async function assertOwnershipAuthorization(context) {
673
981
  const ownerActor = hasTrustedPrincipal(context?.actor) ? context.actor.principalId : undefined;
674
982
  if (!authorization && !ownerRole)
675
983
  return;
676
- await refreshOwnershipForAuthorization(context?.selfAid ?? authorization?.agentAid);
984
+ await refreshOwnershipForAuthorization(context?.selfAid ?? authorization?.agentAid, { allowTransient: true });
985
+ const subjectAid = context?.selfAid ?? authorization?.agentAid;
677
986
  if (authorization) {
678
987
  if (!getDaemonOwners().includes(authorization.authorizedBy)
679
988
  || (authorization.source === 'agent-policy'
680
989
  && !ownerResolver.isOwner(authorization.agentAid ?? '', authorization.authorizedBy))) {
990
+ const view = subjectAid ? ownerResolver.snapshot(subjectAid) : undefined;
991
+ logger.warn(`[ownership] authorization denied ${JSON.stringify({
992
+ subjectAid, authorizedBy: authorization.authorizedBy,
993
+ consensusStatus: view?.consensusStatus,
994
+ failureCode: view?.refreshFailure?.code,
995
+ refreshFailure: view?.refreshFailure?.reason,
996
+ refreshFailureKind: view?.refreshFailure?.kind,
997
+ })}`);
681
998
  throw new OwnershipError('OWNER_UNAVAILABLE', 'Current Owner no longer authorizes this execution');
682
999
  }
683
1000
  }
684
1001
  else if (!context?.selfAid || !ownerResolver.isOwner(context.selfAid, ownerActor)) {
1002
+ const view = context?.selfAid ? ownerResolver.snapshot(context.selfAid) : undefined;
1003
+ logger.warn(`[ownership] authorization denied ${JSON.stringify({
1004
+ subjectAid: context?.selfAid, actorAid: ownerActor,
1005
+ consensusStatus: view?.consensusStatus,
1006
+ failureCode: view?.refreshFailure?.code,
1007
+ refreshFailure: view?.refreshFailure?.reason,
1008
+ refreshFailureKind: view?.refreshFailure?.kind,
1009
+ })}`);
685
1010
  throw new OwnershipError('OWNER_UNAVAILABLE', 'Current Owner could not be verified; start a new task with the current role');
686
1011
  }
687
1012
  }