evolcore 0.0.39 → 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 (102) hide show
  1. package/CHANGELOG.md +49 -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 +51 -27
  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/client.js +1 -1
  11. package/dist/aun/aid/control-aid.js +23 -98
  12. package/dist/aun/aid/ownership-operation.js +1 -3
  13. package/dist/aun/group/invite-service.js +6 -0
  14. package/dist/aun/msg/p2p.js +8 -12
  15. package/dist/channels/aun.js +66 -48
  16. package/dist/channels/dingtalk.js +8 -2
  17. package/dist/channels/feishu.js +6 -1
  18. package/dist/channels/wecom.js +7 -3
  19. package/dist/cli/agent-create.js +23 -4
  20. package/dist/cli/aid-bind-command.js +4 -1
  21. package/dist/cli/daemon-commands.js +242 -17
  22. package/dist/cli/index.js +25 -13
  23. package/dist/cli/memory-command.js +33 -14
  24. package/dist/cli/restart-monitor.js +35 -13
  25. package/dist/cli/task-context.js +1 -0
  26. package/dist/config/config-field-policy.js +1 -0
  27. package/dist/contracts/ecweb-service-protocol.js +44 -0
  28. package/dist/core/agent-runtime.js +6 -0
  29. package/dist/core/auth/agent-delegation.js +7 -4
  30. package/dist/core/auth/auth-gateway.js +3 -1
  31. package/dist/core/auth/authorization-audit.js +5 -1
  32. package/dist/core/auth/debug-context.js +5 -1
  33. package/dist/core/auth/operation-catalog.js +16 -0
  34. package/dist/core/command/command-handler.js +7 -1
  35. package/dist/core/command/menu-catalog.js +48 -12
  36. package/dist/core/command/menu-handler.js +72 -4
  37. package/dist/core/command/menu-protocol.js +20 -1
  38. package/dist/core/command/slash-gate.js +17 -3
  39. package/dist/core/command/slash-handler.js +90 -7
  40. package/dist/core/event-catalog.js +2 -0
  41. package/dist/core/interaction-router.js +2 -1
  42. package/dist/core/ipc-registration.js +26 -7
  43. package/dist/core/memory/batch-meta.js +2 -0
  44. package/dist/core/memory/consolidation-context.js +42 -51
  45. package/dist/core/memory/consolidation-ops-records.js +39 -7
  46. package/dist/core/memory/consolidation-trigger.js +71 -64
  47. package/dist/core/memory/consolidation.js +226 -20
  48. package/dist/core/memory/memory-writer.js +160 -34
  49. package/dist/core/memory/relation-idle-monitor.js +287 -0
  50. package/dist/core/memory/relation-sweep.js +206 -0
  51. package/dist/core/message/aun-ipc-sender.js +5 -0
  52. package/dist/core/message/message-bridge.js +64 -55
  53. package/dist/core/message/message-log.js +17 -0
  54. package/dist/core/message/message-queue.js +6 -0
  55. package/dist/core/message/outbound.js +70 -0
  56. package/dist/core/message/peer-mode.js +7 -2
  57. package/dist/core/message/response-engine.js +341 -73
  58. package/dist/core/ownership/owner-resolver.js +384 -178
  59. package/dist/core/permission/ec-command-parser.js +19 -3
  60. package/dist/core/permission/tool-policy.js +7 -7
  61. package/dist/core/role/runtime-policy.js +64 -0
  62. package/dist/core/session/session-manager.js +25 -5
  63. package/dist/core/system-session/memory.js +3 -18
  64. package/dist/ecweb/basic-settings-service.js +709 -0
  65. package/dist/ecweb/providers-service.js +172 -0
  66. package/dist/index.js +92 -4
  67. package/dist/ipc.js +78 -11
  68. package/dist/stats/db.js +33 -0
  69. package/dist/stats/index.js +2 -2
  70. package/dist/stats/query.js +246 -0
  71. package/dist/stats/writer.js +41 -1
  72. package/dist/trigger/application-service.js +12 -0
  73. package/dist/trigger/authorization.js +2 -2
  74. package/dist/trigger/history.js +36 -0
  75. package/dist/trigger/scheduler.js +3 -0
  76. package/dist/utils/ecweb-supervisor.js +31 -10
  77. package/dist/utils/error-utils.js +8 -4
  78. package/dist/utils/evolcore-version.js +3 -2
  79. package/dist/utils/host-info.js +52 -0
  80. package/ecagent/dist/harness/agent-harness.js +4 -1
  81. package/kits/docs/evolcore/INDEX.md +1 -1
  82. package/kits/docs/evolcore/agent.md +1 -1
  83. package/kits/docs/evolcore/aid.md +29 -12
  84. package/kits/docs/evolcore/config.md +9 -7
  85. package/kits/docs/evolcore/memory-architecture.md +3 -4
  86. package/kits/docs/evolcore/memory-consolidation.md +28 -7
  87. package/kits/docs/evolcore/memory-tags.md +1 -1
  88. package/kits/docs/evolcore/memory.md +1 -1
  89. package/kits/docs/evolcore/menu-client.md +1 -1
  90. package/kits/docs/evolcore/menu-protocol.md +6 -0
  91. package/kits/docs/evolcore/msg.md +1 -1
  92. package/kits/eck_manifest.json +3 -3
  93. package/kits/rules/04-relation.md +3 -2
  94. package/kits/schemas/agent-config.schema.13.json +5 -1
  95. package/kits/templates/memory-consolidation/generalize.md +32 -4
  96. package/kits/templates/memory-consolidation/notes.md +2 -2
  97. package/kits/templates/memory-consolidation/promote.md +10 -2
  98. package/kits/templates/system-fragments/baseagent.md +5 -0
  99. package/kits/templates/system-fragments/memory.md +12 -3
  100. package/kits/templates/system-fragments/session.md +1 -1
  101. package/kits/{rules/07-memory-and-thread.md → templates/system-fragments/thread.md} +17 -80
  102. 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) {
@@ -134,7 +136,7 @@ export class OwnerResolver {
134
136
  subject.taskEvents.delete(subject.taskEvents.values().next().value);
135
137
  }
136
138
  this.invalidate(subject, 'Ownership changed; fresh consensus is required');
137
- const task = this.task(record);
139
+ const task = this.task(record, event);
138
140
  logger.info(`[ownership] ${event} ${JSON.stringify({ subjectAid, ownerAid: task?.owner_aid,
139
141
  operationId: task?.operation_id, taskStatus: task?.status, step: task?.step,
140
142
  code: task?.code, reason: task?.reason })}`);
@@ -144,10 +146,6 @@ export class OwnerResolver {
144
146
  subject.view.task = task;
145
147
  subject.view.requestStatus = 'submitted';
146
148
  }
147
- try {
148
- this.persist(subjectAid, subject);
149
- }
150
- catch { }
151
149
  this.schedule(subjectAid, subject);
152
150
  }));
153
151
  }
@@ -200,9 +198,6 @@ export class OwnerResolver {
200
198
  if (subject.timer)
201
199
  clearTimeout(subject.timer);
202
200
  subject.timer = undefined;
203
- if (subject.pendingRefreshTimer)
204
- clearTimeout(subject.pendingRefreshTimer);
205
- subject.pendingRefreshTimer = undefined;
206
201
  }
207
202
  subject.view.connected = connected;
208
203
  if (connected)
@@ -216,9 +211,6 @@ export class OwnerResolver {
216
211
  if (subject.timer)
217
212
  clearTimeout(subject.timer);
218
213
  subject.timer = undefined;
219
- if (subject.pendingRefreshTimer)
220
- clearTimeout(subject.pendingRefreshTimer);
221
- subject.pendingRefreshTimer = undefined;
222
214
  subject.taskEvents.clear();
223
215
  subject.requests.clear();
224
216
  subject.decisionTasks.clear();
@@ -293,12 +285,6 @@ export class OwnerResolver {
293
285
  && view.checkedAt !== undefined && view.checkedAt <= now
294
286
  ? view.ownerAid && now - view.checkedAt < OWNERSHIP_AUTHORIZATION_MAX_AGE_MS ? 'verified' : 'expired'
295
287
  : 'unavailable';
296
- if (subject.persistenceError) {
297
- view.requestStatus = 'error';
298
- view.reason = subject.persistenceError;
299
- view.synchronization.status = 'unavailable';
300
- view.authorizationStatus = 'unavailable';
301
- }
302
288
  return view;
303
289
  }
304
290
  async refresh(subjectAid, options = {}) {
@@ -307,7 +293,10 @@ export class OwnerResolver {
307
293
  if (subject.timer)
308
294
  clearTimeout(subject.timer);
309
295
  subject.timer = undefined;
310
- 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;
311
300
  const maxAgeMs = options.maxAgeMs ?? this.options.lifetimeMs ?? OWNERSHIP_CACHE_MAX_AGE_MS;
312
301
  const isFresh = () => {
313
302
  const { checkedAt, validUntil, consensusStatus } = subject.view;
@@ -317,32 +306,48 @@ export class OwnerResolver {
317
306
  && now - checkedAt < maxAgeMs;
318
307
  };
319
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
+ }
320
313
  return this.snapshot(subjectAid);
321
314
  }
322
- const promise = refreshing ?? this.refreshSubject(subjectAid, subject);
323
- 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 };
324
319
  try {
325
320
  await promise;
326
- 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) {
327
327
  this.invalidate(subject, 'Ownership cache expired while disconnected');
328
328
  }
329
+ if (!options.allowTransient && subject.view.refreshFailure?.attemptId === attemptId
330
+ && subject.view.refreshFailure.fallbackUsed) {
331
+ return this.unavailableSnapshot(subjectAid, subject.view.refreshFailure);
332
+ }
329
333
  return this.snapshot(subjectAid);
330
334
  }
331
335
  finally {
332
336
  if (subject.refreshing?.promise === promise)
333
337
  subject.refreshing = undefined;
334
- this.schedulePendingRefresh(subjectAid, subject);
335
338
  }
336
339
  }
337
- async refreshSubject(subjectAid, subject) {
340
+ async refreshSubject(subjectAid, subject, attemptId, attemptedAt) {
338
341
  const client = subject.client;
342
+ subject.lastRefreshAttemptAt = attemptedAt;
339
343
  if (!client || !subject.view.connected) {
340
- 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);
341
346
  return this.snapshot(subjectAid);
342
347
  }
343
348
  const generation = subject.generation;
344
349
  const epoch = subject.epoch;
345
- const query = ++subject.query;
350
+ const query = attemptId;
346
351
  let timeout;
347
352
  try {
348
353
  const consensus = await Promise.race([
@@ -361,6 +366,16 @@ export class OwnerResolver {
361
366
  if (consensus.sub_aid !== subjectAid || (consensus.status === 'consensus' && !ownerAid)) {
362
367
  throw new Error('Ownership consensus contains a mismatched subject or invalid owner');
363
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
+ }
364
379
  const previousOwner = this.owner(subjectAid);
365
380
  const previousStatus = subject.view.consensusStatus;
366
381
  const checkedAt = this.now();
@@ -381,6 +396,7 @@ export class OwnerResolver {
381
396
  || subject.view.consensus?.relation_version !== consensus.relation_version
382
397
  || subject.view.consensus?.lock !== consensus.lock ? 1 : 0),
383
398
  reason: consensus.reason,
399
+ refreshFailure: undefined,
384
400
  synchronization: {
385
401
  status: consensus.status === 'unavailable' || consensus.status === 'not_applicable' ? 'unavailable'
386
402
  : consensus.status === 'invalid' ? 'conflict'
@@ -393,12 +409,12 @@ export class OwnerResolver {
393
409
  this.notify(subjectAid, ownerAid);
394
410
  if (ownerAid === subject.view.requestedOwnerAid && subject.view.requestStatus === 'interrupted') {
395
411
  subject.view.requestStatus = 'submitted';
396
- this.persist(subjectAid, subject);
397
412
  }
398
413
  }
399
414
  catch (error) {
400
415
  if (subject.client === client && subject.generation === generation && subject.epoch === epoch && subject.query === query) {
401
- 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);
402
418
  }
403
419
  }
404
420
  finally {
@@ -407,14 +423,75 @@ export class OwnerResolver {
407
423
  }
408
424
  return this.snapshot(subjectAid);
409
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
+ }
410
489
  async bind(subjectAid, ownerAid) {
411
- this.assertLocalSubject(subjectAid);
490
+ this.assertAid(subjectAid);
412
491
  this.assertAid(ownerAid);
413
492
  if (ownerAid === subjectAid)
414
493
  throw new OwnershipError('INVALID_OWNER', 'Subject cannot own itself');
415
494
  const subject = this.subject(subjectAid);
416
- if (subject.persistenceError)
417
- throw new OwnershipError('persistence_unavailable', subject.persistenceError);
418
495
  if (subject.binding) {
419
496
  if (subject.binding.ownerAid !== ownerAid)
420
497
  throw new OwnershipError('BIND_CONFLICT', 'Another owner binding is in progress');
@@ -431,43 +508,39 @@ export class OwnerResolver {
431
508
  }
432
509
  }
433
510
  async decide(subjectAid, operationId, decision, reason) {
434
- this.assertLocalSubject(subjectAid);
435
511
  const subject = this.subject(subjectAid);
436
- const client = subject.client;
437
- if (!client || !subject.view.connected) {
438
- throw new OwnershipError('SUBJECT_UNAVAILABLE', 'Subject AID SDK client is not ready');
439
- }
440
- const request = subject.requests.get(operationId) ?? subject.decisionTasks.get(operationId);
441
- if (!request) {
442
- throw new OwnershipError('REQUEST_NOT_FOUND', 'No SDK-verified binding request matches this operation ID; use bind status');
443
- }
444
512
  const generation = subject.generation;
445
513
  logger.info(`[ownership] ${decision} requested local=${subjectAid} operationId=${operationId}`);
446
514
  try {
447
- // The SDK revalidates the stored request, decision and current relation
448
- // before any protocol mutation; this projection is only a discovery aid.
449
- const result = decision === 'accept'
450
- ? await client.acceptOwnershipRequest(operationId)
451
- : await client.rejectOwnershipRequest(operationId, reason);
452
- if (subject.client !== client || subject.generation !== generation) {
453
- throw new OwnershipError('CLIENT_REPLACED', 'Subject SDK client changed during approval; query status before retrying');
454
- }
455
- const task = this.bindingTask(result);
456
- if (!task || !this.sameBindingTask(task, request)) {
457
- throw new OwnershipError('TASK_MISMATCH', 'SDK returned a different ownership decision task');
458
- }
459
- subject.view.decisionTask = task;
460
- subject.decisionTasks.set(operationId, task);
461
- subject.requests.delete(operationId);
462
- if (request.sub_aid === subjectAid) {
463
- this.invalidate(subject, 'Binding decision submitted; fresh consensus is required');
464
- this.schedule(subjectAid, subject);
465
- }
466
- logger.info(`[ownership] ${decision} result ${JSON.stringify(task)}`);
467
- if (task.status === 'error' || task.status === 'stopped' || (decision === 'accept' && task.status === 'rejected')) {
468
- throw Object.assign(new OwnershipError(task.code ?? 'OWNERSHIP_DECISION_FAILED', task.reason ?? `Ownership task ${task.status}`), { retryable: task.retryable });
469
- }
470
- return this.snapshot(subjectAid);
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
+ });
471
544
  }
472
545
  catch (error) {
473
546
  logger.warn(`[ownership] ${decision} failed ${JSON.stringify({
@@ -476,94 +549,219 @@ export class OwnerResolver {
476
549
  throw error;
477
550
  }
478
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
+ }
479
604
  stop() {
480
605
  for (const subjectAid of this.subjects.keys())
481
606
  this.detachClient(subjectAid);
482
607
  this.configuredOwners.clear();
483
608
  }
484
609
  async submitBinding(subjectAid, ownerAid, subject) {
485
- const current = await this.refresh(subjectAid, { force: true });
486
- if (current.consensusStatus === 'consensus') {
487
- if (current.ownerAid === ownerAid)
488
- return current;
489
- throw new OwnershipError('OWNER_ALREADY_BOUND', 'Subject already has a protocol owner; transfer is not supported');
490
- }
491
- const recovering = (subject.view.task?.status === 'pending' || subject.view.requestStatus === 'interrupted')
492
- && subject.view.requestedOwnerAid === ownerAid;
493
- if (!['unbound', 'released'].includes(current.consensusStatus)
494
- && !(recovering && current.consensusStatus === 'single_sided')) {
495
- throw new OwnershipError('CONSENSUS_REQUIRED', 'Binding requires a fresh unbound or released consensus result');
496
- }
497
- if (subject.view.task?.status === 'pending') {
498
- if (subject.view.task.owner_aid !== ownerAid)
499
- throw new OwnershipError('BIND_CONFLICT', 'A different binding task is pending');
500
- }
501
- if (subject.view.requestStatus === 'interrupted' && subject.view.requestedOwnerAid !== ownerAid) {
502
- throw new OwnershipError('BIND_UNCERTAIN', 'Retry the interrupted request with its original owner before changing the target');
503
- }
504
610
  const client = subject.client;
505
- if (!client || !subject.view.connected)
506
- throw new OwnershipError('SUBJECT_UNAVAILABLE', 'Subject AID SDK client is not ready');
507
611
  const generation = subject.generation;
508
612
  subject.view = { ...subject.view, requestedOwnerAid: ownerAid, requestStatus: 'requesting', task: undefined };
509
- this.persist(subjectAid, subject);
510
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
+ }
511
636
  const task = this.task(await client.bindOwneraid(ownerAid));
512
637
  if (subject.client !== client || subject.generation !== generation) {
513
638
  throw new OwnershipError('CLIENT_REPLACED', 'Subject SDK client changed during binding; the request may have been submitted');
514
639
  }
515
640
  if (!task || task.sub_aid !== subjectAid || task.owner_aid !== ownerAid || task.operation !== 'bind') {
516
- 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');
517
642
  }
518
643
  subject.view.task = task;
519
- subject.view.requestStatus = 'submitted';
520
- 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
+ }
521
651
  this.invalidate(subject, 'Binding submitted; waiting for explicit owner approval and fresh consensus');
522
652
  this.schedule(subjectAid, subject);
523
653
  return this.snapshot(subjectAid);
524
654
  }
525
655
  catch (error) {
526
- if (subject.client === client && subject.generation === generation) {
656
+ if (!client || (subject.client === client && subject.generation === generation)) {
527
657
  subject.view.requestStatus = 'interrupted';
528
658
  subject.view.reason = error instanceof Error ? error.message : String(error);
529
- this.persist(subjectAid, subject);
530
659
  }
531
660
  throw error;
532
661
  }
533
662
  }
534
- subject(subjectAid) {
535
- this.assertAid(subjectAid);
536
- let subject = this.subjects.get(subjectAid);
537
- if (subject)
538
- return subject;
539
- let saved = null;
540
- let persistenceError;
663
+ async tryLocalBind(subjectAid, ownerAid) {
664
+ let store;
665
+ let ownerClient;
666
+ let subClient;
667
+ let invoked = false;
541
668
  try {
542
- const filename = this.statePath(subjectAid);
543
- saved = atomicReadJson(filename);
544
- const savedTask = saved?.task !== undefined ? this.task(saved.task) : undefined;
545
- if ((saved === null && fs.existsSync(filename)) || (saved && (saved.version !== 1 || saved.subjectAid !== subjectAid
546
- || (saved.requestedOwnerAid !== undefined && (typeof saved.requestedOwnerAid !== 'string' || !isValidAid(saved.requestedOwnerAid)))
547
- || (saved.task !== undefined && (!savedTask || savedTask.sub_aid !== subjectAid || savedTask.owner_aid !== saved.requestedOwnerAid))
548
- || (saved.requestStatus !== undefined && !['requesting', 'submitted', 'interrupted', 'error'].includes(String(saved.requestStatus)))))) {
549
- 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 });
550
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');
692
+ }
693
+ return { task, consensus };
551
694
  }
552
695
  catch (error) {
553
- saved = null;
554
- 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;
701
+ }
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 { }
555
719
  }
556
- const task = saved?.subjectAid === subjectAid ? this.task(saved.task) : undefined;
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;
557
760
  subject = {
558
761
  generation: 0, epoch: 0, query: 0, subscriptions: [], taskEvents: new Set(),
559
- requests: new Map(), decisionTasks: new Map(), persistenceError,
762
+ requests: new Map(), decisionTasks: new Map(),
560
763
  view: {
561
764
  subjectAid, consensusStatus: 'unavailable', authorizationStatus: 'unavailable', connected: false, revision: 0,
562
- task: task?.sub_aid === subjectAid ? task : undefined,
563
- requestedOwnerAid: saved?.subjectAid === subjectAid && typeof saved.requestedOwnerAid === 'string'
564
- && isValidAid(saved.requestedOwnerAid) ? saved.requestedOwnerAid : undefined,
565
- requestStatus: saved?.requestStatus === 'requesting' || saved?.requestStatus === 'interrupted' ? 'interrupted'
566
- : task ? 'submitted' : undefined,
567
765
  synchronization: { status: 'unavailable' },
568
766
  },
569
767
  };
@@ -575,7 +773,8 @@ export class OwnerResolver {
575
773
  subject.epoch += 1;
576
774
  subject.view = {
577
775
  ...subject.view, ownerAid: undefined, consensusStatus: 'unavailable', authorizationStatus: 'unavailable', validUntil: undefined,
578
- revision: subject.view.revision + 1, reason, synchronization: { status: 'unavailable' },
776
+ revision: subject.view.revision + 1, reason, refreshFailure: undefined,
777
+ synchronization: { status: 'unavailable' },
579
778
  };
580
779
  if (previousOwner)
581
780
  this.notify(subject.view.subjectAid);
@@ -600,63 +799,17 @@ export class OwnerResolver {
600
799
  }
601
800
  }
602
801
  }
603
- schedule(subjectAid, subject) {
802
+ schedule(subjectAid, subject, allowTransient = true) {
604
803
  if (subject.timer || !subject.client || !subject.view.connected)
605
804
  return;
606
805
  subject.timer = setTimeout(() => {
607
806
  subject.timer = undefined;
608
- void this.refresh(subjectAid, { force: true }).catch(error => {
807
+ void this.refresh(subjectAid, { force: true, allowTransient }).catch(error => {
609
808
  logger.warn(`[ownership] Event refresh failed for ${subjectAid}: ${error instanceof Error ? error.message : String(error)}`);
610
809
  });
611
810
  }, 0);
612
811
  subject.timer.unref?.();
613
812
  }
614
- schedulePendingRefresh(subjectAid, subject) {
615
- // The owner's final publication can become readable after the last SDK
616
- // event. A cached unbound/single-sided observation must not strand an
617
- // approved creation in ownerpending until the next connection or command.
618
- // Only fresh SDK consensus can authorize the existing owner-bound handler;
619
- // this never resubmits a binding or infers ownership from task completion.
620
- const needsRefresh = subject.client && subject.view.connected
621
- && subject.view.requestedOwnerAid && subject.view.consensusStatus !== 'consensus'
622
- && (subject.view.task?.status === 'pending' || subject.view.task?.status === 'completed'
623
- || subject.view.requestStatus === 'requesting' || subject.view.requestStatus === 'interrupted');
624
- if (!needsRefresh) {
625
- if (subject.pendingRefreshTimer)
626
- clearTimeout(subject.pendingRefreshTimer);
627
- subject.pendingRefreshTimer = undefined;
628
- return;
629
- }
630
- if (subject.pendingRefreshTimer)
631
- return;
632
- subject.pendingRefreshTimer = setTimeout(() => {
633
- subject.pendingRefreshTimer = undefined;
634
- void this.refresh(subjectAid, { force: true }).catch(error => {
635
- logger.warn(`[ownership] Pending consensus refresh failed for ${subjectAid}: ${error instanceof Error ? error.message : String(error)}`);
636
- });
637
- }, OWNERSHIP_PENDING_REFRESH_MS);
638
- subject.pendingRefreshTimer.unref?.();
639
- }
640
- statePath(subjectAid) {
641
- return path.join(this.options.stateDirectory?.() ?? path.join(resolvePaths().root, 'data', 'ownership'), `${subjectAid}.json`);
642
- }
643
- persist(subjectAid, subject) {
644
- if (!subject.persistenceError) {
645
- try {
646
- const filename = this.statePath(subjectAid);
647
- fs.mkdirSync(path.dirname(filename), { recursive: true });
648
- atomicWriteJson(filename, {
649
- version: 1, subjectAid, requestedOwnerAid: subject.view.requestedOwnerAid,
650
- requestStatus: subject.view.requestStatus, task: subject.view.task,
651
- });
652
- return;
653
- }
654
- catch (error) {
655
- subject.persistenceError = `Ownership task storage is unavailable: ${error instanceof Error ? error.message : String(error)}`;
656
- }
657
- }
658
- throw new OwnershipError('persistence_unavailable', subject.persistenceError);
659
- }
660
813
  now() {
661
814
  return this.options.now?.() ?? Date.now();
662
815
  }
@@ -696,7 +849,13 @@ export class OwnerResolver {
696
849
  return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
697
850
  }
698
851
  trackBindingRequest(localAid, subject, event, record) {
699
- const task = this.bindingTask(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);
700
859
  if (!task || (task.sub_aid !== localAid && task.owner_aid !== localAid))
701
860
  return;
702
861
  const request = subject.requests.get(task.operation_id);
@@ -738,19 +897,23 @@ export class OwnerResolver {
738
897
  }
739
898
  sameBindingTask(left, right) {
740
899
  return left.operation_id === right.operation_id && left.sub_aid === right.sub_aid
741
- && left.owner_aid === right.owner_aid && left.base_relation_version === right.base_relation_version
900
+ && left.owner_aid === right.owner_aid && left.new_owner_aid === right.new_owner_aid
901
+ && left.base_relation_version === right.base_relation_version
742
902
  && left.target_relation_version === right.target_relation_version;
743
903
  }
744
904
  /** Only public task fields are exposed; raw SDK records remain SDK-owned. */
745
- bindingTask(value) {
746
- const task = this.task(value);
747
- if (!task || !CREATION_BIND_OPERATION_ID.test(task.operation_id) || task.new_owner_aid !== undefined
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)
748
908
  || task.sub_aid === task.owner_aid || !Number.isSafeInteger(task.base_relation_version)
749
909
  || task.base_relation_version < 0 || !Number.isSafeInteger(task.target_relation_version)
750
910
  || task.target_relation_version !== task.base_relation_version + 1)
751
911
  return undefined;
912
+ if (task.new_owner_aid !== undefined)
913
+ return undefined;
752
914
  return {
753
- operation_id: task.operation_id, operation: 'bind', sub_aid: task.sub_aid, owner_aid: task.owner_aid,
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 } : {}),
754
917
  base_relation_version: task.base_relation_version, target_relation_version: task.target_relation_version,
755
918
  status: task.status, step: task.step,
756
919
  ...(typeof task.code === 'string' ? { code: task.code } : {}),
@@ -758,15 +921,34 @@ export class OwnerResolver {
758
921
  ...(typeof task.retryable === 'boolean' ? { retryable: task.retryable } : {}),
759
922
  };
760
923
  }
761
- task(value) {
924
+ task(value, event) {
762
925
  const record = this.record(value);
926
+ const baseRelationVersion = record?.base_relation_version;
927
+ const targetRelationVersion = record?.target_relation_version;
763
928
  if (!record || typeof record.operation_id !== 'string' || !record.operation_id
764
- || 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)
765
930
  || typeof record.owner_aid !== 'string' || !isValidAid(record.owner_aid)
766
- || !['pending', 'completed', 'rejected', 'stopped', 'error'].includes(String(record.status))
767
- || 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)
768
935
  return undefined;
769
- 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
+ };
770
952
  }
771
953
  }
772
954
  export const ownerResolver = new OwnerResolver();
@@ -781,10 +963,17 @@ export async function refreshOwnershipForAuthorization(subjectAid, options = {})
781
963
  const ownershipSubjectAid = daemonAid && subjectAid === `${daemonAid}#daemon` ? daemonAid : subjectAid;
782
964
  const subjects = new Set([ownershipSubjectAid, ...(!ownershipSubjectAid || options.includeDaemon !== false ? [daemonAid] : [])]
783
965
  .filter((aid) => !!aid));
784
- await Promise.all([...subjects].map(aid => ownerResolver.refresh(aid, {
966
+ const views = await Promise.all([...subjects].map(aid => ownerResolver.refresh(aid, {
785
967
  maxAgeMs: options.maxAgeMs ?? OWNERSHIP_AUTHORIZATION_MAX_AGE_MS,
786
968
  force: options.force,
969
+ allowTransient: options.allowTransient,
787
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
+ }
788
977
  }
789
978
  export async function assertOwnershipAuthorization(context) {
790
979
  const authorization = context?.executionPermission;
@@ -792,15 +981,32 @@ export async function assertOwnershipAuthorization(context) {
792
981
  const ownerActor = hasTrustedPrincipal(context?.actor) ? context.actor.principalId : undefined;
793
982
  if (!authorization && !ownerRole)
794
983
  return;
795
- await refreshOwnershipForAuthorization(context?.selfAid ?? authorization?.agentAid);
984
+ await refreshOwnershipForAuthorization(context?.selfAid ?? authorization?.agentAid, { allowTransient: true });
985
+ const subjectAid = context?.selfAid ?? authorization?.agentAid;
796
986
  if (authorization) {
797
987
  if (!getDaemonOwners().includes(authorization.authorizedBy)
798
988
  || (authorization.source === 'agent-policy'
799
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
+ })}`);
800
998
  throw new OwnershipError('OWNER_UNAVAILABLE', 'Current Owner no longer authorizes this execution');
801
999
  }
802
1000
  }
803
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
+ })}`);
804
1010
  throw new OwnershipError('OWNER_UNAVAILABLE', 'Current Owner could not be verified; start a new task with the current role');
805
1011
  }
806
1012
  }