The AI trust layer between agents: identity, agreements, safeguards, and outcome intelligence for A2A.

Viewing files at commit 89b58109a0e6Commit detailsView latest

packages/persistence/src/hosted.ts 3426 lines · 130.8 KB · typescript
1
import { neon, type NeonQueryFunction } from '@neondatabase/serverless';
2
import { createHash, randomBytes } from 'node:crypto';
3
import {
4
  buildCounterpartyBrief,
5
  buildInteractionConclusion,
6
  deriveBehaviouralObservations,
7
  evaluateLearningEligibility,
8
  summarizeContextualReliability,
9
  updateContextualBehaviouralProfile,
10
} from '../../core/src/index.js';
11
import {
12
  AgentIdentitySchema,
13
  AgentResolutionSchema,
14
  BehaviouralProfileDeltaSchema,
15
  DEFAULT_AGENT_AUTH_SCOPES,
16
  DEFAULT_EXTENSION_URI,
17
  CounterpartyBriefSchema,
18
  ContractRevisionSchema,
19
  FederatedInteractionSchema,
20
  FeedbackDimensionSchema,
21
  FeedbackRequestSchema,
22
  LiveSessionAcceptanceSchema,
23
  LiveSessionActivationSchema,
24
  LiveSessionEventSchema,
25
  LiveSessionOfferSchema,
26
  LiveSessionStateRecordSchema,
27
  InteractionCompletionReportSchema,
28
  InteractionContractSchema,
29
  InteractionConclusionSchema,
30
  InteractionFeedbackSchema,
31
  LearningEligibilityDecisionSchema,
32
  PublicAgentCardSchema,
33
  ReceiptSchema,
34
  canonicalHash,
35
  verifyObject,
36
  type FederatedInteraction,
37
  type AgentPresence,
38
  type AgentResolution,
39
  type BehaviouralProfileDelta,
40
  type CounterpartyBrief,
41
  type ContractRevision,
42
  type LiveSessionActivation,
43
  type LiveSessionEvent,
44
  type LiveSessionInsight,
45
  type InteractionCompletionReport,
46
  type InteractionContract,
47
  type InteractionFeedback,
48
  type InteractionConclusion,
49
  type PublicAgentCard,
50
} from '../../protocol/src/index.js';
51
import type { AgentProfile } from './onboarding.js';
52
import type { AgentInstallation } from './onboarding.js';
53
import {
54
  agentAccessTokenClientId,
55
  agentAccessTokenId,
56
  createAgentAccessToken,
57
  matchesAgentAccessToken,
58
  type AgentAccessTokenMetadata,
59
} from './access-token.js';
60
import {
61
  decryptGatewayPayload,
62
  encryptGatewayPayload,
63
  attestSessionRecord,
64
  getSessionKeyId,
65
  getSessionVerificationKey,
66
  issueSessionGrant,
67
  signSessionControl,
68
  verifySessionGrant,
69
} from './relay.js';
70
import { postRuntimeJson, resolvePublicRuntimeEndpoint } from './runtime.js';
71
import { verifyHostedMigrations } from './hosted-migrations.js';
72
import {
73
  buildSourceRecordEnvelope,
74
  shouldJournalSourceRecord,
75
  type SourceRecordWriteMetadata,
76
} from './source-record.js';
77
78
export type HostedRecordKind =
79
  | 'agent'
80
  | 'delegation'
81
  | 'contract'
82
  | 'interaction'
83
  | 'event'
84
  | 'receipt'
85
  | 'feedback'
86
  | 'counterparty_brief'
87
  | 'completion_report'
88
  | 'feedback_request'
89
  | 'interaction_feedback'
90
  | 'interaction_conclusion'
91
  | 'learning_eligibility'
92
  | 'profile_delta'
93
  | 'conflict'
94
  | 'profile'
95
  | 'consent'
96
  | 'revocation'
97
  | 'project'
98
  | 'agent_profile'
99
  | 'installation'
100
  | 'setup_request'
101
  | 'publication'
102
  | 'presence';
103
104
export type { PublicAgentCard, FederatedInteraction } from '../../protocol/src/index.js';
105
106
type ContextualProfile = {
107
  recordId: string;
108
  agentId: string;
109
  agentVersion: string;
110
  taskCategory: string;
111
  sampleSize: number;
112
  effectiveSampleSize?: number;
113
  dimensionSampleSizes?: Partial<
114
    Record<
115
      | 'completion'
116
      | 'acceptance'
117
      | 'specification'
118
      | 'deadline'
119
      | 'communication'
120
      | 'evidence'
121
      | 'scope'
122
      | 'correction'
123
      | 'limitations'
124
      | 'disputes',
125
      number
126
    >
127
  >;
128
  updatedAt: string;
129
  completion: number;
130
  acceptance: number;
131
  specification: number;
132
  deadline: number;
133
  communication: number;
134
  evidence: number;
135
  scope: number;
136
  correction: number;
137
  limitations: number;
138
  disputes: number;
139
};
140
141
const FEEDBACK_DIMENSIONS = FeedbackDimensionSchema.options;
142
143
function feedbackWindowMilliseconds() {
144
  const configured = Number(process.env.OPENCLASP_FEEDBACK_WINDOW_MINUTES ?? 120);
145
  const minutes = Number.isFinite(configured) ? Math.max(15, Math.min(1440, configured)) : 120;
146
  return minutes * 60_000;
147
}
148
149
function reviewerCredibility(feedback: InteractionFeedback): number {
150
  const provenance =
151
    feedback.submissionMethod === 'agent_signature'
152
      ? 0.9
153
      : feedback.submissionMethod === 'runtime_session'
154
        ? 0.8
155
        : feedback.submissionMethod === 'agent_access_token'
156
          ? 0.75
157
          : 0.65;
158
  return Math.min(1, provenance + (feedback.evidenceReferences.length ? 0.1 : 0));
159
}
160
161
function deterministicUuid(value: string): string {
162
  const bytes = createHash('sha256').update(value).digest().subarray(0, 16);
163
  bytes[6] = (bytes[6]! & 0x0f) | 0x50;
164
  bytes[8] = (bytes[8]! & 0x3f) | 0x80;
165
  const hex = bytes.toString('hex');
166
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
167
}
168
169
function normalizePublicAgentCard(value: unknown): PublicAgentCard {
170
  const current = PublicAgentCardSchema.safeParse(value);
171
  const baseUrl = (process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(
172
    /\/$/,
173
    '',
174
  );
175
  if (current.success) {
176
    const slug = current.data.slug ?? publicAgentSlug(current.data.name, current.data.agentId);
177
    return PublicAgentCardSchema.parse({
178
      ...current.data,
179
      slug,
180
      profileUrl: `${baseUrl}/a/${encodeURIComponent(slug)}`,
181
      cardUrl: `${baseUrl}/agents/${encodeURIComponent(current.data.agentId)}/card.json`,
182
      a2aAgentCardUrl: `${baseUrl}/agents/${encodeURIComponent(current.data.agentId)}/a2a-agent-card.json`,
183
      extensionUri: current.data.extensionUri ?? DEFAULT_EXTENSION_URI,
184
      verification: {
185
        ...(current.data.verification ?? {
186
          status: 'verified',
187
          method: 'openclasp_oauth_account',
188
          verifiedAt: current.data.publishedAt,
189
        }),
190
        verificationKeyUrl: `${baseUrl}/.well-known/openclasp-session-key`,
191
      },
192
    });
193
  }
194
  const legacy = value as Record<string, unknown>;
195
  const agentId = String(legacy.agentId ?? '');
196
  const slug = publicAgentSlug(String(legacy.name ?? agentId), agentId);
197
  return PublicAgentCardSchema.parse({
198
    protocolVersion: '0.1',
199
    agentId,
200
    name: legacy.name,
201
    description: '',
202
    framework: legacy.framework,
203
    agentVersion: '1.0.0',
204
    capabilities: legacy.capabilities,
205
    limitations: legacy.limitations,
206
    assurance: legacy.assurance,
207
    transports:
208
      typeof legacy.a2aEndpoint === 'string'
209
        ? [
210
            {
211
              protocol: 'A2A/1.0',
212
              protocolBinding: 'JSONRPC',
213
              endpoint: legacy.a2aEndpoint,
214
              managedBy: 'agent',
215
            },
216
          ]
217
        : [],
218
    slug,
219
    profileUrl: `${baseUrl}/a/${encodeURIComponent(slug)}`,
220
    cardUrl: `${baseUrl}/agents/${encodeURIComponent(agentId)}/card.json`,
221
    a2aAgentCardUrl: `${baseUrl}/agents/${encodeURIComponent(agentId)}/a2a-agent-card.json`,
222
    extensionUri: DEFAULT_EXTENSION_URI,
223
    verification: {
224
      status: 'verified',
225
      method: 'openclasp_oauth_account',
226
      verifiedAt: legacy.publishedAt,
227
      verificationKeyUrl: `${baseUrl}/.well-known/openclasp-session-key`,
228
    },
229
    publishedAt: legacy.publishedAt,
230
    updatedAt: legacy.updatedAt,
231
  });
232
}
233
234
function publicAgentSlug(name: string, agentId: string) {
235
  const prefix =
236
    name
237
      .trim()
238
      .toLowerCase()
239
      .replace(/[^a-z0-9]+/g, '-')
240
      .replace(/^-|-$/g, '')
241
      .slice(0, 48) || 'agent';
242
  const suffix = createHash('sha256').update(agentId).digest('hex').slice(0, 8);
243
  return `${prefix}-${suffix}`;
244
}
245
246
export function buildPublicAgentCard(
247
  agent: AgentProfile,
248
  baseUrl: string,
249
  previous?: PublicAgentCard,
250
): PublicAgentCard {
251
  const now = new Date().toISOString();
252
  const root = baseUrl.replace(/\/$/, '');
253
  const slug = previous?.slug ?? publicAgentSlug(agent.name, agent.agentId);
254
  return PublicAgentCardSchema.parse({
255
    protocolVersion: '0.1',
256
    agentId: agent.agentId,
257
    name: agent.name,
258
    description: agent.description ?? '',
259
    framework: agent.framework,
260
    agentVersion: agent.agentVersion ?? '1.0.0',
261
    capabilities: agent.capabilities,
262
    limitations: agent.limitations,
263
    assurance: 'oauth_authenticated',
264
    agentMode: 'persistent_runtime',
265
    transports: agent.a2aEndpoint
266
      ? [
267
          {
268
            protocol: 'A2A/1.0',
269
            protocolBinding: 'JSONRPC',
270
            endpoint: agent.a2aEndpoint,
271
            managedBy: 'agent',
272
          },
273
        ]
274
      : [],
275
    slug,
276
    profileUrl: `${root}/a/${encodeURIComponent(slug)}`,
277
    cardUrl: `${root}/agents/${encodeURIComponent(agent.agentId)}/card.json`,
278
    a2aAgentCardUrl: `${root}/agents/${encodeURIComponent(agent.agentId)}/a2a-agent-card.json`,
279
    extensionUri: DEFAULT_EXTENSION_URI,
280
    verification: {
281
      status: 'verified',
282
      method: 'openclasp_oauth_account',
283
      verifiedAt: previous?.verification?.verifiedAt ?? previous?.publishedAt ?? now,
284
      verificationKeyUrl: `${root}/.well-known/openclasp-session-key`,
285
    },
286
    publishedAt: previous?.publishedAt ?? now,
287
    updatedAt: now,
288
  });
289
}
290
291
const normalized = (value: string) =>
292
  value
293
    .trim()
294
    .toLowerCase()
295
    .replace(/[^a-z0-9]+/g, '_');
296
297
export const AGENT_ONLINE_WINDOW_MS = 2 * 60_000;
298
299
export function resolveAgentPresence(lastSeenAt?: string, now = new Date()): AgentPresence {
300
  const online = Boolean(
301
    lastSeenAt && now.getTime() - Date.parse(lastSeenAt) <= AGENT_ONLINE_WINDOW_MS,
302
  );
303
  return {
304
    status: online ? 'online' : 'offline',
305
    ...(lastSeenAt ? { lastSeenAt } : {}),
306
    checkedAt: now.toISOString(),
307
  };
308
}
309
310
export function canAutoAcceptInteraction(
311
  agent: AgentProfile,
312
  interaction: FederatedInteraction,
313
): boolean {
314
  if (agent.autoAcceptPolicy !== 'safe_matching' || agent.status !== 'active') return false;
315
  const categories = new Set(
316
    [...(agent.autoAcceptTaskCategories ?? []), ...(agent.capabilities ?? [])].map(normalized),
317
  );
318
  const taskCategory = normalized(interaction.contract.taskCategory);
319
  if (!taskCategory || !categories.has(taskCategory)) return false;
320
  if (interaction.contract.allowedData.length > 0) return false;
321
  if (
322
    /\b(password|secret|api[_ -]?key|access[_ -]?token|credential|bank|payment|medical|health|ssn|personal[_ -]?data|customer[_ -]?record)\b/i.test(
323
      `${interaction.contract.purpose} ${interaction.contract.requestedOutcome}`,
324
    )
325
  )
326
    return false;
327
  if (interaction.contract.humanApprovalRequirements.length > 0) return false;
328
  if (interaction.contract.retentionDays > 30) return false;
329
  if (interaction.contract.allowedActions.length > 0) {
330
    const capabilities = new Set((agent.capabilities ?? []).map(normalized));
331
    if (
332
      !interaction.contract.allowedActions.every((action) => capabilities.has(normalized(action)))
333
    )
334
      return false;
335
  }
336
  return true;
337
}
338
339
function withContractRevisionHistory(value: FederatedInteraction): FederatedInteraction {
340
  if (value.contractRevisions.length) return value;
341
  const status =
342
    value.status === 'pending'
343
      ? 'proposed'
344
      : ['active', 'completed'].includes(value.status)
345
        ? 'accepted'
346
        : 'rejected';
347
  const revision = ContractRevisionSchema.parse({
348
    revisionId: deterministicUuid(`${value.interactionId}:contract:1:${value.termsHash}`),
349
    interactionId: value.interactionId,
350
    revision: value.contractRevision,
351
    termsHash: value.termsHash,
352
    contract: value.contract,
353
    proposedByAgentId: value.initiatorAgentId,
354
    status,
355
    acceptances: value.acceptances,
356
    createdAt: value.createdAt,
357
    updatedAt: value.updatedAt,
358
    expiresAt: value.expiresAt,
359
  });
360
  return FederatedInteractionSchema.parse({ ...value, contractRevisions: [revision] });
361
}
362
363
function openContractRevision(value: FederatedInteraction): ContractRevision | undefined {
364
  return [...value.contractRevisions].reverse().find((revision) => revision.status === 'proposed');
365
}
366
367
export type AccountSettings = {
368
  displayName: string;
369
  contributionEnabled: boolean;
370
  rawConversationsStored: false;
371
};
372
373
const defaults: AccountSettings = {
374
  displayName: '',
375
  contributionEnabled: false,
376
  rawConversationsStored: false,
377
};
378
379
export class HostedRepository {
380
  private readonly sql: NeonQueryFunction<false, false>;
381
  private initialized?: Promise<void>;
382
383
  constructor(databaseUrl: string) {
384
    this.sql = neon(databaseUrl);
385
  }
386
387
  private gatewaySecret() {
388
    const value = process.env.OPENCLASP_RELAY_ENCRYPTION_KEY;
389
    if (!value || value.length < 32) throw new Error('Gateway encryption is not configured');
390
    return value;
391
  }
392
393
  private gatewaySecrets() {
394
    const active = this.gatewaySecret();
395
    const previous = (process.env.OPENCLASP_RELAY_PREVIOUS_KEYS ?? '')
396
      .split(',')
397
      .map((value) => value.trim())
398
      .filter(Boolean);
399
    if (previous.some((value) => value.length < 32))
400
      throw new Error('A previous gateway key is too short');
401
    if (previous.length > 3) throw new Error('At most three previous gateway keys are supported');
402
    return [...new Set([active, ...previous])];
403
  }
404
405
  private decryptGatewayPayload(encrypted: { ciphertext: string; iv: string; authTag: string }) {
406
    for (const secret of this.gatewaySecrets()) {
407
      try {
408
        return decryptGatewayPayload(secret, encrypted);
409
      } catch {
410
        // Try retained rotation keys without exposing which key failed.
411
      }
412
    }
413
    throw new Error('Encrypted gateway payload could not be decrypted');
414
  }
415
416
  private verifySessionGrant(token: string) {
417
    for (const secret of this.gatewaySecrets()) {
418
      try {
419
        return verifySessionGrant(secret, token);
420
      } catch {
421
        // Short-lived sessions remain valid during an orderly key rotation.
422
      }
423
    }
424
    throw new Error('Invalid or expired live-session credential');
425
  }
426
427
  private attestPublicAgentCard(value: PublicAgentCard): PublicAgentCard {
428
    const unsigned = { ...value };
429
    delete unsigned.platformAttestation;
430
    return PublicAgentCardSchema.parse({
431
      ...unsigned,
432
      platformAttestation: attestSessionRecord(this.gatewaySecret(), unsigned),
433
    });
434
  }
435
436
  ensureSchema(): Promise<void> {
437
    return (this.initialized ??= verifyHostedMigrations(this.sql));
438
  }
439
440
  private async appendSourceRecord(
441
    operatorId: string,
442
    kind: string,
443
    recordId: string,
444
    payload: unknown,
445
    metadata?: SourceRecordWriteMetadata,
446
  ) {
447
    const source = buildSourceRecordEnvelope({
448
      operatorId,
449
      kind,
450
      recordId,
451
      payload,
452
      ...(metadata ? { metadata } : {}),
453
    });
454
    await this.sql`
455
      INSERT INTO openclasp_source_records(
456
        event_id, operator_id, kind, record_id, schema_name, schema_version, payload,
457
        payload_digest, entity_refs, provenance, visibility, retention_class, learning_scope,
458
        reported_at, ingested_at
459
      ) VALUES (
460
        ${source.eventId}, ${source.operatorId}, ${source.kind}, ${source.recordId},
461
        ${source.schemaName}, ${source.schemaVersion}, ${JSON.stringify(source.payload)}::jsonb,
462
        ${source.payloadDigest}, ${JSON.stringify(source.entityRefs)}::jsonb,
463
        ${source.provenance}, ${source.visibility}, ${source.retentionClass},
464
        ${source.learningScope}, ${source.reportedAt}, ${source.ingestedAt}
465
      )
466
      ON CONFLICT (operator_id, kind, record_id, payload_digest) DO NOTHING
467
    `;
468
  }
469
470
  private async interactionOperatorIds(interactionId: string): Promise<string[]> {
471
    const rows = await this.sql`
472
      SELECT initiator_operator_id, responder_operator_id
473
      FROM openclasp_federated_interactions
474
      WHERE interaction_id = ${interactionId}
475
      LIMIT 1
476
    `;
477
    if (!rows[0]) return [];
478
    return [
479
      ...new Set([String(rows[0].initiator_operator_id), String(rows[0].responder_operator_id)]),
480
    ];
481
  }
482
483
  private async journalFederatedInteraction(interactionId: string) {
484
    const rows = await this.sql`
485
      SELECT payload, initiator_operator_id, responder_operator_id
486
      FROM openclasp_federated_interactions
487
      WHERE interaction_id = ${interactionId}
488
      LIMIT 1
489
    `;
490
    if (!rows[0]) return;
491
    const payload = FederatedInteractionSchema.parse(rows[0].payload);
492
    const operatorIds = [
493
      ...new Set([String(rows[0].initiator_operator_id), String(rows[0].responder_operator_id)]),
494
    ];
495
    await Promise.all(
496
      operatorIds.map((operatorId) =>
497
        this.appendSourceRecord(operatorId, 'federated_interaction', interactionId, payload, {
498
          schemaName: 'openclasp.federated_interaction',
499
          schemaVersion: '0.1',
500
          visibility: 'shared_participants',
501
          retentionClass: 'audit',
502
        }),
503
      ),
504
    );
505
  }
506
507
  private async journalLiveSessionState(interactionId: string) {
508
    const rows = await this.sql`
509
      SELECT session.interaction_id, session.initiator_agent_id, session.responder_agent_id,
510
        session.status, session.expires_at, session.created_at, session.activated_at,
511
        session.completed_at, interaction.initiator_operator_id,
512
        interaction.responder_operator_id
513
      FROM openclasp_live_sessions session
514
      INNER JOIN openclasp_federated_interactions interaction
515
        ON interaction.interaction_id = session.interaction_id
516
      WHERE session.interaction_id = ${interactionId}
517
      LIMIT 1
518
    `;
519
    if (!rows[0]) return;
520
    const row = rows[0];
521
    const payload = LiveSessionStateRecordSchema.parse({
522
      interactionId: String(row.interaction_id),
523
      initiatorAgentId: String(row.initiator_agent_id),
524
      responderAgentId: String(row.responder_agent_id),
525
      status: String(row.status),
526
      expiresAt: new Date(String(row.expires_at)).toISOString(),
527
      createdAt: new Date(String(row.created_at)).toISOString(),
528
      ...(row.activated_at
529
        ? { activatedAt: new Date(String(row.activated_at)).toISOString() }
530
        : {}),
531
      ...(row.completed_at
532
        ? { completedAt: new Date(String(row.completed_at)).toISOString() }
533
        : {}),
534
      ...(row.status === 'failed' ? { failureCode: 'session_failed' } : {}),
535
    });
536
    const operatorIds = [
537
      ...new Set([String(row.initiator_operator_id), String(row.responder_operator_id)]),
538
    ];
539
    await Promise.all(
540
      operatorIds.map((operatorId) =>
541
        this.appendSourceRecord(operatorId, 'live_session_state', interactionId, payload, {
542
          schemaName: 'openclasp.live_session_state',
543
          schemaVersion: '0.1',
544
          visibility: 'shared_participants',
545
          retentionClass: 'audit',
546
          reportedAt: payload.completedAt ?? payload.activatedAt ?? payload.createdAt,
547
        }),
548
      ),
549
    );
550
  }
551
552
  private async journalLiveSessionEvent(payload: Record<string, unknown>) {
553
    const interactionId = String(payload.interactionId ?? '');
554
    const eventId = String(payload.eventId ?? '');
555
    if (!interactionId || !eventId) return;
556
    const operatorIds = await this.interactionOperatorIds(interactionId);
557
    await Promise.all(
558
      operatorIds.map((operatorId) =>
559
        this.appendSourceRecord(operatorId, 'live_session_event', eventId, payload, {
560
          schemaName: 'openclasp.live_session_event',
561
          schemaVersion: '0.1',
562
          visibility: 'shared_participants',
563
          retentionClass: 'audit',
564
        }),
565
      ),
566
    );
567
  }
568
569
  getRuntimeVerificationKey() {
570
    const [active, ...previous] = this.gatewaySecrets();
571
    return {
572
      algorithm: 'Ed25519' as const,
573
      keyId: getSessionKeyId(active!),
574
      publicKey: getSessionVerificationKey(active!),
575
      previousKeys: previous.map((secret) => ({
576
        algorithm: 'Ed25519' as const,
577
        keyId: getSessionKeyId(secret),
578
        publicKey: getSessionVerificationKey(secret),
579
      })),
580
    };
581
  }
582
583
  async upsert(
584
    operatorId: string,
585
    kind: HostedRecordKind,
586
    recordId: string,
587
    payload: unknown,
588
    metadata?: SourceRecordWriteMetadata,
589
  ): Promise<void> {
590
    await this.ensureSchema();
591
    const encoded = JSON.stringify(payload);
592
    if (!(metadata?.journal ?? shouldJournalSourceRecord(kind))) {
593
      await this.sql`
594
        INSERT INTO openclasp_records(operator_id, kind, record_id, payload)
595
        VALUES (${operatorId}, ${kind}, ${recordId}, ${encoded}::jsonb)
596
        ON CONFLICT (operator_id, kind, record_id)
597
        DO UPDATE SET payload = EXCLUDED.payload, updated_at = NOW()
598
      `;
599
      return;
600
    }
601
    const source = buildSourceRecordEnvelope({
602
      operatorId,
603
      kind,
604
      recordId,
605
      payload,
606
      ...(metadata ? { metadata } : {}),
607
    });
608
    const sourcePayload = JSON.stringify(source.payload);
609
    const sourceEntityRefs = JSON.stringify(source.entityRefs);
610
    await this.sql.transaction((transaction) => [
611
      transaction`
612
        INSERT INTO openclasp_records(operator_id, kind, record_id, payload)
613
        VALUES (${operatorId}, ${kind}, ${recordId}, ${encoded}::jsonb)
614
        ON CONFLICT (operator_id, kind, record_id)
615
        DO UPDATE SET payload = EXCLUDED.payload, updated_at = NOW()
616
      `,
617
      transaction`
618
        INSERT INTO openclasp_source_records(
619
          event_id,
620
          operator_id,
621
          kind,
622
          record_id,
623
          schema_name,
624
          schema_version,
625
          payload,
626
          payload_digest,
627
          entity_refs,
628
          provenance,
629
          visibility,
630
          retention_class,
631
          learning_scope,
632
          reported_at,
633
          ingested_at
634
        ) VALUES (
635
          ${source.eventId},
636
          ${source.operatorId},
637
          ${source.kind},
638
          ${source.recordId},
639
          ${source.schemaName},
640
          ${source.schemaVersion},
641
          ${sourcePayload}::jsonb,
642
          ${source.payloadDigest},
643
          ${sourceEntityRefs}::jsonb,
644
          ${source.provenance},
645
          ${source.visibility},
646
          ${source.retentionClass},
647
          ${source.learningScope},
648
          ${source.reportedAt},
649
          ${source.ingestedAt}
650
        )
651
        ON CONFLICT (operator_id, kind, record_id, payload_digest) DO NOTHING
652
      `,
653
    ]);
654
  }
655
656
  async list(
657
    operatorId: string,
658
  ): Promise<{ kind: HostedRecordKind; recordId: string; payload: any }[]> {
659
    await this.ensureSchema();
660
    const rows = await this.sql`
661
      SELECT kind, record_id, payload
662
      FROM openclasp_records
663
      WHERE operator_id = ${operatorId}
664
      ORDER BY created_at ASC
665
    `;
666
    return rows.map((row) => ({
667
      kind: row.kind as HostedRecordKind,
668
      recordId: String(row.record_id),
669
      payload: row.payload,
670
    }));
671
  }
672
673
  async dashboard(operatorId: string) {
674
    await this.ensureSchema();
675
    const dueFeedback = await this.sql`
676
      SELECT 1 FROM openclasp_records
677
      WHERE operator_id = ${operatorId}
678
        AND kind = 'feedback_request'
679
        AND payload->>'status' = 'pending'
680
        AND (payload->>'dueAt')::timestamptz <= NOW()
681
      LIMIT 1
682
    `;
683
    if (dueFeedback.length) await this.processDueFeedback().catch(() => undefined);
684
    const outstanding = await this.sql`
685
      SELECT DISTINCT report.payload->>'interactionId' AS interaction_id
686
      FROM openclasp_records report
687
      INNER JOIN openclasp_federated_interactions interaction
688
        ON interaction.interaction_id::text = report.payload->>'interactionId'
689
      WHERE report.kind = 'completion_report'
690
        AND (
691
          interaction.initiator_operator_id = ${operatorId}
692
          OR interaction.responder_operator_id = ${operatorId}
693
        )
694
        AND NOT EXISTS (
695
          SELECT 1 FROM openclasp_records conclusion
696
          WHERE conclusion.operator_id = ${operatorId}
697
            AND conclusion.kind = 'interaction_conclusion'
698
            AND conclusion.payload->>'interactionId' = report.payload->>'interactionId'
699
        )
700
      LIMIT 20
701
    `;
702
    for (const row of outstanding) {
703
      const interactionId = String(row.interaction_id ?? '');
704
      if (interactionId)
705
        await this.finalizeInteractionConclusion(interactionId).catch(() => undefined);
706
    }
707
    const intelligenceBackfill = await this.sql`
708
      SELECT DISTINCT interaction.interaction_id
709
      FROM openclasp_federated_interactions interaction
710
      INNER JOIN openclasp_records conclusion
711
        ON conclusion.payload->>'interactionId' = interaction.interaction_id::text
712
        AND conclusion.kind = 'interaction_conclusion'
713
        AND conclusion.payload->>'lifecycle' = 'final'
714
      WHERE (
715
          interaction.initiator_operator_id = ${operatorId}
716
          OR interaction.responder_operator_id = ${operatorId}
717
        )
718
        AND EXISTS (
719
          SELECT 1 FROM openclasp_records eligibility
720
          WHERE eligibility.kind = 'learning_eligibility'
721
            AND eligibility.payload->>'interactionId' = interaction.interaction_id::text
722
            AND eligibility.payload->>'eligible' = 'true'
723
        )
724
        AND NOT EXISTS (
725
          SELECT 1 FROM openclasp_records delta
726
          WHERE delta.operator_id = ${operatorId}
727
            AND delta.kind = 'profile_delta'
728
            AND delta.payload->>'interactionId' = interaction.interaction_id::text
729
            AND delta.payload->>'agentId' = CASE
730
              WHEN interaction.initiator_operator_id = ${operatorId}
731
                THEN interaction.initiator_agent_id::text
732
              ELSE interaction.responder_agent_id::text
733
            END
734
        )
735
      ORDER BY interaction.interaction_id DESC
736
      LIMIT 20
737
    `;
738
    for (const row of intelligenceBackfill) {
739
      const interactionId = String(row.interaction_id ?? '');
740
      if (interactionId)
741
        await this.finalizeInteractionConclusion(interactionId).catch(() => undefined);
742
    }
743
    const rows = await this.list(operatorId);
744
    const [federatedInteractions, runtimes, accessTokens, liveSessionRows, liveEventRows] =
745
      await Promise.all([
746
        this.listFederatedInteractions(operatorId),
747
        this.listAgentRuntimes(operatorId),
748
        this.listAgentAccessTokens(operatorId),
749
        this.sql`
750
        SELECT session.interaction_id, session.initiator_agent_id, session.responder_agent_id,
751
          session.status, session.created_at, session.activated_at, session.completed_at,
752
          session.expires_at, session.last_error
753
        FROM openclasp_live_sessions session
754
        INNER JOIN openclasp_federated_interactions interaction
755
          ON interaction.interaction_id = session.interaction_id
756
        WHERE interaction.initiator_operator_id = ${operatorId}
757
           OR interaction.responder_operator_id = ${operatorId}
758
        ORDER BY session.created_at DESC
759
      `,
760
        this.sql`
761
        SELECT events.event
762
        FROM openclasp_live_session_events events
763
        INNER JOIN openclasp_federated_interactions interaction
764
          ON interaction.interaction_id = events.interaction_id
765
        WHERE interaction.initiator_operator_id = ${operatorId}
766
           OR interaction.responder_operator_id = ${operatorId}
767
        ORDER BY events.created_at ASC
768
      `,
769
      ]);
770
    const ofKind = (kind: HostedRecordKind) =>
771
      rows.filter((row) => row.kind === kind).map((row) => row.payload);
772
    const presence = new Map(
773
      rows
774
        .filter((row) => row.kind === 'presence')
775
        .map((row) => [row.recordId, String(row.payload.lastSeenAt)]),
776
    );
777
    const intelligenceSummaries = await this.listContextualIntelligence(operatorId);
778
    return {
779
      agents: [...ofKind('agent_profile'), ...ofKind('agent')].map((agent) => ({
780
        ...agent,
781
        presence: resolveAgentPresence(presence.get(String(agent.agentId))),
782
      })),
783
      projects: ofKind('project'),
784
      installations: ofKind('installation'),
785
      setupRequests: ofKind('setup_request'),
786
      publications: ofKind('publication'),
787
      interactions: ofKind('interaction'),
788
      events: [...ofKind('event'), ...liveEventRows.map((row) => row.event)],
789
      conflicts: ofKind('conflict'),
790
      receipts: ofKind('receipt'),
791
      profiles: ofKind('profile'),
792
      counterpartyBriefs: ofKind('counterparty_brief'),
793
      completionReports: ofKind('completion_report'),
794
      feedbackRequests: ofKind('feedback_request'),
795
      interactionFeedback: ofKind('interaction_feedback'),
796
      interactionConclusions: ofKind('interaction_conclusion'),
797
      learningEligibility: ofKind('learning_eligibility'),
798
      profileDeltas: ofKind('profile_delta'),
799
      intelligenceSummaries,
800
      federatedInteractions,
801
      liveSessions: liveSessionRows.map((row) => ({
802
        interactionId: String(row.interaction_id),
803
        initiatorAgentId: String(row.initiator_agent_id),
804
        responderAgentId: String(row.responder_agent_id),
805
        status: String(row.status),
806
        createdAt: new Date(String(row.created_at)).toISOString(),
807
        ...(row.activated_at
808
          ? { activatedAt: new Date(String(row.activated_at)).toISOString() }
809
          : {}),
810
        ...(row.completed_at
811
          ? { completedAt: new Date(String(row.completed_at)).toISOString() }
812
          : {}),
813
        expiresAt: new Date(String(row.expires_at)).toISOString(),
814
        ...(row.last_error ? { lastError: String(row.last_error) } : {}),
815
      })),
816
      runtimes,
817
      accessTokens,
818
    };
819
  }
820
821
  async listContextualIntelligence(
822
    operatorId: string,
823
    input: { agentId?: string; taskCategory?: string } = {},
824
  ) {
825
    await this.ensureSchema();
826
    const [records, publicRows] = await Promise.all([
827
      this.list(operatorId),
828
      this
829
        .sql`SELECT agent_id, card FROM openclasp_public_agents ORDER BY updated_at DESC LIMIT 1000`,
830
    ]);
831
    const currentVersions = new Map<string, string>();
832
    for (const row of publicRows) {
833
      const card = PublicAgentCardSchema.safeParse(normalizePublicAgentCard(row.card));
834
      if (card.success) currentVersions.set(String(row.agent_id), card.data.agentVersion);
835
    }
836
    for (const record of records.filter((item) => item.kind === 'agent_profile')) {
837
      const profile = record.payload as Partial<AgentProfile>;
838
      if (typeof profile.agentId === 'string' && typeof profile.agentVersion === 'string')
839
        currentVersions.set(profile.agentId, profile.agentVersion);
840
    }
841
    const deltas = records
842
      .filter((record) => record.kind === 'profile_delta')
843
      .map((record) => BehaviouralProfileDeltaSchema.safeParse(record.payload))
844
      .filter((result) => result.success)
845
      .map((result) => result.data);
846
    return records
847
      .filter((record) => record.kind === 'profile')
848
      .map((record) => ({ recordId: record.recordId, ...record.payload }) as ContextualProfile)
849
      .filter(
850
        (profile) =>
851
          (!input.agentId || profile.agentId === input.agentId) &&
852
          (!input.taskCategory || profile.taskCategory === input.taskCategory),
853
      )
854
      .map((profile) =>
855
        summarizeContextualReliability({
856
          profile: {
857
            ...profile,
858
            effectiveSampleSize: profile.effectiveSampleSize ?? profile.sampleSize,
859
          },
860
          deltas: deltas.filter(
861
            (delta) =>
862
              delta.agentId === profile.agentId &&
863
              delta.agentVersion === profile.agentVersion &&
864
              delta.taskCategory === profile.taskCategory,
865
          ),
866
          currentAgentVersion: currentVersions.get(profile.agentId) ?? profile.agentVersion,
867
        }),
868
      )
869
      .sort((left, right) => {
870
        if (left.agentId !== right.agentId) return left.agentId.localeCompare(right.agentId);
871
        if (left.versionStatus.status !== right.versionStatus.status)
872
          return left.versionStatus.status === 'current' ? -1 : 1;
873
        return right.confidence.value - left.confidence.value;
874
      });
875
  }
876
877
  async issueAgentAccessToken(
878
    operatorId: string,
879
    agentId: string,
880
    input: { name: string; expiresInDays: number },
881
  ): Promise<AgentAccessTokenMetadata & { token: string }> {
882
    await this.ensureSchema();
883
    const agents = await this.sql`
884
      SELECT payload FROM openclasp_records
885
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
886
      LIMIT 1
887
    `;
888
    const agent = agents[0]?.payload as AgentProfile | undefined;
889
    if (!agent) throw new Error('Owned agent not found');
890
    if (agent.status !== 'active') throw new Error('Revoked agents cannot receive access tokens');
891
    const name = input.name.trim();
892
    if (!name) throw new Error('Token name is required');
893
    const { tokenId, token, tokenHash } = createAgentAccessToken();
894
    const createdAt = new Date();
895
    const expiresAt = new Date(createdAt.getTime() + input.expiresInDays * 86_400_000);
896
    // The credential is bound to one agent. It may use MCP outbound and let that
897
    // same agent register its own inbound runtime; it cannot manage other agents.
898
    const scopes = DEFAULT_AGENT_AUTH_SCOPES;
899
    await this.sql`
900
      INSERT INTO openclasp_agent_access_tokens(
901
        token_id, operator_id, agent_id, name, token_hash, scopes, expires_at, created_at
902
      ) VALUES (
903
        ${tokenId}, ${operatorId}, ${agentId}, ${name}, ${tokenHash},
904
        ${JSON.stringify(scopes)}::jsonb, ${expiresAt.toISOString()}, ${createdAt.toISOString()}
905
      )
906
    `;
907
    const clientId = agentAccessTokenClientId(tokenId);
908
    await this.upsert(operatorId, 'installation', clientId, {
909
      installationId: `install_${tokenId}`,
910
      clientId,
911
      agentId,
912
      projectId: agent.projectId,
913
      connectedAt: createdAt.toISOString(),
914
      updatedAt: createdAt.toISOString(),
915
    } satisfies AgentInstallation);
916
    return {
917
      tokenId,
918
      token,
919
      agentId,
920
      name,
921
      scopes,
922
      createdAt: createdAt.toISOString(),
923
      expiresAt: expiresAt.toISOString(),
924
    };
925
  }
926
927
  async listAgentAccessTokens(
928
    operatorId: string,
929
    agentId?: string,
930
  ): Promise<AgentAccessTokenMetadata[]> {
931
    await this.ensureSchema();
932
    const rows = agentId
933
      ? await this.sql`
934
          SELECT token_id, agent_id, name, scopes, expires_at, last_used_at, revoked_at, created_at
935
          FROM openclasp_agent_access_tokens
936
          WHERE operator_id = ${operatorId} AND agent_id = ${agentId}
937
          ORDER BY created_at DESC
938
        `
939
      : await this.sql`
940
          SELECT token_id, agent_id, name, scopes, expires_at, last_used_at, revoked_at, created_at
941
          FROM openclasp_agent_access_tokens
942
          WHERE operator_id = ${operatorId}
943
          ORDER BY created_at DESC
944
        `;
945
    return rows.map((row) => ({
946
      tokenId: String(row.token_id),
947
      agentId: String(row.agent_id),
948
      name: String(row.name),
949
      scopes: Array.isArray(row.scopes)
950
        ? row.scopes.filter((scope): scope is string => typeof scope === 'string')
951
        : [],
952
      createdAt: new Date(String(row.created_at)).toISOString(),
953
      expiresAt: new Date(String(row.expires_at)).toISOString(),
954
      ...(row.last_used_at ? { lastUsedAt: new Date(String(row.last_used_at)).toISOString() } : {}),
955
      ...(row.revoked_at ? { revokedAt: new Date(String(row.revoked_at)).toISOString() } : {}),
956
    }));
957
  }
958
959
  async revokeAgentAccessToken(operatorId: string, agentId: string, tokenId: string) {
960
    await this.ensureSchema();
961
    const rows = await this.sql`
962
      UPDATE openclasp_agent_access_tokens
963
      SET revoked_at = COALESCE(revoked_at, NOW())
964
      WHERE token_id = ${tokenId} AND operator_id = ${operatorId} AND agent_id = ${agentId}
965
      RETURNING revoked_at
966
    `;
967
    if (!rows.length) throw new Error('Agent access token not found');
968
    const clientId = agentAccessTokenClientId(tokenId);
969
    await this.sql`
970
      DELETE FROM openclasp_records
971
      WHERE operator_id = ${operatorId} AND kind = 'installation' AND record_id = ${clientId}
972
    `;
973
    return {
974
      tokenId,
975
      agentId,
976
      revokedAt: new Date(String(rows[0]?.revoked_at)).toISOString(),
977
    };
978
  }
979
980
  async verifyAgentAccessToken(token: string) {
981
    await this.ensureSchema();
982
    const tokenId = agentAccessTokenId(token);
983
    if (!tokenId) throw new Error('Invalid agent access token');
984
    const rows = await this.sql`
985
      SELECT tokens.operator_id, tokens.agent_id, tokens.token_hash, tokens.scopes,
986
        tokens.expires_at, profile.payload AS profile
987
      FROM openclasp_agent_access_tokens tokens
988
      INNER JOIN openclasp_records profile
989
        ON profile.operator_id = tokens.operator_id
990
        AND profile.kind = 'agent_profile'
991
        AND profile.record_id = tokens.agent_id
992
      WHERE tokens.token_id = ${tokenId} AND tokens.revoked_at IS NULL
993
      LIMIT 1
994
    `;
995
    const row = rows[0];
996
    if (!row || !matchesAgentAccessToken(token, String(row.token_hash)))
997
      throw new Error('Invalid agent access token');
998
    if (Date.parse(String(row.expires_at)) <= Date.now())
999
      throw new Error('Agent access token has expired');
1000
    const profile = row.profile as Partial<AgentProfile>;
1001
    if (profile.status !== 'active') throw new Error('Agent access token is disabled');
1002
    await this.sql`
1003
      UPDATE openclasp_agent_access_tokens
1004
      SET last_used_at = NOW()
1005
      WHERE token_id = ${tokenId}
1006
        AND (last_used_at IS NULL OR last_used_at < NOW() - INTERVAL '5 minutes')
1007
    `;
1008
    return {
1009
      tokenId,
1010
      operatorId: String(row.operator_id),
1011
      agentId: String(row.agent_id),
1012
      clientId: agentAccessTokenClientId(tokenId),
1013
      scopes: Array.isArray(row.scopes)
1014
        ? row.scopes.filter((scope): scope is string => typeof scope === 'string')
1015
        : [],
1016
      expiresAt: new Date(String(row.expires_at)).toISOString(),
1017
    };
1018
  }
1019
1020
  async publishAgent(operatorId: string, card: PublicAgentCard): Promise<PublicAgentCard> {
1021
    await this.ensureSchema();
1022
    card = PublicAgentCardSchema.parse(card);
1023
    const runtimes = await this.sql`
1024
      SELECT a2a_endpoint, endpoint FROM openclasp_agent_runtimes
1025
      WHERE operator_id = ${operatorId} AND agent_id = ${card.agentId} AND status = 'verified'
1026
    `;
1027
    if (!runtimes[0]) throw new Error('Connect and verify the agent runtime before publishing');
1028
    card = PublicAgentCardSchema.parse({
1029
      ...card,
1030
      agentMode: 'persistent_runtime',
1031
      transports: [
1032
        {
1033
          protocol: 'A2A/1.0',
1034
          protocolBinding: 'JSONRPC',
1035
          endpoint: String(runtimes[0].a2a_endpoint ?? runtimes[0].endpoint),
1036
          managedBy: 'agent',
1037
        },
1038
      ],
1039
    });
1040
    card = this.attestPublicAgentCard(card);
1041
    const encoded = JSON.stringify(card);
1042
    const rows = await this.sql`
1043
      INSERT INTO openclasp_public_agents(agent_id, operator_id, card, published_at, updated_at)
1044
      VALUES (${card.agentId}, ${operatorId}, ${encoded}::jsonb, NOW(), NOW())
1045
      ON CONFLICT (agent_id) DO UPDATE SET
1046
        card = EXCLUDED.card,
1047
        updated_at = NOW()
1048
      WHERE openclasp_public_agents.operator_id = ${operatorId}
1049
      RETURNING card
1050
    `;
1051
    const published = rows[0]?.card as PublicAgentCard | undefined;
1052
    if (!published) throw new Error('Agent ID is already owned by another operator');
1053
    return PublicAgentCardSchema.parse(published);
1054
  }
1055
1056
  async unpublishAgent(operatorId: string, agentId: string): Promise<boolean> {
1057
    await this.ensureSchema();
1058
    const rows = await this.sql`
1059
      DELETE FROM openclasp_public_agents
1060
      WHERE agent_id = ${agentId} AND operator_id = ${operatorId}
1061
      RETURNING agent_id
1062
    `;
1063
    return rows.length > 0;
1064
  }
1065
1066
  async getPublishedAgent(agentId: string): Promise<PublicAgentCard | undefined> {
1067
    await this.ensureSchema();
1068
    const rows = await this.sql`
1069
      SELECT agents.card, runtime.a2a_endpoint, runtime.endpoint, profile.payload AS profile
1070
      FROM openclasp_public_agents agents
1071
      INNER JOIN openclasp_agent_runtimes runtime
1072
        ON runtime.agent_id = agents.agent_id AND runtime.status = 'verified'
1073
      LEFT JOIN openclasp_records profile
1074
        ON profile.operator_id = agents.operator_id AND profile.kind = 'agent_profile'
1075
        AND profile.record_id = agents.agent_id
1076
      WHERE agents.agent_id = ${agentId}
1077
    `;
1078
    if (!rows[0]?.card) return undefined;
1079
    const card = PublicAgentCardSchema.parse({
1080
      ...normalizePublicAgentCard(rows[0].card),
1081
      agentMode: 'persistent_runtime',
1082
      transports: [
1083
        {
1084
          protocol: 'A2A/1.0',
1085
          protocolBinding: 'JSONRPC',
1086
          endpoint: String(rows[0].a2a_endpoint ?? rows[0].endpoint),
1087
          managedBy: 'agent',
1088
        },
1089
      ],
1090
    });
1091
    return this.attestPublicAgentCard({ ...card, presence: await this.getAgentPresence(agentId) });
1092
  }
1093
1094
  async resolveAgentReference(reference: string): Promise<AgentResolution | undefined> {
1095
    await this.ensureSchema();
1096
    const normalizedReference = reference.trim().replace(/\/$/, '');
1097
    if (!normalizedReference || normalizedReference.length > 2048)
1098
      throw new Error('Agent reference is invalid');
1099
    const rows = await this.sql`
1100
      SELECT agent_id, card
1101
      FROM openclasp_public_agents
1102
      WHERE agent_id = ${normalizedReference}
1103
        OR card->>'slug' = ${normalizedReference}
1104
        OR RTRIM(card->>'profileUrl', '/') = ${normalizedReference}
1105
        OR RTRIM(card->>'cardUrl', '/') = ${normalizedReference}
1106
        OR RTRIM(card->>'a2aAgentCardUrl', '/') = ${normalizedReference}
1107
      LIMIT 1
1108
    `;
1109
    let row = rows[0];
1110
    if (!row) {
1111
      const legacyRows = await this.sql`
1112
        SELECT agent_id, card
1113
        FROM openclasp_public_agents
1114
        ORDER BY updated_at DESC
1115
        LIMIT 1000
1116
      `;
1117
      row = legacyRows.find((candidate) => {
1118
        const card = normalizePublicAgentCard(candidate.card);
1119
        return [card.slug, card.profileUrl, card.cardUrl, card.a2aAgentCardUrl]
1120
          .filter((value): value is string => typeof value === 'string')
1121
          .some((value) => value.replace(/\/$/, '') === normalizedReference);
1122
      });
1123
    }
1124
    if (!row) return undefined;
1125
    const stored = normalizePublicAgentCard(row.card);
1126
    const matchedBy =
1127
      String(row.agent_id) === normalizedReference
1128
        ? 'agent_id'
1129
        : stored.slug === normalizedReference
1130
          ? 'slug'
1131
          : stored.profileUrl?.replace(/\/$/, '') === normalizedReference
1132
            ? 'profile_url'
1133
            : stored.cardUrl.replace(/\/$/, '') === normalizedReference
1134
              ? 'card_url'
1135
              : 'a2a_card_url';
1136
    const card = await this.getPublishedAgent(String(row.agent_id));
1137
    if (!card) return undefined;
1138
    return AgentResolutionSchema.parse({
1139
      reference,
1140
      matchedBy,
1141
      verified: true,
1142
      card,
1143
      resolvedAt: new Date().toISOString(),
1144
    });
1145
  }
1146
1147
  async searchPublishedAgents(input: {
1148
    query?: string | undefined;
1149
    capability?: string | undefined;
1150
    limit?: number | undefined;
1151
  }): Promise<PublicAgentCard[]> {
1152
    await this.ensureSchema();
1153
    const rows = await this.sql`
1154
      SELECT agents.card, runtime.a2a_endpoint, runtime.endpoint, profile.payload AS profile
1155
      FROM openclasp_public_agents agents
1156
      INNER JOIN openclasp_agent_runtimes runtime
1157
        ON runtime.agent_id = agents.agent_id AND runtime.status = 'verified'
1158
      LEFT JOIN openclasp_records profile
1159
        ON profile.operator_id = agents.operator_id AND profile.kind = 'agent_profile'
1160
        AND profile.record_id = agents.agent_id
1161
      ORDER BY agents.updated_at DESC LIMIT 100
1162
    `;
1163
    const query = input.query?.trim().toLowerCase();
1164
    const capability = input.capability?.trim().toLowerCase();
1165
    const presenceRows = await this.sql`
1166
      SELECT records.record_id, records.payload
1167
      FROM openclasp_records records
1168
      INNER JOIN openclasp_public_agents agents ON agents.agent_id = records.record_id
1169
      WHERE records.kind = 'presence'
1170
    `;
1171
    const presence = new Map(
1172
      presenceRows.map((row) => [String(row.record_id), String(row.payload.lastSeenAt)]),
1173
    );
1174
    return rows
1175
      .map((row) => {
1176
        const card = PublicAgentCardSchema.parse({
1177
          ...normalizePublicAgentCard(row.card),
1178
          agentMode: 'persistent_runtime',
1179
          transports: [
1180
            {
1181
              protocol: 'A2A/1.0',
1182
              protocolBinding: 'JSONRPC',
1183
              endpoint: String(row.a2a_endpoint ?? row.endpoint),
1184
              managedBy: 'agent',
1185
            },
1186
          ],
1187
        });
1188
        return this.attestPublicAgentCard({
1189
          ...card,
1190
          presence: resolveAgentPresence(presence.get(card.agentId)),
1191
        });
1192
      })
1193
      .filter(
1194
        (card) =>
1195
          (!query ||
1196
            card.agentId.toLowerCase().includes(query) ||
1197
            card.slug?.includes(query) ||
1198
            card.name.toLowerCase().includes(query) ||
1199
            card.description.toLowerCase().includes(query) ||
1200
            card.framework.toLowerCase().includes(query) ||
1201
            card.capabilities.some((value) => value.toLowerCase().includes(query)) ||
1202
            card.limitations.some((value) => value.toLowerCase().includes(query))) &&
1203
          (!capability ||
1204
            card.capabilities.some((value) => value.toLowerCase().includes(capability))),
1205
      )
1206
      .slice(0, Math.min(Math.max(input.limit ?? 20, 1), 50));
1207
  }
1208
1209
  async searchPersonalizedMarketplace(
1210
    operatorId: string,
1211
    input: {
1212
      agentId?: string;
1213
      taskCategory?: string;
1214
      query?: string;
1215
      limit?: number;
1216
    } = {},
1217
  ) {
1218
    const [cards, records, summaries] = await Promise.all([
1219
      this.searchPublishedAgents({ query: input.query, limit: input.limit }),
1220
      this.list(operatorId),
1221
      this.listContextualIntelligence(operatorId, {
1222
        ...(input.taskCategory ? { taskCategory: input.taskCategory } : {}),
1223
      }),
1224
    ]);
1225
    const ownedAgents = records
1226
      .filter((record) => record.kind === 'agent_profile')
1227
      .map((record) => record.payload as Partial<AgentProfile>);
1228
    const ownedIds = new Set(ownedAgents.map((agent) => agent.agentId));
1229
    const selected = ownedAgents.find((agent) => agent.agentId === input.agentId);
1230
    const taskCategory = input.taskCategory ?? selected?.capabilities?.[0] ?? 'general';
1231
    const normalizedCategory = taskCategory.toLowerCase();
1232
    const summaryFor = (agentId: string) =>
1233
      summaries.find(
1234
        (summary) =>
1235
          summary.agentId === agentId &&
1236
          summary.taskCategory.toLowerCase() === normalizedCategory &&
1237
          summary.versionStatus.status === 'current',
1238
      ) ?? summaries.find((summary) => summary.agentId === agentId);
1239
    return cards
1240
      .filter(
1241
        (card) =>
1242
          card.agentMode === 'persistent_runtime' &&
1243
          card.transports.some((transport) => transport.managedBy === 'agent'),
1244
      )
1245
      .filter((card) => !ownedIds.has(card.agentId))
1246
      .map((card) => {
1247
        const intelligence = summaryFor(card.agentId);
1248
        const capabilityMatch = card.capabilities.some((capability) =>
1249
          capability.toLowerCase().includes(normalizedCategory),
1250
        );
1251
        const online = card.presence?.status === 'online';
1252
        const fitScore = Math.min(
1253
          1,
1254
          0.25 +
1255
            (capabilityMatch ? 0.35 : 0) +
1256
            (online ? 0.1 : 0) +
1257
            (intelligence ? intelligence.score * intelligence.confidence.value * 0.3 : 0),
1258
        );
1259
        return {
1260
          card,
1261
          taskCategory,
1262
          ...(intelligence ? { contextualReliability: intelligence } : {}),
1263
          match: {
1264
            score: fitScore,
1265
            label: fitScore >= 0.72 ? 'strong' : fitScore >= 0.48 ? 'possible' : 'unproven',
1266
            reasons: [
1267
              capabilityMatch
1268
                ? `Published capability matches ${taskCategory}`
1269
                : `No explicit ${taskCategory} capability`,
1270
              intelligence
1271
                ? `${intelligence.confidence.evidenceCount} verified outcomes in private history`
1272
                : 'No verified private history for this task',
1273
              online ? 'Currently online' : 'Currently offline',
1274
            ],
1275
          },
1276
        };
1277
      })
1278
      .sort((left, right) => right.match.score - left.match.score);
1279
  }
1280
1281
  async touchAgentPresence(operatorId: string, agentId: string): Promise<AgentPresence> {
1282
    await this.ensureSchema();
1283
    const owned = await this.sql`
1284
      SELECT 1 FROM openclasp_records
1285
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
1286
    `;
1287
    if (!owned.length) throw new Error('Agent is not owned by this account');
1288
    const lastSeenAt = new Date().toISOString();
1289
    await this.upsert(operatorId, 'presence', agentId, { lastSeenAt });
1290
    await this.sql`
1291
      UPDATE openclasp_agent_runtimes SET last_seen_at = ${lastSeenAt}, updated_at = NOW()
1292
      WHERE operator_id = ${operatorId} AND agent_id = ${agentId} AND status = 'verified'
1293
    `;
1294
    return resolveAgentPresence(lastSeenAt);
1295
  }
1296
1297
  async getAgentPresence(agentId: string): Promise<AgentPresence> {
1298
    await this.ensureSchema();
1299
    const rows = await this.sql`
1300
      SELECT records.payload
1301
      FROM openclasp_public_agents agents
1302
      LEFT JOIN openclasp_records records
1303
        ON records.operator_id = agents.operator_id
1304
        AND records.kind = 'presence'
1305
        AND records.record_id = agents.agent_id
1306
      WHERE agents.agent_id = ${agentId}
1307
      LIMIT 1
1308
    `;
1309
    if (!rows.length) throw new Error('Published agent not found');
1310
    const lastSeenAt = rows[0]?.payload?.lastSeenAt;
1311
    return resolveAgentPresence(typeof lastSeenAt === 'string' ? lastSeenAt : undefined);
1312
  }
1313
1314
  async listAgentRuntimes(operatorId: string) {
1315
    await this.ensureSchema();
1316
    const rows = await this.sql`
1317
      SELECT agent_id, endpoint, a2a_endpoint, status, verified_at, last_seen_at, last_error, updated_at
1318
      FROM openclasp_agent_runtimes
1319
      WHERE operator_id = ${operatorId}
1320
      ORDER BY updated_at DESC
1321
    `;
1322
    return rows.map((row) => ({
1323
      agentId: String(row.agent_id),
1324
      endpoint: String(row.endpoint),
1325
      a2aEndpoint: String(row.a2a_endpoint ?? row.endpoint),
1326
      status: row.status as 'verified' | 'disabled',
1327
      verifiedAt: new Date(String(row.verified_at)).toISOString(),
1328
      ...(row.last_seen_at ? { lastSeenAt: new Date(String(row.last_seen_at)).toISOString() } : {}),
1329
      ...(row.last_error ? { lastError: String(row.last_error) } : {}),
1330
      updatedAt: new Date(String(row.updated_at)).toISOString(),
1331
    }));
1332
  }
1333
1334
  async registerAgentRuntime(operatorId: string, agentId: string, endpoint: string) {
1335
    await this.ensureSchema();
1336
    const owned = await this.sql`
1337
      SELECT 1 FROM openclasp_records
1338
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
1339
    `;
1340
    if (!owned.length) throw new Error('Agent is not owned by this account');
1341
    const challenge = crypto.randomUUID();
1342
    const verification = await postRuntimeJson(endpoint, {
1343
      type: 'openclasp.runtime.verify',
1344
      version: '1',
1345
      agentId,
1346
      challenge,
1347
    });
1348
    if (
1349
      verification.status < 200 ||
1350
      verification.status >= 300 ||
1351
      !verification.body ||
1352
      typeof verification.body !== 'object' ||
1353
      (verification.body as { type?: unknown }).type !== 'openclasp.runtime.verified' ||
1354
      (verification.body as { version?: unknown }).version !== '1' ||
1355
      (verification.body as { agentId?: unknown }).agentId !== agentId ||
1356
      (verification.body as { challenge?: unknown }).challenge !== challenge
1357
    )
1358
      throw new Error('Runtime endpoint did not return the verification challenge');
1359
    const a2aEndpointValue = (verification.body as { a2aEndpoint?: unknown }).a2aEndpoint;
1360
    const a2aEndpoint =
1361
      typeof a2aEndpointValue === 'string' && a2aEndpointValue ? a2aEndpointValue : endpoint;
1362
    await resolvePublicRuntimeEndpoint(a2aEndpoint);
1363
    const runtimeSecret = randomBytes(32).toString('base64url');
1364
    const encrypted = encryptGatewayPayload(this.gatewaySecret(), runtimeSecret);
1365
    const verifiedAt = new Date().toISOString();
1366
    const saved = await this.sql`
1367
      INSERT INTO openclasp_agent_runtimes(
1368
        agent_id, operator_id, endpoint, a2a_endpoint, secret_ciphertext, secret_iv, secret_auth_tag,
1369
        status, verified_at, last_seen_at, updated_at
1370
      ) VALUES (
1371
        ${agentId}, ${operatorId}, ${endpoint}, ${a2aEndpoint}, ${encrypted.ciphertext},
1372
        ${encrypted.iv}, ${encrypted.authTag}, 'verified', ${verifiedAt}, ${verifiedAt}, NOW()
1373
      )
1374
      ON CONFLICT (agent_id) DO UPDATE SET
1375
        operator_id = EXCLUDED.operator_id,
1376
        endpoint = EXCLUDED.endpoint,
1377
        a2a_endpoint = EXCLUDED.a2a_endpoint,
1378
        secret_ciphertext = EXCLUDED.secret_ciphertext,
1379
        secret_iv = EXCLUDED.secret_iv,
1380
        secret_auth_tag = EXCLUDED.secret_auth_tag,
1381
        status = 'verified',
1382
        verified_at = EXCLUDED.verified_at,
1383
        last_seen_at = EXCLUDED.last_seen_at,
1384
        last_error = NULL,
1385
        updated_at = NOW()
1386
      WHERE openclasp_agent_runtimes.operator_id = ${operatorId}
1387
      RETURNING agent_id
1388
    `;
1389
    if (!saved.length) throw new Error('Agent runtime is owned by another account');
1390
    await this.sql`
1391
      UPDATE openclasp_public_agents
1392
      SET card = jsonb_set(
1393
          jsonb_set(
1394
            card,
1395
            '{transports}',
1396
            ${JSON.stringify([
1397
              {
1398
                protocol: 'A2A/1.0',
1399
                protocolBinding: 'JSONRPC',
1400
                endpoint: a2aEndpoint,
1401
                managedBy: 'agent',
1402
              },
1403
            ])}::jsonb
1404
          ),
1405
          '{agentMode}', '"persistent_runtime"'::jsonb
1406
        ), updated_at = NOW()
1407
      WHERE agent_id = ${agentId} AND operator_id = ${operatorId}
1408
    `;
1409
    await this.sql`
1410
      UPDATE openclasp_records
1411
      SET payload = jsonb_set(
1412
          jsonb_set(payload, '{a2aEndpoint}', to_jsonb(${a2aEndpoint}::text)),
1413
          '{agentMode}', '"persistent_runtime"'::jsonb
1414
        ),
1415
        updated_at = NOW()
1416
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
1417
    `;
1418
    await this.touchAgentPresence(operatorId, agentId);
1419
    return {
1420
      agentId,
1421
      endpoint,
1422
      a2aEndpoint,
1423
      status: 'verified' as const,
1424
      verifiedAt,
1425
      verificationKey: getSessionVerificationKey(this.gatewaySecret()),
1426
    };
1427
  }
1428
1429
  async disableAgentRuntime(operatorId: string, agentId: string) {
1430
    await this.ensureSchema();
1431
    const rows = await this.sql`
1432
      UPDATE openclasp_agent_runtimes
1433
      SET status = 'disabled', updated_at = NOW()
1434
      WHERE operator_id = ${operatorId} AND agent_id = ${agentId}
1435
      RETURNING agent_id
1436
    `;
1437
    if (!rows.length) throw new Error('Agent runtime not found');
1438
    await this.sql`
1439
      DELETE FROM openclasp_public_agents
1440
      WHERE operator_id = ${operatorId} AND agent_id = ${agentId}
1441
    `;
1442
    await this.sql`
1443
      UPDATE openclasp_records
1444
      SET payload = jsonb_set(
1445
        jsonb_set(payload, '{published}', 'false'::jsonb),
1446
        '{updatedAt}', to_jsonb(${new Date().toISOString()}::text)
1447
      ), updated_at = NOW()
1448
      WHERE operator_id = ${operatorId} AND kind = 'publication' AND record_id = ${agentId}
1449
    `;
1450
    return { agentId, status: 'disabled' as const, unpublished: true as const };
1451
  }
1452
1453
  async deleteAgent(operatorId: string, agentId: string) {
1454
    await this.ensureSchema();
1455
    const owned = await this.sql`
1456
      SELECT 1 FROM openclasp_records
1457
      WHERE operator_id = ${operatorId}
1458
        AND kind = 'agent_profile'
1459
        AND record_id = ${agentId}
1460
      LIMIT 1
1461
    `;
1462
    if (!owned.length) throw new Error('Owned agent not found');
1463
    const openInteractions = await this.sql`
1464
      SELECT interaction_id FROM openclasp_federated_interactions
1465
      WHERE (initiator_agent_id = ${agentId} OR responder_agent_id = ${agentId})
1466
        AND status IN ('pending', 'active')
1467
      LIMIT 1
1468
    `;
1469
    if (openInteractions.length) {
1470
      const error = new Error('Finish or reject the agent’s open interactions before deleting it');
1471
      Object.assign(error, { statusCode: 409 });
1472
      throw error;
1473
    }
1474
    await this.sql`
1475
      DELETE FROM openclasp_public_agents
1476
      WHERE operator_id = ${operatorId} AND agent_id = ${agentId}
1477
    `;
1478
    await this.sql`
1479
      DELETE FROM openclasp_agent_runtimes
1480
      WHERE operator_id = ${operatorId} AND agent_id = ${agentId}
1481
    `;
1482
    await this.sql`
1483
      DELETE FROM openclasp_agent_access_tokens
1484
      WHERE operator_id = ${operatorId} AND agent_id = ${agentId}
1485
    `;
1486
    await this.sql`
1487
      DELETE FROM openclasp_records
1488
      WHERE operator_id = ${operatorId}
1489
        AND (
1490
          (record_id = ${agentId} AND kind IN ('agent', 'agent_profile', 'publication', 'presence'))
1491
          OR (kind = 'installation' AND payload->>'agentId' = ${agentId})
1492
          OR (kind = 'setup_request' AND payload->>'existingAgentId' = ${agentId})
1493
        )
1494
    `;
1495
    return {
1496
      agentId,
1497
      deleted: true as const,
1498
      historyRetained: true as const,
1499
    };
1500
  }
1501
1502
  private async sessionParticipant(agentId: string) {
1503
    const rows = await this.sql`
1504
      SELECT agents.operator_id, agents.card, profile.payload AS profile,
1505
        runtime.endpoint, runtime.a2a_endpoint
1506
      FROM openclasp_public_agents agents
1507
      LEFT JOIN openclasp_records profile
1508
        ON profile.operator_id = agents.operator_id
1509
        AND profile.kind = 'agent_profile'
1510
        AND profile.record_id = agents.agent_id
1511
      INNER JOIN openclasp_agent_runtimes runtime
1512
        ON runtime.agent_id = agents.agent_id AND runtime.status = 'verified'
1513
      WHERE agents.agent_id = ${agentId}
1514
      LIMIT 1
1515
    `;
1516
    const row = rows[0];
1517
    if (!row) throw new Error(`Published agent ${agentId} was not found`);
1518
    const card = normalizePublicAgentCard(row.card);
1519
    if (!row.endpoint) throw new Error(`Agent ${agentId} does not have a verified live runtime`);
1520
    return {
1521
      agentId,
1522
      operatorId: String(row.operator_id),
1523
      mode: 'persistent_runtime' as const,
1524
      callbackEndpoint: String(row.endpoint),
1525
      a2aEndpoint: String(row.a2a_endpoint ?? row.endpoint),
1526
      card: PublicAgentCardSchema.parse({ ...card, agentMode: 'persistent_runtime' }),
1527
    };
1528
  }
1529
1530
  private async signedRuntimeRequest(
1531
    runtime: { callbackEndpoint: string },
1532
    requestId: string,
1533
    value: unknown,
1534
  ) {
1535
    const body = JSON.stringify(value);
1536
    const timestamp = new Date().toISOString();
1537
    return postRuntimeJson(runtime.callbackEndpoint, value, {
1538
      'openclasp-request-id': requestId,
1539
      'openclasp-timestamp': timestamp,
1540
      'openclasp-signature': `v1=${signSessionControl(this.gatewaySecret(), requestId, timestamp, body)}`,
1541
    });
1542
  }
1543
1544
  private sessionGrant(
1545
    interaction: FederatedInteraction,
1546
    senderAgentId: string,
1547
    recipientAgentId: string,
1548
  ) {
1549
    return issueSessionGrant(this.gatewaySecret(), {
1550
      interactionId: interaction.interactionId,
1551
      senderAgentId,
1552
      recipientAgentId,
1553
      expiresAt: Math.min(Date.parse(interaction.expiresAt), Date.now() + 60 * 60_000),
1554
    });
1555
  }
1556
1557
  private async privateCounterpartyInsights(
1558
    viewerOperatorId: string,
1559
    counterparty: { agentId: string; card: PublicAgentCard },
1560
    taskCategory: string,
1561
  ): Promise<{
1562
    insights: LiveSessionInsight[];
1563
    relevantSampleSize: number;
1564
    historyConfidence: number;
1565
  }> {
1566
    const rows = await this.sql`
1567
      SELECT record_id, payload
1568
      FROM openclasp_records
1569
      WHERE operator_id = ${viewerOperatorId}
1570
        AND kind = 'profile'
1571
        AND payload->>'agentId' = ${counterparty.agentId}
1572
        AND payload->>'taskCategory' = ${taskCategory}
1573
      ORDER BY (payload->>'sampleSize')::integer DESC, updated_at DESC
1574
    `;
1575
    const profiles: ContextualProfile[] = rows
1576
      .map<Record<string, unknown>>((row) => ({
1577
        recordId: String(row.record_id),
1578
        ...(row.payload as Record<string, unknown>),
1579
      }))
1580
      .filter(
1581
        (profile) =>
1582
          typeof profile.agentVersion === 'string' &&
1583
          typeof profile.sampleSize === 'number' &&
1584
          typeof profile.updatedAt === 'string' &&
1585
          typeof profile.completion === 'number' &&
1586
          typeof profile.acceptance === 'number' &&
1587
          typeof profile.specification === 'number' &&
1588
          typeof profile.deadline === 'number' &&
1589
          typeof profile.communication === 'number' &&
1590
          typeof profile.evidence === 'number' &&
1591
          typeof profile.scope === 'number' &&
1592
          typeof profile.disputes === 'number',
1593
      ) as ContextualProfile[];
1594
    const exact = profiles.find(
1595
      (profile) => profile.agentVersion === counterparty.card.agentVersion,
1596
    );
1597
    const current = exact ?? profiles[0];
1598
    const insights: LiveSessionInsight[] = [];
1599
    let relevantSampleSize = 0;
1600
    let historyConfidence = 0;
1601
    if (!current) {
1602
      insights.push({
1603
        code: 'limited_verified_history',
1604
        severity: 'caution',
1605
        message: `No eligible ${taskCategory} history is available for this agent version. Ask for task-relevant evidence.`,
1606
        evidenceReferences: [],
1607
        requirementReferences: [],
1608
      });
1609
    } else {
1610
      const ageDays = Math.max(
1611
        0,
1612
        (Date.now() - Date.parse(String(current.updatedAt))) / 86_400_000,
1613
      );
1614
      const freshness = Math.exp(-ageDays / 180);
1615
      const sampleSize = Number(current.sampleSize);
1616
      const effectiveSampleSize = Number(current.effectiveSampleSize ?? sampleSize);
1617
      const versionChanged = current.agentVersion !== counterparty.card.agentVersion;
1618
      const confidence = Math.min(
1619
        0.95,
1620
        (effectiveSampleSize / (effectiveSampleSize + 5)) * freshness * (versionChanged ? 0.35 : 1),
1621
      );
1622
      relevantSampleSize = sampleSize;
1623
      historyConfidence = confidence;
1624
      const reference = `openclasp:profile:${current.recordId}`;
1625
      insights.push({
1626
        code: versionChanged ? 'version_history_only' : 'contextual_history',
1627
        severity: versionChanged || confidence < 0.25 ? 'caution' : 'info',
1628
        message: versionChanged
1629
          ? `Only prior-version ${taskCategory} history is available (${sampleSize} eligible interaction${sampleSize === 1 ? '' : 's'}); confidence is reduced for version ${counterparty.card.agentVersion}.`
1630
          : `Based on ${sampleSize} eligible ${taskCategory} interaction${sampleSize === 1 ? '' : 's'}; confidence ${(confidence * 100).toFixed(0)}%.`,
1631
        evidenceReferences: [reference],
1632
        requirementReferences: [],
1633
      });
1634
      const weakDimensions = [
1635
        ['completion', Number(current.completion)],
1636
        ['output acceptance', Number(current.acceptance)],
1637
        ['specification adherence', Number(current.specification)],
1638
        ['deadline reliability', Number(current.deadline)],
1639
        ['communication quality', Number(current.communication)],
1640
        ['evidence quality', Number(current.evidence)],
1641
        ['scope adherence', Number(current.scope)],
1642
      ].filter(([, score]) => Number(score) < 0.7);
1643
      if (weakDimensions.length)
1644
        insights.push({
1645
          code: 'historical_weaknesses',
1646
          severity: weakDimensions.some(([, score]) => Number(score) < 0.5) ? 'high' : 'caution',
1647
          message: `Observed weaker areas: ${weakDimensions
1648
            .map(([name, score]) => `${String(name)} ${(Number(score) * 100).toFixed(0)}%`)
1649
            .join(', ')}.`,
1650
          evidenceReferences: [reference],
1651
          requirementReferences: [],
1652
        });
1653
      if (Number(current.disputes) > 0.25)
1654
        insights.push({
1655
          code: 'elevated_dispute_history',
1656
          severity: Number(current.disputes) >= 0.5 ? 'high' : 'caution',
1657
          message: `Eligible feedback shows a ${(Number(current.disputes) * 100).toFixed(0)}% dispute rate in this task category.`,
1658
          evidenceReferences: [reference],
1659
          requirementReferences: [],
1660
        });
1661
    }
1662
    if (counterparty.card.limitations.length)
1663
      insights.push({
1664
        code: 'declared_limitations',
1665
        severity: 'info',
1666
        message: `Counterparty declares: ${counterparty.card.limitations.join('; ')}`,
1667
        evidenceReferences: [counterparty.card.cardUrl],
1668
        requirementReferences: [],
1669
      });
1670
    return { insights, relevantSampleSize, historyConfidence };
1671
  }
1672
1673
  async brokerLiveSession(interaction: FederatedInteraction) {
1674
    await this.ensureSchema();
1675
    const existing = await this.sql`
1676
      SELECT status FROM openclasp_live_sessions
1677
      WHERE interaction_id = ${interaction.interactionId}
1678
    `;
1679
    if (existing[0]?.status === 'active') return;
1680
    const [initiator, responder] = await Promise.all([
1681
      this.sessionParticipant(interaction.initiatorAgentId),
1682
      this.sessionParticipant(interaction.responderAgentId),
1683
    ]);
1684
    const [initiatorContext, responderContext] = await Promise.all([
1685
      this.privateCounterpartyInsights(
1686
        initiator.operatorId,
1687
        responder,
1688
        interaction.contract.taskCategory,
1689
      ),
1690
      this.privateCounterpartyInsights(
1691
        responder.operatorId,
1692
        initiator,
1693
        interaction.contract.taskCategory,
1694
      ),
1695
    ]);
1696
    const issuedAt = new Date().toISOString();
1697
    const initiatorBrief = buildCounterpartyBrief({
1698
      interactionId: interaction.interactionId,
1699
      contractHash: interaction.termsHash,
1700
      contract: interaction.contract,
1701
      recipientAgentId: initiator.agentId,
1702
      subject: responder.card,
1703
      historyInsights: initiatorContext.insights,
1704
      relevantSampleSize: initiatorContext.relevantSampleSize,
1705
      historyConfidence: initiatorContext.historyConfidence,
1706
      generatedAt: issuedAt,
1707
      expiresAt: interaction.expiresAt,
1708
    });
1709
    const responderBrief = buildCounterpartyBrief({
1710
      interactionId: interaction.interactionId,
1711
      contractHash: interaction.termsHash,
1712
      contract: interaction.contract,
1713
      recipientAgentId: responder.agentId,
1714
      subject: initiator.card,
1715
      historyInsights: responderContext.insights,
1716
      relevantSampleSize: responderContext.relevantSampleSize,
1717
      historyConfidence: responderContext.historyConfidence,
1718
      generatedAt: issuedAt,
1719
      expiresAt: interaction.expiresAt,
1720
    });
1721
    await Promise.all([
1722
      this.upsert(
1723
        initiator.operatorId,
1724
        'counterparty_brief',
1725
        initiatorBrief.briefId,
1726
        initiatorBrief,
1727
      ),
1728
      this.upsert(
1729
        responder.operatorId,
1730
        'counterparty_brief',
1731
        responderBrief.briefId,
1732
        responderBrief,
1733
      ),
1734
    ]);
1735
    const makeOffer = (
1736
      runtime: typeof initiator,
1737
      counterparty: typeof responder,
1738
      role: 'initiator' | 'responder',
1739
      privateInsights: LiveSessionInsight[],
1740
      counterpartyBrief: CounterpartyBrief,
1741
    ) =>
1742
      LiveSessionOfferSchema.parse({
1743
        type: 'openclasp.session.offer',
1744
        version: '1',
1745
        offerId: crypto.randomUUID(),
1746
        interactionId: interaction.interactionId,
1747
        agentId: runtime.agentId,
1748
        role,
1749
        counterparty: {
1750
          agentId: counterparty.agentId,
1751
          name: counterparty.card.name,
1752
          agentVersion: counterparty.card.agentVersion,
1753
          capabilities: counterparty.card.capabilities,
1754
        },
1755
        contract: interaction.contract,
1756
        contractHash: interaction.termsHash,
1757
        privateInsights,
1758
        counterpartyBrief,
1759
        issuedAt,
1760
        expiresAt: interaction.expiresAt,
1761
      });
1762
    const initiatorOffer = makeOffer(
1763
      initiator,
1764
      responder,
1765
      'initiator',
1766
      initiatorBrief.insights,
1767
      initiatorBrief,
1768
    );
1769
    const responderOffer = makeOffer(
1770
      responder,
1771
      initiator,
1772
      'responder',
1773
      responderBrief.insights,
1774
      responderBrief,
1775
    );
1776
    const prepare = async (
1777
      participant: typeof initiator,
1778
      offer: typeof initiatorOffer,
1779
      label: 'Initiator' | 'Responder',
1780
    ) => {
1781
      if (!participant.callbackEndpoint) throw new Error(`${label} runtime is not configured`);
1782
      const response = await this.signedRuntimeRequest(
1783
        { callbackEndpoint: participant.callbackEndpoint },
1784
        offer.offerId,
1785
        offer,
1786
      );
1787
      if (response.status < 200 || response.status >= 300)
1788
        throw new Error(`${label} runtime is not live (HTTP ${response.status})`);
1789
      return LiveSessionAcceptanceSchema.parse(response.body);
1790
    };
1791
    const [initiatorAcceptance, responderAcceptance] = await Promise.all([
1792
      prepare(initiator, initiatorOffer, 'Initiator'),
1793
      prepare(responder, responderOffer, 'Responder'),
1794
    ]);
1795
    if (
1796
      initiatorAcceptance.offerId !== initiatorOffer.offerId ||
1797
      initiatorAcceptance.agentId !== initiator.agentId ||
1798
      responderAcceptance.offerId !== responderOffer.offerId ||
1799
      responderAcceptance.agentId !== responder.agentId
1800
    )
1801
      throw new Error('Runtime returned a mismatched live-session acceptance');
1802
    await Promise.all([
1803
      ...(initiator.mode === 'persistent_runtime'
1804
        ? [resolvePublicRuntimeEndpoint(initiatorAcceptance.a2aEndpoint)]
1805
        : []),
1806
      ...(responder.mode === 'persistent_runtime'
1807
        ? [resolvePublicRuntimeEndpoint(responderAcceptance.a2aEndpoint)]
1808
        : []),
1809
    ]);
1810
    await this.sql`
1811
      INSERT INTO openclasp_live_sessions(
1812
        interaction_id, initiator_agent_id, responder_agent_id,
1813
        initiator_session_id, responder_session_id,
1814
        initiator_endpoint, responder_endpoint, status, expires_at
1815
      ) VALUES (
1816
        ${interaction.interactionId}, ${interaction.initiatorAgentId}, ${interaction.responderAgentId},
1817
        ${initiatorAcceptance.sessionId}, ${responderAcceptance.sessionId},
1818
        ${initiatorAcceptance.a2aEndpoint}, ${responderAcceptance.a2aEndpoint},
1819
        'preparing', ${interaction.expiresAt}
1820
      )
1821
      ON CONFLICT (interaction_id) DO UPDATE SET
1822
        initiator_session_id = EXCLUDED.initiator_session_id,
1823
        responder_session_id = EXCLUDED.responder_session_id,
1824
        initiator_endpoint = EXCLUDED.initiator_endpoint,
1825
        responder_endpoint = EXCLUDED.responder_endpoint,
1826
        status = 'preparing', expires_at = EXCLUDED.expires_at,
1827
        activated_at = NULL, completed_at = NULL, last_error = NULL
1828
    `;
1829
    await this.journalLiveSessionState(interaction.interactionId);
1830
    const baseUrl = (process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(
1831
      /\/$/,
1832
      '',
1833
    );
1834
    const activatedAt = new Date().toISOString();
1835
    const verificationKey = getSessionVerificationKey(this.gatewaySecret());
1836
    const makeActivation = (
1837
      agentId: string,
1838
      sessionId: string,
1839
      role: 'initiator' | 'responder',
1840
      peerAgentId: string,
1841
      peerSessionId: string,
1842
      peerEndpoint: string,
1843
      privateInsights: LiveSessionInsight[],
1844
      counterpartyBrief: CounterpartyBrief,
1845
    ): LiveSessionActivation => {
1846
      const bearerToken = this.sessionGrant(interaction, agentId, peerAgentId);
1847
      return LiveSessionActivationSchema.parse({
1848
        type: 'openclasp.session.activation',
1849
        version: '1',
1850
        activationId: crypto.randomUUID(),
1851
        interactionId: interaction.interactionId,
1852
        agentId,
1853
        sessionId,
1854
        role,
1855
        peer: {
1856
          agentId: peerAgentId,
1857
          sessionId: peerSessionId,
1858
          endpoint: peerEndpoint,
1859
          bearerToken,
1860
          verificationKey,
1861
        },
1862
        reporting: {
1863
          endpoint: `${baseUrl}/sessions/${encodeURIComponent(interaction.interactionId)}/events`,
1864
          completionEndpoint: `${baseUrl}/sessions/${encodeURIComponent(interaction.interactionId)}/completion-reports`,
1865
          feedbackEndpoint: `${baseUrl}/sessions/${encodeURIComponent(interaction.interactionId)}/feedback`,
1866
          bearerToken,
1867
        },
1868
        privateInsights,
1869
        counterpartyBrief,
1870
        contractHash: interaction.termsHash,
1871
        activatedAt,
1872
        expiresAt: interaction.expiresAt,
1873
      });
1874
    };
1875
    const initiatorActivation = makeActivation(
1876
      initiator.agentId,
1877
      initiatorAcceptance.sessionId,
1878
      'initiator',
1879
      responder.agentId,
1880
      responderAcceptance.sessionId,
1881
      responderAcceptance.a2aEndpoint,
1882
      initiatorBrief.insights,
1883
      initiatorBrief,
1884
    );
1885
    const responderActivation = makeActivation(
1886
      responder.agentId,
1887
      responderAcceptance.sessionId,
1888
      'responder',
1889
      initiator.agentId,
1890
      initiatorAcceptance.sessionId,
1891
      initiatorAcceptance.a2aEndpoint,
1892
      responderBrief.insights,
1893
      responderBrief,
1894
    );
1895
    try {
1896
      const activate = async (
1897
        participant: typeof initiator,
1898
        activation: LiveSessionActivation,
1899
        label: 'Initiator' | 'Responder',
1900
      ) => {
1901
        if (!participant.callbackEndpoint) throw new Error(`${label} runtime is not configured`);
1902
        const response = await this.signedRuntimeRequest(
1903
          { callbackEndpoint: participant.callbackEndpoint },
1904
          activation.activationId,
1905
          activation,
1906
        );
1907
        if (response.status < 200 || response.status >= 300)
1908
          throw new Error(`${label} activation failed with HTTP ${response.status}`);
1909
      };
1910
      await activate(responder, responderActivation, 'Responder');
1911
      await activate(initiator, initiatorActivation, 'Initiator');
1912
      await this.sql`
1913
        UPDATE openclasp_live_sessions
1914
        SET status = 'active', activated_at = NOW(), last_error = NULL
1915
        WHERE interaction_id = ${interaction.interactionId}
1916
      `;
1917
      await this.journalLiveSessionState(interaction.interactionId);
1918
      await this.sql`
1919
        UPDATE openclasp_agent_runtimes
1920
        SET last_seen_at = NOW(), last_error = NULL, updated_at = NOW()
1921
        WHERE agent_id IN (${initiator.agentId}, ${responder.agentId})
1922
      `;
1923
      await Promise.all([
1924
        this.touchAgentPresence(initiator.operatorId, initiator.agentId),
1925
        this.touchAgentPresence(responder.operatorId, responder.agentId),
1926
      ]);
1927
    } catch (error) {
1928
      const reason =
1929
        error instanceof Error ? error.message.slice(0, 500) : 'Session activation failed';
1930
      await this.sql`
1931
        UPDATE openclasp_live_sessions SET status = 'failed', last_error = ${reason}
1932
        WHERE interaction_id = ${interaction.interactionId}
1933
      `;
1934
      await this.journalLiveSessionState(interaction.interactionId);
1935
      throw error;
1936
    }
1937
  }
1938
1939
  async getLiveSession(operatorId: string, interactionId: string, agentId: string) {
1940
    await this.ensureSchema();
1941
    const rows = await this.sql`
1942
      SELECT session.*, interaction.payload,
1943
        initiator.operator_id AS initiator_operator_id,
1944
        responder.operator_id AS responder_operator_id
1945
      FROM openclasp_live_sessions session
1946
      INNER JOIN openclasp_federated_interactions interaction
1947
        ON interaction.interaction_id = session.interaction_id
1948
      INNER JOIN openclasp_public_agents initiator
1949
        ON initiator.agent_id = session.initiator_agent_id
1950
      INNER JOIN openclasp_public_agents responder
1951
        ON responder.agent_id = session.responder_agent_id
1952
      WHERE session.interaction_id = ${interactionId} AND session.status = 'active'
1953
      LIMIT 1
1954
    `;
1955
    const row = rows[0];
1956
    if (!row) throw new Error('Active live session not found');
1957
    const isInitiator = row.initiator_agent_id === agentId;
1958
    const isResponder = row.responder_agent_id === agentId;
1959
    if (!isInitiator && !isResponder) throw new Error('Agent is not a session participant');
1960
    const owner = isInitiator ? row.initiator_operator_id : row.responder_operator_id;
1961
    if (owner !== operatorId) throw new Error('Agent is not owned by this account');
1962
    const interaction = FederatedInteractionSchema.parse(row.payload);
1963
    const peerAgentId = String(isInitiator ? row.responder_agent_id : row.initiator_agent_id);
1964
    const peer = await this.sessionParticipant(peerAgentId);
1965
    const privateContext = await this.privateCounterpartyInsights(
1966
      String(owner),
1967
      peer,
1968
      interaction.contract.taskCategory,
1969
    );
1970
    const briefRows = await this.sql`
1971
      SELECT payload FROM openclasp_records
1972
      WHERE operator_id = ${String(owner)}
1973
        AND kind = 'counterparty_brief'
1974
        AND payload->>'interactionId' = ${interactionId}
1975
        AND payload->>'recipientAgentId' = ${agentId}
1976
      ORDER BY updated_at DESC
1977
      LIMIT 1
1978
    `;
1979
    const storedBrief = CounterpartyBriefSchema.safeParse(briefRows[0]?.payload);
1980
    const counterpartyBrief = storedBrief.success
1981
      ? storedBrief.data
1982
      : buildCounterpartyBrief({
1983
          interactionId,
1984
          contractHash: interaction.termsHash,
1985
          contract: interaction.contract,
1986
          recipientAgentId: agentId,
1987
          subject: peer.card,
1988
          historyInsights: privateContext.insights,
1989
          relevantSampleSize: privateContext.relevantSampleSize,
1990
          historyConfidence: privateContext.historyConfidence,
1991
          expiresAt: new Date(String(row.expires_at)).toISOString(),
1992
        });
1993
    const bearerToken = this.sessionGrant(interaction, agentId, peerAgentId);
1994
    return {
1995
      type: 'openclasp.session.activation' as const,
1996
      version: '1' as const,
1997
      activationId: crypto.randomUUID(),
1998
      interactionId,
1999
      agentId,
2000
      sessionId: String(isInitiator ? row.initiator_session_id : row.responder_session_id),
2001
      role: isInitiator ? ('initiator' as const) : ('responder' as const),
2002
      peer: {
2003
        agentId: peerAgentId,
2004
        sessionId: String(isInitiator ? row.responder_session_id : row.initiator_session_id),
2005
        endpoint: String(isInitiator ? row.responder_endpoint : row.initiator_endpoint),
2006
        bearerToken,
2007
        verificationKey: getSessionVerificationKey(this.gatewaySecret()),
2008
      },
2009
      reporting: {
2010
        endpoint: `${(process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(/\/$/, '')}/sessions/${encodeURIComponent(interactionId)}/events`,
2011
        completionEndpoint: `${(process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(/\/$/, '')}/sessions/${encodeURIComponent(interactionId)}/completion-reports`,
2012
        feedbackEndpoint: `${(process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(/\/$/, '')}/sessions/${encodeURIComponent(interactionId)}/feedback`,
2013
        bearerToken,
2014
      },
2015
      privateInsights: privateContext.insights,
2016
      counterpartyBrief,
2017
      contractHash: interaction.termsHash,
2018
      activatedAt: row.activated_at
2019
        ? new Date(String(row.activated_at)).toISOString()
2020
        : new Date().toISOString(),
2021
      expiresAt: new Date(String(row.expires_at)).toISOString(),
2022
    };
2023
  }
2024
2025
  private async interactionParticipant(operatorId: string, interactionId: string, agentId: string) {
2026
    await this.ensureSchema();
2027
    const rows = await this.sql`
2028
      SELECT payload, initiator_operator_id, responder_operator_id,
2029
        initiator_agent_id, responder_agent_id
2030
      FROM openclasp_federated_interactions
2031
      WHERE interaction_id = ${interactionId}
2032
        AND (
2033
          (initiator_operator_id = ${operatorId} AND initiator_agent_id = ${agentId})
2034
          OR (responder_operator_id = ${operatorId} AND responder_agent_id = ${agentId})
2035
        )
2036
      LIMIT 1
2037
    `;
2038
    const row = rows[0];
2039
    if (!row) throw new Error('Interaction participant not found for this account');
2040
    const interaction = FederatedInteractionSchema.parse(row.payload);
2041
    const isInitiator = interaction.initiatorAgentId === agentId;
2042
    return {
2043
      interaction,
2044
      counterpartyAgentId: isInitiator
2045
        ? interaction.responderAgentId
2046
        : interaction.initiatorAgentId,
2047
      participantOperatorId: String(
2048
        isInitiator ? row.initiator_operator_id : row.responder_operator_id,
2049
      ),
2050
      counterpartyOperatorId: String(
2051
        isInitiator ? row.responder_operator_id : row.initiator_operator_id,
2052
      ),
2053
    };
2054
  }
2055
2056
  async getCounterpartyBrief(operatorId: string, interactionId: string, agentId: string) {
2057
    await this.interactionParticipant(operatorId, interactionId, agentId);
2058
    const rows = await this.sql`
2059
      SELECT payload FROM openclasp_records
2060
      WHERE operator_id = ${operatorId}
2061
        AND kind = 'counterparty_brief'
2062
        AND payload->>'interactionId' = ${interactionId}
2063
        AND payload->>'recipientAgentId' = ${agentId}
2064
      ORDER BY updated_at DESC
2065
      LIMIT 1
2066
    `;
2067
    const parsed = CounterpartyBriefSchema.safeParse(rows[0]?.payload);
2068
    if (!parsed.success) throw new Error('Counterparty brief is not available');
2069
    return parsed.data;
2070
  }
2071
2072
  private async ensureFeedbackRequest(
2073
    operatorId: string,
2074
    interactionId: string,
2075
    reviewerAgentId: string,
2076
    subjectAgentId: string,
2077
  ) {
2078
    const existing = await this.sql`
2079
      SELECT payload FROM openclasp_records
2080
      WHERE operator_id = ${operatorId}
2081
        AND kind = 'feedback_request'
2082
        AND payload->>'interactionId' = ${interactionId}
2083
        AND payload->>'reviewerAgentId' = ${reviewerAgentId}
2084
      ORDER BY updated_at DESC
2085
      LIMIT 1
2086
    `;
2087
    const parsed = FeedbackRequestSchema.safeParse(existing[0]?.payload);
2088
    if (parsed.success) return parsed.data;
2089
    const requestedAt = new Date();
2090
    const base = FeedbackRequestSchema.parse({
2091
      requestId: deterministicUuid(`feedback-request:${interactionId}:${reviewerAgentId}`),
2092
      interactionId,
2093
      reviewerAgentId,
2094
      subjectAgentId,
2095
      status: 'pending',
2096
      requestedDimensions: FEEDBACK_DIMENSIONS,
2097
      requestedAt: requestedAt.toISOString(),
2098
      dueAt: new Date(requestedAt.getTime() + feedbackWindowMilliseconds()).toISOString(),
2099
    });
2100
    const request = FeedbackRequestSchema.parse({
2101
      ...base,
2102
      platformAttestation: attestSessionRecord(this.gatewaySecret(), base),
2103
    });
2104
    await this.upsert(operatorId, 'feedback_request', request.requestId, request);
2105
    return request;
2106
  }
2107
2108
  private async requestRuntimeFinalization(
2109
    interactionId: string,
2110
    reportingAgentId: string,
2111
    counterpartyAgentId: string,
2112
    contractHash: string,
2113
  ) {
2114
    const rows = await this.sql`
2115
      SELECT endpoint FROM openclasp_agent_runtimes
2116
      WHERE agent_id = ${counterpartyAgentId} AND status = 'verified'
2117
      LIMIT 1
2118
    `;
2119
    if (!rows[0]?.endpoint) return false;
2120
    const requestId = crypto.randomUUID();
2121
    const value = {
2122
      type: 'openclasp.session.finalization_request',
2123
      version: '1',
2124
      requestId,
2125
      interactionId,
2126
      agentId: counterpartyAgentId,
2127
      peerAgentId: reportingAgentId,
2128
      contractHash,
2129
      requestedAt: new Date().toISOString(),
2130
    };
2131
    const response = await this.signedRuntimeRequest(
2132
      { callbackEndpoint: String(rows[0].endpoint) },
2133
      requestId,
2134
      value,
2135
    );
2136
    return response.status >= 200 && response.status < 300;
2137
  }
2138
2139
  async listFeedbackRequests(operatorId: string, agentId: string) {
2140
    const rows = await this.sql`
2141
      SELECT payload FROM openclasp_records
2142
      WHERE operator_id = ${operatorId}
2143
        AND kind = 'feedback_request'
2144
        AND payload->>'reviewerAgentId' = ${agentId}
2145
      ORDER BY updated_at DESC
2146
    `;
2147
    return rows.map((row) => FeedbackRequestSchema.parse(row.payload));
2148
  }
2149
2150
  async submitCompletionReport(
2151
    operatorId: string,
2152
    agentId: string,
2153
    value: InteractionCompletionReport,
2154
    submissionMethod:
2155
      'oauth_account' | 'oauth_installation' | 'agent_access_token' | 'runtime_session',
2156
  ) {
2157
    const participant = await this.interactionParticipant(operatorId, value.interactionId, agentId);
2158
    const report = InteractionCompletionReportSchema.parse(value);
2159
    if (report.platformAttestation)
2160
      throw new Error('Platform attestation is assigned by OpenClasp');
2161
    if (report.reportingAgentId !== agentId)
2162
      throw new Error('Completion report identity does not match the authenticated agent');
2163
    if (report.counterpartyAgentId !== participant.counterpartyAgentId)
2164
      throw new Error('Completion report counterparty does not match the interaction');
2165
    if (report.contractHash !== participant.interaction.termsHash)
2166
      throw new Error('Completion report contract hash does not match the interaction');
2167
    if (report.requestedOutcome !== participant.interaction.contract.requestedOutcome)
2168
      throw new Error('Completion report requested outcome does not match the contract');
2169
    if (
2170
      report.criteria.some(
2171
        (criterion) =>
2172
          !participant.interaction.contract.successCriteria.includes(criterion.criterion),
2173
      )
2174
    )
2175
      throw new Error('Completion report contains a criterion outside the contract');
2176
    if (!['active', 'completed'].includes(participant.interaction.status))
2177
      throw new Error('Only active or completed interactions accept completion reports');
2178
    const profiles = await this.sql`
2179
      SELECT payload FROM openclasp_records
2180
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
2181
      LIMIT 1
2182
    `;
2183
    const profile = profiles[0]?.payload as Partial<AgentProfile> | undefined;
2184
    if (!profile || profile.agentVersion !== report.agentVersion)
2185
      throw new Error('Completion report agent version does not match the registered agent');
2186
    let verifiedSubmissionMethod: InteractionCompletionReport['submissionMethod'] =
2187
      submissionMethod;
2188
    if (report.signature) {
2189
      if (report.submissionMethod !== 'agent_signature')
2190
        throw new Error('Signed completion reports must declare agent_signature submission');
2191
      const identities = await this.sql`
2192
        SELECT payload FROM openclasp_records
2193
        WHERE operator_id = ${operatorId} AND kind = 'agent' AND record_id = ${agentId}
2194
        LIMIT 1
2195
      `;
2196
      const identity = AgentIdentitySchema.safeParse(identities[0]?.payload);
2197
      if (
2198
        !identity.success ||
2199
        !verifyObject(report as unknown as Record<string, unknown>, identity.data.publicKey)
2200
      )
2201
        throw new Error('Completion report agent signature is invalid or unverifiable');
2202
      verifiedSubmissionMethod = 'agent_signature';
2203
    }
2204
    const unsigned = InteractionCompletionReportSchema.parse({
2205
      ...report,
2206
      submissionMethod: verifiedSubmissionMethod,
2207
    });
2208
    const stored = InteractionCompletionReportSchema.parse({
2209
      ...unsigned,
2210
      platformAttestation: attestSessionRecord(this.gatewaySecret(), unsigned),
2211
    });
2212
    const existing = await this.sql`
2213
      SELECT payload FROM openclasp_records
2214
      WHERE kind = 'completion_report' AND record_id = ${stored.reportId}
2215
        AND payload->>'interactionId' = ${stored.interactionId}
2216
      LIMIT 1
2217
    `;
2218
    if (existing[0] && canonicalHash(existing[0].payload) !== canonicalHash(stored))
2219
      throw new Error('Conflicting completion report ID');
2220
    await Promise.all([
2221
      this.upsert(participant.participantOperatorId, 'completion_report', stored.reportId, stored),
2222
      this.upsert(participant.counterpartyOperatorId, 'completion_report', stored.reportId, stored),
2223
    ]);
2224
    const [feedbackRequest] = await Promise.all([
2225
      this.ensureFeedbackRequest(
2226
        participant.participantOperatorId,
2227
        stored.interactionId,
2228
        stored.reportingAgentId,
2229
        stored.counterpartyAgentId,
2230
      ),
2231
      this.ensureFeedbackRequest(
2232
        participant.counterpartyOperatorId,
2233
        stored.interactionId,
2234
        stored.counterpartyAgentId,
2235
        stored.reportingAgentId,
2236
      ),
2237
    ]);
2238
    const completed = await this.sql`
2239
      SELECT COUNT(DISTINCT payload->>'reportingAgentId') AS count
2240
      FROM openclasp_records
2241
      WHERE kind = 'completion_report'
2242
        AND payload->>'interactionId' = ${stored.interactionId}
2243
    `;
2244
    let peerReportStatus: InteractionConclusion['peerReportStatus'] = 'received';
2245
    if (Number(completed[0]?.count ?? 0) >= 2) {
2246
      const completedAt = new Date().toISOString();
2247
      await Promise.all([
2248
        this.sql`
2249
          UPDATE openclasp_live_sessions SET status = 'completed', completed_at = NOW()
2250
          WHERE interaction_id = ${stored.interactionId} AND status = 'active'
2251
        `,
2252
        this.sql`
2253
          UPDATE openclasp_federated_interactions
2254
          SET status = 'completed',
2255
            payload = jsonb_set(
2256
              jsonb_set(payload, '{status}', '"completed"'::jsonb),
2257
              '{updatedAt}', to_jsonb(${completedAt}::text)
2258
            ),
2259
            updated_at = NOW()
2260
          WHERE interaction_id = ${stored.interactionId} AND status = 'active'
2261
        `,
2262
      ]);
2263
      await Promise.all([
2264
        this.journalLiveSessionState(stored.interactionId),
2265
        this.journalFederatedInteraction(stored.interactionId),
2266
      ]);
2267
    } else {
2268
      const requested = await this.requestRuntimeFinalization(
2269
        stored.interactionId,
2270
        stored.reportingAgentId,
2271
        stored.counterpartyAgentId,
2272
        stored.contractHash,
2273
      ).catch(() => false);
2274
      peerReportStatus = requested ? 'awaiting' : 'unreachable';
2275
    }
2276
    const conclusion = await this.finalizeInteractionConclusion(stored.interactionId, {
2277
      peerReportStatus,
2278
    });
2279
    return {
2280
      report: stored,
2281
      feedbackRequest,
2282
      peerReportRequested: peerReportStatus === 'awaiting',
2283
      ...(conclusion.released ? { conclusion: conclusion.conclusion } : {}),
2284
    };
2285
  }
2286
2287
  async recordSessionCompletionReport(token: string, value: InteractionCompletionReport) {
2288
    const report = InteractionCompletionReportSchema.parse(value);
2289
    const grant = this.verifySessionGrant(token);
2290
    if (
2291
      grant.interactionId !== report.interactionId ||
2292
      grant.senderAgentId !== report.reportingAgentId ||
2293
      grant.recipientAgentId !== report.counterpartyAgentId
2294
    )
2295
      throw new Error('Session credential does not match the completion report');
2296
    const owners = await this.sql`
2297
      SELECT initiator_operator_id, responder_operator_id,
2298
        initiator_agent_id, responder_agent_id
2299
      FROM openclasp_federated_interactions
2300
      WHERE interaction_id = ${report.interactionId}
2301
      LIMIT 1
2302
    `;
2303
    const row = owners[0];
2304
    if (!row) throw new Error('Interaction not found');
2305
    const operatorId = String(
2306
      row.initiator_agent_id === report.reportingAgentId
2307
        ? row.initiator_operator_id
2308
        : row.responder_operator_id,
2309
    );
2310
    return this.submitCompletionReport(
2311
      operatorId,
2312
      report.reportingAgentId,
2313
      report,
2314
      'runtime_session',
2315
    );
2316
  }
2317
2318
  private async applyInteractionLearning(input: {
2319
    interaction: FederatedInteraction;
2320
    initiatorOperatorId: string;
2321
    responderOperatorId: string;
2322
    reports: InteractionCompletionReport[];
2323
    feedback: InteractionFeedback[];
2324
    conclusion: InteractionConclusion;
2325
  }) {
2326
    const { interaction, reports, feedback, conclusion } = input;
2327
    const [initiatorSettings, responderSettings, cards, pairActivity] = await Promise.all([
2328
      this.getSettings(input.initiatorOperatorId),
2329
      this.getSettings(input.responderOperatorId),
2330
      this.sql`
2331
        SELECT agent_id, card FROM openclasp_public_agents
2332
        WHERE agent_id IN (${interaction.initiatorAgentId}, ${interaction.responderAgentId})
2333
      `,
2334
      this.sql`
2335
        SELECT COUNT(*)::integer AS count
2336
        FROM openclasp_federated_interactions
2337
        WHERE created_at >= NOW() - INTERVAL '24 hours'
2338
          AND (
2339
            (initiator_agent_id = ${interaction.initiatorAgentId}
2340
              AND responder_agent_id = ${interaction.responderAgentId})
2341
            OR
2342
            (initiator_agent_id = ${interaction.responderAgentId}
2343
              AND responder_agent_id = ${interaction.initiatorAgentId})
2344
          )
2345
      `,
2346
    ]);
2347
    const contributionMode =
2348
      initiatorSettings.contributionEnabled && responderSettings.contributionEnabled
2349
        ? ('network_aggregate' as const)
2350
        : ('local_only' as const);
2351
    const eligibilityBase = evaluateLearningEligibility({
2352
      interactionId: interaction.interactionId,
2353
      reports,
2354
      feedback,
2355
      consensus: conclusion.consensus,
2356
      contributionMode,
2357
      reviewerCredibility: Object.fromEntries(
2358
        feedback.map((item) => [item.reviewerAgentId, reviewerCredibility(item)]),
2359
      ),
2360
      manipulationSignals: [
2361
        ...(input.initiatorOperatorId === input.responderOperatorId ? ['same_operator_pair'] : []),
2362
        ...(Number(pairActivity[0]?.count ?? 0) > 10 ? ['rapid_reciprocal_pair_activity'] : []),
2363
      ],
2364
      decisionId: deterministicUuid(`learning-eligibility:${interaction.interactionId}`),
2365
      decidedAt: conclusion.generatedAt,
2366
    });
2367
    const eligibility = LearningEligibilityDecisionSchema.parse({
2368
      ...eligibilityBase,
2369
      platformAttestation: attestSessionRecord(this.gatewaySecret(), eligibilityBase),
2370
    });
2371
    await Promise.all([
2372
      this.upsert(
2373
        input.initiatorOperatorId,
2374
        'learning_eligibility',
2375
        eligibility.decisionId,
2376
        eligibility,
2377
      ),
2378
      this.upsert(
2379
        input.responderOperatorId,
2380
        'learning_eligibility',
2381
        eligibility.decisionId,
2382
        eligibility,
2383
      ),
2384
    ]);
2385
    if (!eligibility.eligible || eligibility.sampleWeight <= 0)
2386
      return { eligibility, profileDeltas: [] as BehaviouralProfileDelta[] };
2387
2388
    const appliedAt = new Date().toISOString();
2389
    const versions = new Map<string, string>();
2390
    for (const report of reports) versions.set(report.reportingAgentId, report.agentVersion);
2391
    for (const row of cards) {
2392
      const card = PublicAgentCardSchema.safeParse(row.card);
2393
      if (card.success && !versions.has(String(row.agent_id)))
2394
        versions.set(String(row.agent_id), card.data.agentVersion);
2395
    }
2396
    const participants = [
2397
      {
2398
        viewerOperatorId: input.initiatorOperatorId,
2399
        reviewerAgentId: interaction.initiatorAgentId,
2400
        subjectAgentId: interaction.responderAgentId,
2401
      },
2402
      {
2403
        viewerOperatorId: input.responderOperatorId,
2404
        reviewerAgentId: interaction.responderAgentId,
2405
        subjectAgentId: interaction.initiatorAgentId,
2406
      },
2407
      {
2408
        viewerOperatorId: input.initiatorOperatorId,
2409
        reviewerAgentId: interaction.responderAgentId,
2410
        subjectAgentId: interaction.initiatorAgentId,
2411
      },
2412
      {
2413
        viewerOperatorId: input.responderOperatorId,
2414
        reviewerAgentId: interaction.initiatorAgentId,
2415
        subjectAgentId: interaction.responderAgentId,
2416
      },
2417
    ];
2418
    const profileDeltas: Array<BehaviouralProfileDelta | undefined> = [];
2419
    for (const participant of participants) {
2420
      const agentVersion = versions.get(participant.subjectAgentId);
2421
      if (!agentVersion) {
2422
        profileDeltas.push(undefined);
2423
        continue;
2424
      }
2425
      const deltaId = deterministicUuid(
2426
        `profile-delta:${interaction.interactionId}:${participant.viewerOperatorId}:${participant.reviewerAgentId}:${participant.subjectAgentId}`,
2427
      );
2428
      const legacyDeltaId = deterministicUuid(
2429
        `profile-delta:${interaction.interactionId}:${participant.viewerOperatorId}:${participant.subjectAgentId}`,
2430
      );
2431
      const existingDelta = await this.sql`
2432
          SELECT payload FROM openclasp_records
2433
          WHERE operator_id = ${participant.viewerOperatorId}
2434
            AND kind = 'profile_delta'
2435
            AND (record_id = ${deltaId} OR record_id = ${legacyDeltaId})
2436
          LIMIT 1
2437
        `;
2438
      const parsedDelta = BehaviouralProfileDeltaSchema.safeParse(existingDelta[0]?.payload);
2439
      if (parsedDelta.success) {
2440
        profileDeltas.push(parsedDelta.data);
2441
        continue;
2442
      }
2443
      const profileId = deterministicUuid(
2444
        `contextual-profile:${participant.subjectAgentId}:${agentVersion}:${interaction.contract.taskCategory}`,
2445
      );
2446
      const currentRows = await this.sql`
2447
          SELECT payload FROM openclasp_records
2448
          WHERE operator_id = ${participant.viewerOperatorId}
2449
            AND kind = 'profile' AND record_id = ${profileId}
2450
          LIMIT 1
2451
        `;
2452
      const current = currentRows[0]?.payload as Partial<ContextualProfile> | undefined;
2453
      const reviewerFeedback = feedback.find(
2454
        (item) =>
2455
          item.reviewerAgentId === participant.reviewerAgentId &&
2456
          item.subjectAgentId === participant.subjectAgentId,
2457
      );
2458
      const observations = deriveBehaviouralObservations({
2459
        subjectAgentId: participant.subjectAgentId,
2460
        reviewerAgentId: participant.reviewerAgentId,
2461
        reports,
2462
        ...(reviewerFeedback ? { reviewerFeedback } : {}),
2463
        conclusion,
2464
      });
2465
      if (!Object.keys(observations).length) {
2466
        profileDeltas.push(undefined);
2467
        continue;
2468
      }
2469
      const applied = updateContextualBehaviouralProfile({
2470
        ...(current ? { current } : {}),
2471
        observations,
2472
        sampleWeight: eligibility.sampleWeight,
2473
        appliedAt,
2474
      });
2475
      const profile: ContextualProfile = {
2476
        recordId: profileId,
2477
        agentId: participant.subjectAgentId,
2478
        agentVersion,
2479
        taskCategory: interaction.contract.taskCategory,
2480
        ...applied.profile,
2481
      };
2482
      const deltaBase = BehaviouralProfileDeltaSchema.parse({
2483
        deltaId,
2484
        interactionId: interaction.interactionId,
2485
        agentId: participant.subjectAgentId,
2486
        agentVersion,
2487
        taskCategory: interaction.contract.taskCategory,
2488
        sampleWeight: eligibility.sampleWeight,
2489
        dimensionDeltas: applied.dimensionDeltas,
2490
        explanation:
2491
          'Applied platform-attested structured outcome and eligible reviewer signals; private comments and raw conversation content were excluded.',
2492
        appliedAt,
2493
      });
2494
      const delta = BehaviouralProfileDeltaSchema.parse({
2495
        ...deltaBase,
2496
        platformAttestation: attestSessionRecord(this.gatewaySecret(), deltaBase),
2497
      });
2498
      await Promise.all([
2499
        this.upsert(participant.viewerOperatorId, 'profile', profileId, profile),
2500
        this.upsert(participant.viewerOperatorId, 'profile_delta', deltaId, delta),
2501
      ]);
2502
      profileDeltas.push(delta);
2503
    }
2504
    return {
2505
      eligibility,
2506
      profileDeltas: profileDeltas.filter(
2507
        (delta): delta is BehaviouralProfileDelta => delta !== undefined,
2508
      ),
2509
    };
2510
  }
2511
2512
  private async finalizeInteractionConclusion(
2513
    interactionId: string,
2514
    options: { peerReportStatus?: InteractionConclusion['peerReportStatus'] } = {},
2515
  ) {
2516
    const [interactionRows, reportRows, feedbackRows, requestRows, existingRows] =
2517
      await Promise.all([
2518
        this.sql`
2519
        SELECT payload, initiator_operator_id, responder_operator_id
2520
        FROM openclasp_federated_interactions
2521
        WHERE interaction_id = ${interactionId}
2522
        LIMIT 1
2523
      `,
2524
        this.sql`
2525
        SELECT record_id, payload FROM openclasp_records
2526
        WHERE kind = 'completion_report' AND payload->>'interactionId' = ${interactionId}
2527
      `,
2528
        this.sql`
2529
        SELECT record_id, payload FROM openclasp_records
2530
        WHERE kind = 'interaction_feedback' AND payload->>'interactionId' = ${interactionId}
2531
      `,
2532
        this.sql`
2533
        SELECT record_id, payload FROM openclasp_records
2534
        WHERE kind = 'feedback_request' AND payload->>'interactionId' = ${interactionId}
2535
      `,
2536
        this.sql`
2537
        SELECT payload FROM openclasp_records
2538
        WHERE kind = 'interaction_conclusion' AND payload->>'interactionId' = ${interactionId}
2539
        LIMIT 1
2540
      `,
2541
      ]);
2542
    const interactionRow = interactionRows[0];
2543
    if (!interactionRow) throw new Error('Interaction not found');
2544
    const interaction = FederatedInteractionSchema.parse(interactionRow.payload);
2545
    const reports = [
2546
      ...new Map(
2547
        reportRows
2548
          .map((row) => InteractionCompletionReportSchema.parse(row.payload))
2549
          .map((report) => [report.reportId, report]),
2550
      ).values(),
2551
    ];
2552
    const feedback = [
2553
      ...new Map(
2554
        feedbackRows
2555
          .map((row) => InteractionFeedbackSchema.parse(row.payload))
2556
          .map((item) => [item.feedbackId, item]),
2557
      ).values(),
2558
    ];
2559
    const requests = [
2560
      ...new Map(
2561
        requestRows
2562
          .map((row) => FeedbackRequestSchema.parse(row.payload))
2563
          .map((request) => [request.requestId, request]),
2564
      ).values(),
2565
    ];
2566
    if (!reports.length) return { released: false as const, feedbackRevealed: false as const };
2567
    const existing = InteractionConclusionSchema.safeParse(existingRows[0]?.payload);
2568
    const reportingAgentIds = new Set(reports.map((report) => report.reportingAgentId));
2569
    const missingReportAgentIds = [
2570
      interaction.initiatorAgentId,
2571
      interaction.responderAgentId,
2572
    ].filter((agentId) => !reportingAgentIds.has(agentId));
2573
    const feedbackWindowClosed =
2574
      requests.length >= 2 && requests.every((request) => request.status !== 'pending');
2575
    const lifecycle =
2576
      missingReportAgentIds.length === 0 || feedbackWindowClosed
2577
        ? ('final' as const)
2578
        : ('provisional' as const);
2579
    const visibleFeedback = feedbackWindowClosed ? feedback : [];
2580
    const peerReportStatus: InteractionConclusion['peerReportStatus'] =
2581
      missingReportAgentIds.length === 0
2582
        ? 'received'
2583
        : feedbackWindowClosed
2584
          ? 'timed_out'
2585
          : (options.peerReportStatus ?? existing.data?.peerReportStatus ?? 'awaiting');
2586
    const pendingFeedbackAgentIds = requests
2587
      .filter((request) => request.status === 'pending')
2588
      .map((request) => request.reviewerAgentId);
2589
    if (requests.length < 2) return { released: false as const };
2590
    const base = buildInteractionConclusion({
2591
      interaction,
2592
      reports,
2593
      feedback: visibleFeedback,
2594
      conclusionId: deterministicUuid(`interaction-conclusion:${interactionId}`),
2595
      lifecycle,
2596
      pendingFeedbackAgentIds,
2597
      peerReportStatus,
2598
    });
2599
    const conclusion = InteractionConclusionSchema.parse({
2600
      ...base,
2601
      platformAttestation: attestSessionRecord(this.gatewaySecret(), base),
2602
    });
2603
    const startedAt = reports
2604
      .map((report) => report.startedAt)
2605
      .filter((value): value is string => typeof value === 'string')
2606
      .sort()[0];
2607
    const receiptBase = {
2608
      receiptId: deterministicUuid(`interaction-receipt:${interactionId}`),
2609
      interactionId,
2610
      participants: [interaction.initiatorAgentId, interaction.responderAgentId],
2611
      agentVersions: Object.fromEntries(
2612
        reports.map((report) => [report.reportingAgentId, report.agentVersion]),
2613
      ),
2614
      contractHash: interaction.termsHash,
2615
      startedAt: startedAt ?? interaction.createdAt,
2616
      completedAt:
2617
        reports
2618
          .map((report) => report.completedAt)
2619
          .sort()
2620
          .at(-1) ?? conclusion.generatedAt,
2621
      outcome: conclusion.outcome,
2622
      commitmentsFulfilled: conclusion.criteria
2623
        .filter((criterion) => criterion.status === 'met')
2624
        .map((criterion) => criterion.criterion),
2625
      commitmentsMissed: conclusion.criteria
2626
        .filter((criterion) => criterion.status !== 'met')
2627
        .map((criterion) => criterion.criterion),
2628
      evidenceHashes: conclusion.evidenceReferences.map((reference) => canonicalHash(reference)),
2629
      policyWarnings: reports.flatMap((report) => report.blockers),
2630
      policyViolations: [],
2631
      disputeStatus: conclusion.consensus === 'conflicting' ? ('open' as const) : ('none' as const),
2632
      delegationChainHash: canonicalHash(interaction.contract.delegationRules),
2633
      unilateral: reports.length < 2,
2634
      provisional: lifecycle === 'provisional',
2635
      confidence: conclusion.confidence,
2636
      signatures: {},
2637
      completionReportIds: conclusion.reportIds,
2638
      conclusionId: conclusion.conclusionId,
2639
    };
2640
    const receipt = ReceiptSchema.parse({
2641
      ...receiptBase,
2642
      platformAttestation: attestSessionRecord(this.gatewaySecret(), receiptBase),
2643
    });
2644
    await Promise.all([
2645
      this.upsert(
2646
        String(interactionRow.initiator_operator_id),
2647
        'interaction_conclusion',
2648
        conclusion.conclusionId,
2649
        conclusion,
2650
      ),
2651
      this.upsert(
2652
        String(interactionRow.responder_operator_id),
2653
        'interaction_conclusion',
2654
        conclusion.conclusionId,
2655
        conclusion,
2656
      ),
2657
      this.upsert(
2658
        String(interactionRow.initiator_operator_id),
2659
        'receipt',
2660
        receipt.receiptId,
2661
        receipt,
2662
      ),
2663
      this.upsert(
2664
        String(interactionRow.responder_operator_id),
2665
        'receipt',
2666
        receipt.receiptId,
2667
        receipt,
2668
      ),
2669
    ]);
2670
    const learning =
2671
      lifecycle === 'final' && feedbackWindowClosed
2672
        ? await this.applyInteractionLearning({
2673
            interaction,
2674
            initiatorOperatorId: String(interactionRow.initiator_operator_id),
2675
            responderOperatorId: String(interactionRow.responder_operator_id),
2676
            reports,
2677
            feedback: visibleFeedback,
2678
            conclusion,
2679
          })
2680
        : undefined;
2681
    return {
2682
      released: true as const,
2683
      feedbackRevealed: feedbackWindowClosed,
2684
      conclusion,
2685
      receipt,
2686
      ...(learning ? { learning } : {}),
2687
    };
2688
  }
2689
2690
  async submitInteractionFeedback(
2691
    operatorId: string,
2692
    agentId: string,
2693
    value: InteractionFeedback,
2694
    submissionMethod:
2695
      'oauth_account' | 'oauth_installation' | 'agent_access_token' | 'runtime_session',
2696
  ) {
2697
    const feedback = InteractionFeedbackSchema.parse(value);
2698
    if (feedback.platformAttestation)
2699
      throw new Error('Platform attestation is assigned by OpenClasp');
2700
    if (feedback.reviewerAgentId !== agentId)
2701
      throw new Error('Feedback reviewer does not match the authenticated agent');
2702
    const participant = await this.interactionParticipant(
2703
      operatorId,
2704
      feedback.interactionId,
2705
      agentId,
2706
    );
2707
    if (feedback.subjectAgentId !== participant.counterpartyAgentId)
2708
      throw new Error('Feedback subject does not match the interaction counterparty');
2709
    const requestRows = await this.sql`
2710
      SELECT payload FROM openclasp_records
2711
      WHERE operator_id = ${operatorId}
2712
        AND kind = 'feedback_request'
2713
        AND record_id = ${feedback.requestId}
2714
      LIMIT 1
2715
    `;
2716
    const request = FeedbackRequestSchema.parse(requestRows[0]?.payload);
2717
    if (
2718
      request.interactionId !== feedback.interactionId ||
2719
      request.reviewerAgentId !== feedback.reviewerAgentId ||
2720
      request.subjectAgentId !== feedback.subjectAgentId
2721
    )
2722
      throw new Error('Feedback does not match its request');
2723
    if (request.status !== 'pending') throw new Error('Feedback request is no longer pending');
2724
    if (Date.parse(request.dueAt) <= Date.now()) throw new Error('Feedback request has expired');
2725
    if (
2726
      request.requestedDimensions.some(
2727
        (dimension) => typeof feedback.ratings[dimension] !== 'number',
2728
      )
2729
    )
2730
      throw new Error('Feedback must rate every requested dimension');
2731
    const profiles = await this.sql`
2732
      SELECT payload FROM openclasp_records
2733
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
2734
      LIMIT 1
2735
    `;
2736
    const profile = profiles[0]?.payload as Partial<AgentProfile> | undefined;
2737
    if (!profile || profile.agentVersion !== feedback.reviewerAgentVersion)
2738
      throw new Error('Feedback reviewer version does not match the registered agent');
2739
    let verifiedSubmissionMethod: InteractionFeedback['submissionMethod'] = submissionMethod;
2740
    if (feedback.signature) {
2741
      if (feedback.submissionMethod !== 'agent_signature')
2742
        throw new Error('Signed feedback must declare agent_signature submission');
2743
      const identities = await this.sql`
2744
        SELECT payload FROM openclasp_records
2745
        WHERE operator_id = ${operatorId} AND kind = 'agent' AND record_id = ${agentId}
2746
        LIMIT 1
2747
      `;
2748
      const identity = AgentIdentitySchema.safeParse(identities[0]?.payload);
2749
      if (
2750
        !identity.success ||
2751
        !verifyObject(feedback as unknown as Record<string, unknown>, identity.data.publicKey)
2752
      )
2753
        throw new Error('Feedback agent signature is invalid or unverifiable');
2754
      verifiedSubmissionMethod = 'agent_signature';
2755
    }
2756
    const unattested = InteractionFeedbackSchema.parse({
2757
      ...feedback,
2758
      submissionMethod: verifiedSubmissionMethod,
2759
    });
2760
    const stored = InteractionFeedbackSchema.parse({
2761
      ...unattested,
2762
      platformAttestation: attestSessionRecord(this.gatewaySecret(), unattested),
2763
    });
2764
    const existing = await this.sql`
2765
      SELECT payload FROM openclasp_records
2766
      WHERE operator_id = ${operatorId}
2767
        AND kind = 'interaction_feedback'
2768
        AND record_id = ${stored.feedbackId}
2769
      LIMIT 1
2770
    `;
2771
    if (existing[0] && canonicalHash(existing[0].payload) !== canonicalHash(stored))
2772
      throw new Error('Conflicting feedback ID');
2773
    await this.upsert(operatorId, 'interaction_feedback', stored.feedbackId, stored);
2774
    const requestBase = { ...request, status: 'submitted' as const };
2775
    delete requestBase.platformAttestation;
2776
    const updatedRequest = FeedbackRequestSchema.parse({
2777
      ...requestBase,
2778
      platformAttestation: attestSessionRecord(this.gatewaySecret(), requestBase),
2779
    });
2780
    await this.upsert(operatorId, 'feedback_request', request.requestId, updatedRequest);
2781
    const release = await this.finalizeInteractionConclusion(feedback.interactionId);
2782
    return {
2783
      feedbackId: stored.feedbackId,
2784
      status: 'submitted' as const,
2785
      revealed: release.released && release.feedbackRevealed,
2786
      ...(release.released ? { conclusion: release.conclusion } : {}),
2787
    };
2788
  }
2789
2790
  async processDueFeedback(now = new Date()) {
2791
    await this.ensureSchema();
2792
    const expiredInteractions = await this.sql`
2793
      UPDATE openclasp_federated_interactions
2794
      SET status = 'expired',
2795
        payload = jsonb_set(
2796
          jsonb_set(payload, '{status}', '"expired"'::jsonb),
2797
          '{updatedAt}', to_jsonb(${now.toISOString()}::text)
2798
        ),
2799
        updated_at = NOW()
2800
      WHERE status IN ('pending', 'active') AND expires_at <= ${now.toISOString()}
2801
      RETURNING interaction_id
2802
    `;
2803
    if (expiredInteractions.length) {
2804
      await this.sql`
2805
        UPDATE openclasp_live_sessions
2806
        SET status = 'failed', completed_at = COALESCE(completed_at, NOW()),
2807
          last_error = COALESCE(last_error, 'Interaction expired before bilateral completion')
2808
        WHERE status IN ('preparing', 'active')
2809
          AND interaction_id IN (
2810
            SELECT interaction_id FROM openclasp_federated_interactions
2811
            WHERE status = 'expired' AND expires_at <= ${now.toISOString()}
2812
          )
2813
      `;
2814
      await Promise.all(
2815
        expiredInteractions.flatMap((row) => {
2816
          const interactionId = String(row.interaction_id);
2817
          return [
2818
            this.journalFederatedInteraction(interactionId),
2819
            this.journalLiveSessionState(interactionId),
2820
          ];
2821
        }),
2822
      );
2823
    }
2824
    const [rows, backfillRows] = await Promise.all([
2825
      this.sql`
2826
        SELECT operator_id, record_id, payload FROM openclasp_records
2827
        WHERE kind = 'feedback_request'
2828
          AND payload->>'status' = 'pending'
2829
          AND (payload->>'dueAt')::timestamptz <= ${now.toISOString()}
2830
        LIMIT 500
2831
      `,
2832
      this.sql`
2833
        SELECT source.interaction_id
2834
        FROM (
2835
          SELECT conclusion.payload->>'interactionId' AS interaction_id
2836
          FROM openclasp_records conclusion
2837
          WHERE conclusion.kind = 'interaction_conclusion'
2838
            AND NOT EXISTS (
2839
              SELECT 1 FROM openclasp_records eligibility
2840
              WHERE eligibility.operator_id = conclusion.operator_id
2841
                AND eligibility.kind = 'learning_eligibility'
2842
                AND eligibility.payload->>'interactionId' = conclusion.payload->>'interactionId'
2843
            )
2844
          UNION
2845
          SELECT report.payload->>'interactionId' AS interaction_id
2846
          FROM openclasp_records report
2847
          WHERE report.kind = 'completion_report'
2848
            AND NOT EXISTS (
2849
              SELECT 1 FROM openclasp_records conclusion
2850
              WHERE conclusion.kind = 'interaction_conclusion'
2851
                AND conclusion.payload->>'interactionId' = report.payload->>'interactionId'
2852
            )
2853
        ) source
2854
        WHERE source.interaction_id IS NOT NULL
2855
        GROUP BY source.interaction_id
2856
        ORDER BY source.interaction_id ASC
2857
        LIMIT 200
2858
      `,
2859
    ]);
2860
    const interactions = new Set<string>();
2861
    for (const row of rows) {
2862
      const request = FeedbackRequestSchema.parse(row.payload);
2863
      const base = { ...request, status: 'expired' as const };
2864
      delete base.platformAttestation;
2865
      const expired = FeedbackRequestSchema.parse({
2866
        ...base,
2867
        platformAttestation: attestSessionRecord(this.gatewaySecret(), base),
2868
      });
2869
      await this.upsert(
2870
        String(row.operator_id),
2871
        'feedback_request',
2872
        String(row.record_id),
2873
        expired,
2874
      );
2875
      interactions.add(request.interactionId);
2876
    }
2877
    for (const row of backfillRows) interactions.add(String(row.interaction_id));
2878
    let released = 0;
2879
    for (const interactionId of interactions) {
2880
      const result = await this.finalizeInteractionConclusion(interactionId);
2881
      if (result.released) released += 1;
2882
    }
2883
    return {
2884
      expired: rows.length,
2885
      interactionsExpired: expiredInteractions.length,
2886
      released,
2887
      backfilled: backfillRows.length,
2888
    };
2889
  }
2890
2891
  async recordSessionFeedback(token: string, value: InteractionFeedback) {
2892
    const feedback = InteractionFeedbackSchema.parse(value);
2893
    const grant = this.verifySessionGrant(token);
2894
    if (
2895
      grant.interactionId !== feedback.interactionId ||
2896
      grant.senderAgentId !== feedback.reviewerAgentId ||
2897
      grant.recipientAgentId !== feedback.subjectAgentId
2898
    )
2899
      throw new Error('Session credential does not match the feedback');
2900
    const owners = await this.sql`
2901
      SELECT initiator_operator_id, responder_operator_id,
2902
        initiator_agent_id, responder_agent_id
2903
      FROM openclasp_federated_interactions
2904
      WHERE interaction_id = ${feedback.interactionId}
2905
      LIMIT 1
2906
    `;
2907
    const row = owners[0];
2908
    if (!row) throw new Error('Interaction not found');
2909
    const operatorId = String(
2910
      row.initiator_agent_id === feedback.reviewerAgentId
2911
        ? row.initiator_operator_id
2912
        : row.responder_operator_id,
2913
    );
2914
    return this.submitInteractionFeedback(
2915
      operatorId,
2916
      feedback.reviewerAgentId,
2917
      feedback,
2918
      'runtime_session',
2919
    );
2920
  }
2921
2922
  async recordLiveSessionEvent(token: string, value: LiveSessionEvent) {
2923
    await this.ensureSchema();
2924
    const event = LiveSessionEventSchema.parse(value);
2925
    const grant = this.verifySessionGrant(token);
2926
    if (grant.interactionId !== event.interactionId || grant.senderAgentId !== event.agentId)
2927
      throw new Error('Session credential does not match the event');
2928
    const sessions = await this.sql`
2929
      SELECT 1 FROM openclasp_live_sessions
2930
      WHERE interaction_id = ${event.interactionId}
2931
        AND status = 'active'
2932
        AND ${event.agentId} IN (initiator_agent_id, responder_agent_id)
2933
    `;
2934
    if (!sessions.length) throw new Error('Active live session not found');
2935
    const attestation = attestSessionRecord(this.gatewaySecret(), event);
2936
    const encoded = JSON.stringify({ ...event, attestation });
2937
    const rows = await this.sql`
2938
      INSERT INTO openclasp_live_session_events(
2939
        interaction_id, event_id, agent_id, sequence, event
2940
      ) SELECT ${event.interactionId}, ${event.eventId}, ${event.agentId}, ${event.sequence}, ${encoded}::jsonb
2941
      WHERE (
2942
        SELECT COUNT(*) FROM openclasp_live_session_events
2943
        WHERE interaction_id = ${event.interactionId}
2944
      ) < 1000
2945
      ON CONFLICT DO NOTHING
2946
      RETURNING event_id
2947
    `;
2948
    if (rows.length)
2949
      await this.journalLiveSessionEvent({
2950
        ...event,
2951
        attestation,
2952
      });
2953
    if (event.type === 'session_completed' || event.type === 'session_failed') {
2954
      const terminal = await this.sql`
2955
        SELECT COUNT(DISTINCT agent_id) AS count
2956
        FROM openclasp_live_session_events
2957
        WHERE interaction_id = ${event.interactionId}
2958
          AND event->>'type' IN ('session_completed', 'session_failed')
2959
      `;
2960
      if (Number(terminal[0]?.count ?? 0) >= 2) {
2961
        await this.sql`
2962
          UPDATE openclasp_live_sessions SET status = 'completed', completed_at = NOW()
2963
          WHERE interaction_id = ${event.interactionId} AND status = 'active'
2964
        `;
2965
        const completedAt = new Date().toISOString();
2966
        await this.sql`
2967
          UPDATE openclasp_federated_interactions
2968
          SET status = 'completed',
2969
            payload = jsonb_set(
2970
              jsonb_set(payload, '{status}', '"completed"'::jsonb),
2971
              '{updatedAt}', to_jsonb(${completedAt}::text)
2972
            ),
2973
            updated_at = NOW()
2974
          WHERE interaction_id = ${event.interactionId} AND status = 'active'
2975
        `;
2976
        await Promise.all([
2977
          this.journalLiveSessionState(event.interactionId),
2978
          this.journalFederatedInteraction(event.interactionId),
2979
        ]);
2980
      }
2981
    }
2982
    return {
2983
      recorded: rows.length > 0,
2984
      deduplicated: rows.length === 0,
2985
      eventId: event.eventId,
2986
      attestation,
2987
    };
2988
  }
2989
2990
  async createFederatedInteraction(
2991
    operatorId: string,
2992
    value: FederatedInteraction,
2993
  ): Promise<FederatedInteraction> {
2994
    await this.ensureSchema();
2995
    let interaction = FederatedInteractionSchema.parse(value);
2996
    if (interaction.status !== 'pending') throw new Error('New interactions must be pending');
2997
    if (interaction.contractRevision !== 1 || interaction.contractRevisions.length)
2998
      throw new Error('New interactions cannot supply contract revision history');
2999
    if (interaction.contract.interactionId !== interaction.interactionId)
3000
      throw new Error('Contract interaction ID does not match');
3001
    if (canonicalHash(interaction.contract) !== interaction.termsHash)
3002
      throw new Error('Contract hash does not match the immutable terms');
3003
    if (
3004
      interaction.contract.parties.length !== 2 ||
3005
      interaction.contract.parties[0] !== interaction.initiatorAgentId ||
3006
      interaction.contract.parties[1] !== interaction.responderAgentId
3007
    )
3008
      throw new Error('Contract parties do not match the interaction participants');
3009
    const acceptanceEntries = Object.entries(interaction.acceptances);
3010
    if (
3011
      acceptanceEntries.length !== 1 ||
3012
      acceptanceEntries[0]?.[0] !== interaction.initiatorAgentId ||
3013
      acceptanceEntries[0]?.[1].agentId !== interaction.initiatorAgentId ||
3014
      acceptanceEntries[0]?.[1].termsHash !== interaction.termsHash ||
3015
      !['oauth_installation', 'oauth_account'].includes(acceptanceEntries[0]?.[1].method ?? '')
3016
    )
3017
      throw new Error('A pending interaction requires only the initiator acceptance');
3018
    if (Date.parse(interaction.expiresAt) <= Date.now())
3019
      throw new Error('Interaction expiry must be in the future');
3020
    interaction = withContractRevisionHistory(interaction);
3021
    const owners = await this.sql`
3022
      SELECT agent_id, operator_id, card FROM openclasp_public_agents
3023
      WHERE agent_id IN (${interaction.initiatorAgentId}, ${interaction.responderAgentId})
3024
    `;
3025
    const initiator = owners.find((row) => row.agent_id === interaction.initiatorAgentId);
3026
    const responder = owners.find((row) => row.agent_id === interaction.responderAgentId);
3027
    if (!initiator || initiator.operator_id !== operatorId)
3028
      throw new Error('The initiating agent must be published and owned by this account');
3029
    if (!responder) throw new Error('Responder is not a published OpenClasp agent');
3030
    if (responder.operator_id === operatorId)
3031
      throw new Error('Federated interactions require agents from different accounts');
3032
    const responderCard = normalizePublicAgentCard(responder.card);
3033
    if (
3034
      !responderCard.transports.some(
3035
        (transport) =>
3036
          transport.endpoint === interaction.responderTransport.endpoint &&
3037
          transport.protocolBinding === interaction.responderTransport.protocolBinding,
3038
      )
3039
    )
3040
      throw new Error('Responder transport does not match its published Agent Card');
3041
    const encoded = JSON.stringify(interaction);
3042
    const rows = await this.sql`
3043
      INSERT INTO openclasp_federated_interactions(
3044
        interaction_id, initiator_operator_id, responder_operator_id,
3045
        initiator_agent_id, responder_agent_id, status, payload, expires_at
3046
      ) VALUES (
3047
        ${interaction.interactionId}, ${operatorId}, ${responder.operator_id},
3048
        ${interaction.initiatorAgentId}, ${interaction.responderAgentId},
3049
        ${interaction.status}, ${encoded}::jsonb, ${interaction.expiresAt}
3050
      )
3051
      RETURNING payload
3052
    `;
3053
    const stored = FederatedInteractionSchema.parse(rows[0]?.payload);
3054
    await this.journalFederatedInteraction(stored.interactionId);
3055
    const profiles = await this.sql`
3056
      SELECT payload FROM openclasp_records
3057
      WHERE operator_id = ${responder.operator_id}
3058
        AND kind = 'agent_profile'
3059
        AND record_id = ${interaction.responderAgentId}
3060
      LIMIT 1
3061
    `;
3062
    const responderProfile = profiles[0]?.payload as AgentProfile | undefined;
3063
    if (responderProfile && canAutoAcceptInteraction(responderProfile, stored))
3064
      return this.respondToFederatedInteraction(
3065
        String(responder.operator_id),
3066
        stored.interactionId,
3067
        stored.responderAgentId,
3068
        'accept',
3069
        'policy_auto_accept',
3070
      );
3071
    return stored;
3072
  }
3073
3074
  async listFederatedInteractions(operatorId: string): Promise<FederatedInteraction[]> {
3075
    await this.ensureSchema();
3076
    const expired = await this.sql`
3077
      UPDATE openclasp_federated_interactions
3078
      SET status = 'expired', payload = jsonb_set(payload, '{status}', '"expired"'::jsonb), updated_at = NOW()
3079
      WHERE status = 'pending' AND expires_at <= NOW()
3080
      RETURNING interaction_id
3081
    `;
3082
    await Promise.all(
3083
      expired.map((row) => this.journalFederatedInteraction(String(row.interaction_id))),
3084
    );
3085
    const rows = await this.sql`
3086
      SELECT payload FROM openclasp_federated_interactions
3087
      WHERE initiator_operator_id = ${operatorId} OR responder_operator_id = ${operatorId}
3088
      ORDER BY updated_at DESC
3089
    `;
3090
    return rows.map((row) =>
3091
      withContractRevisionHistory(FederatedInteractionSchema.parse(row.payload)),
3092
    );
3093
  }
3094
3095
  async getFederatedInteraction(
3096
    operatorId: string,
3097
    interactionId: string,
3098
  ): Promise<FederatedInteraction | undefined> {
3099
    const values = await this.listFederatedInteractions(operatorId);
3100
    return values.find((value) => value.interactionId === interactionId);
3101
  }
3102
3103
  async respondToFederatedInteraction(
3104
    operatorId: string,
3105
    interactionId: string,
3106
    agentId: string,
3107
    decision: 'accept' | 'reject',
3108
    method: 'oauth_installation' | 'oauth_account' | 'policy_auto_accept' = 'oauth_account',
3109
  ): Promise<FederatedInteraction> {
3110
    await this.ensureSchema();
3111
    const rows = await this.sql`
3112
      SELECT payload, responder_operator_id, responder_agent_id
3113
      FROM openclasp_federated_interactions WHERE interaction_id = ${interactionId}
3114
    `;
3115
    const row = rows[0];
3116
    if (!row) throw new Error('Interaction invitation not found');
3117
    if (row.responder_operator_id !== operatorId || row.responder_agent_id !== agentId)
3118
      throw new Error('Only the invited agent may respond');
3119
    const current = withContractRevisionHistory(FederatedInteractionSchema.parse(row.payload));
3120
    if (current.status !== 'pending') throw new Error('Invitation is no longer pending');
3121
    const now = new Date().toISOString();
3122
    if (Date.parse(current.expiresAt) <= Date.now()) {
3123
      const expired = { ...current, status: 'expired' as const, updatedAt: now };
3124
      await this.updateFederatedInteraction(
3125
        interactionId,
3126
        current.status,
3127
        expired,
3128
        current.updatedAt,
3129
      );
3130
      throw new Error('Invitation has expired');
3131
    }
3132
    const proposed = openContractRevision(current);
3133
    if (!proposed || proposed.termsHash !== current.termsHash)
3134
      throw new Error('No current contract proposal is available');
3135
    const acceptance = {
3136
      agentId,
3137
      method,
3138
      termsHash: current.termsHash,
3139
      acceptedAt: now,
3140
    };
3141
    const proposalBase: ContractRevision = {
3142
      ...proposed,
3143
      status: decision === 'accept' ? 'accepted' : 'rejected',
3144
      acceptances:
3145
        decision === 'accept'
3146
          ? { ...proposed.acceptances, [agentId]: acceptance }
3147
          : proposed.acceptances,
3148
      updatedAt: now,
3149
    };
3150
    const resolvedProposal =
3151
      decision === 'accept'
3152
        ? ContractRevisionSchema.parse({
3153
            ...proposalBase,
3154
            platformAttestation: attestSessionRecord(this.gatewaySecret(), proposalBase),
3155
          })
3156
        : proposalBase;
3157
    const next = FederatedInteractionSchema.parse({
3158
      ...current,
3159
      status: decision === 'accept' ? 'active' : 'rejected',
3160
      updatedAt: now,
3161
      acceptances: decision === 'accept' ? resolvedProposal.acceptances : current.acceptances,
3162
      contractRevisions: current.contractRevisions.map((revision) =>
3163
        revision.revisionId === resolvedProposal.revisionId ? resolvedProposal : revision,
3164
      ),
3165
    });
3166
    if (decision === 'accept') {
3167
      try {
3168
        await this.brokerLiveSession(next);
3169
      } catch (error) {
3170
        const reason = error instanceof Error ? error.message.slice(0, 500) : 'Live session failed';
3171
        await this.sql`
3172
          UPDATE openclasp_agent_runtimes SET last_error = ${reason}, updated_at = NOW()
3173
          WHERE agent_id IN (${current.initiatorAgentId}, ${current.responderAgentId})
3174
        `;
3175
        throw error;
3176
      }
3177
    }
3178
    const updated = await this.updateFederatedInteraction(
3179
      interactionId,
3180
      current.status,
3181
      next,
3182
      current.updatedAt,
3183
    );
3184
    if (!updated) throw new Error('Invitation was already handled');
3185
    return next;
3186
  }
3187
3188
  async proposeContractRevision(
3189
    operatorId: string,
3190
    interactionId: string,
3191
    agentId: string,
3192
    contract: InteractionContract,
3193
    expectedTermsHash?: string,
3194
    method: 'oauth_installation' | 'oauth_account' = 'oauth_account',
3195
  ): Promise<FederatedInteraction> {
3196
    await this.ensureSchema();
3197
    const rows = await this.sql`
3198
      SELECT payload, initiator_operator_id, responder_operator_id,
3199
        initiator_agent_id, responder_agent_id
3200
      FROM openclasp_federated_interactions
3201
      WHERE interaction_id = ${interactionId}
3202
    `;
3203
    const row = rows[0];
3204
    if (!row) throw new Error('Interaction not found');
3205
    const ownsParticipant =
3206
      (row.initiator_operator_id === operatorId && row.initiator_agent_id === agentId) ||
3207
      (row.responder_operator_id === operatorId && row.responder_agent_id === agentId);
3208
    if (!ownsParticipant) throw new Error('Only a participating agent may propose contract terms');
3209
    const current = withContractRevisionHistory(FederatedInteractionSchema.parse(row.payload));
3210
    if (!['pending', 'active'].includes(current.status))
3211
      throw new Error('This interaction no longer accepts contract proposals');
3212
    const parsedContract = InteractionContractSchema.parse(contract);
3213
    if (parsedContract.interactionId !== interactionId)
3214
      throw new Error('Contract interaction ID does not match');
3215
    if (
3216
      parsedContract.parties.length !== 2 ||
3217
      parsedContract.parties[0] !== current.initiatorAgentId ||
3218
      parsedContract.parties[1] !== current.responderAgentId
3219
    )
3220
      throw new Error('Contract parties cannot change during negotiation');
3221
    const open = openContractRevision(current);
3222
    const expected = open?.termsHash ?? current.termsHash;
3223
    if (expectedTermsHash && expectedTermsHash !== expected)
3224
      throw new Error('Contract terms changed; fetch the interaction before countering');
3225
    const termsHash = canonicalHash(parsedContract);
3226
    if (termsHash === expected) throw new Error('Proposed terms are unchanged');
3227
    const now = new Date().toISOString();
3228
    const revisionNumber =
3229
      Math.max(
3230
        current.contractRevision,
3231
        ...current.contractRevisions.map((item) => item.revision),
3232
      ) + 1;
3233
    const acceptance = {
3234
      agentId,
3235
      method,
3236
      termsHash,
3237
      acceptedAt: now,
3238
    } as const;
3239
    const revision = ContractRevisionSchema.parse({
3240
      revisionId: crypto.randomUUID(),
3241
      interactionId,
3242
      revision: revisionNumber,
3243
      previousTermsHash: expected,
3244
      termsHash,
3245
      contract: parsedContract,
3246
      proposedByAgentId: agentId,
3247
      status: 'proposed',
3248
      acceptances: { [agentId]: acceptance },
3249
      createdAt: now,
3250
      updatedAt: now,
3251
    });
3252
    const revisions = [
3253
      ...current.contractRevisions.map((item) =>
3254
        item.status === 'proposed'
3255
          ? ContractRevisionSchema.parse({ ...item, status: 'superseded', updatedAt: now })
3256
          : item,
3257
      ),
3258
      revision,
3259
    ];
3260
    const next = FederatedInteractionSchema.parse({
3261
      ...current,
3262
      ...(current.status === 'pending'
3263
        ? {
3264
            contract: parsedContract,
3265
            termsHash,
3266
            acceptances: revision.acceptances,
3267
            contractRevision: revisionNumber,
3268
          }
3269
        : {}),
3270
      contractRevisions: revisions,
3271
      updatedAt: now,
3272
    });
3273
    const updated = await this.updateFederatedInteraction(
3274
      interactionId,
3275
      current.status,
3276
      next,
3277
      current.updatedAt,
3278
    );
3279
    if (!updated) throw new Error('Contract changed concurrently; fetch it and retry');
3280
    return next;
3281
  }
3282
3283
  async respondToContractRevision(
3284
    operatorId: string,
3285
    interactionId: string,
3286
    agentId: string,
3287
    revisionId: string,
3288
    decision: 'accept' | 'reject',
3289
    method: 'oauth_installation' | 'oauth_account' = 'oauth_account',
3290
  ): Promise<FederatedInteraction> {
3291
    await this.ensureSchema();
3292
    const rows = await this.sql`
3293
      SELECT payload, initiator_operator_id, responder_operator_id,
3294
        initiator_agent_id, responder_agent_id
3295
      FROM openclasp_federated_interactions
3296
      WHERE interaction_id = ${interactionId}
3297
    `;
3298
    const row = rows[0];
3299
    if (!row) throw new Error('Interaction not found');
3300
    const ownsParticipant =
3301
      (row.initiator_operator_id === operatorId && row.initiator_agent_id === agentId) ||
3302
      (row.responder_operator_id === operatorId && row.responder_agent_id === agentId);
3303
    if (!ownsParticipant) throw new Error('Only a participating agent may respond to terms');
3304
    const current = withContractRevisionHistory(FederatedInteractionSchema.parse(row.payload));
3305
    if (!['pending', 'active'].includes(current.status))
3306
      throw new Error('This interaction no longer accepts contract responses');
3307
    const proposal = openContractRevision(current);
3308
    if (!proposal || proposal.revisionId !== revisionId)
3309
      throw new Error('Contract proposal is no longer current');
3310
    if (proposal.acceptances[agentId]) throw new Error('Agent already accepted this proposal');
3311
    const now = new Date().toISOString();
3312
    const acceptances =
3313
      decision === 'accept'
3314
        ? {
3315
            ...proposal.acceptances,
3316
            [agentId]: { agentId, method, termsHash: proposal.termsHash, acceptedAt: now },
3317
          }
3318
        : proposal.acceptances;
3319
    const fullyAccepted = current.contract.parties.every((party) => party in acceptances);
3320
    const revisionBase = ContractRevisionSchema.parse({
3321
      ...proposal,
3322
      status: decision === 'reject' ? 'rejected' : fullyAccepted ? 'accepted' : 'proposed',
3323
      acceptances,
3324
      updatedAt: now,
3325
    });
3326
    const revision =
3327
      fullyAccepted && decision === 'accept'
3328
        ? ContractRevisionSchema.parse({
3329
            ...revisionBase,
3330
            platformAttestation: attestSessionRecord(this.gatewaySecret(), revisionBase),
3331
          })
3332
        : revisionBase;
3333
    const next = FederatedInteractionSchema.parse({
3334
      ...current,
3335
      status:
3336
        current.status === 'pending'
3337
          ? decision === 'reject'
3338
            ? 'rejected'
3339
            : fullyAccepted
3340
              ? 'active'
3341
              : 'pending'
3342
          : 'active',
3343
      ...(fullyAccepted
3344
        ? {
3345
            contract: revision.contract,
3346
            termsHash: revision.termsHash,
3347
            acceptances: revision.acceptances,
3348
            contractRevision: revision.revision,
3349
          }
3350
        : {}),
3351
      contractRevisions: current.contractRevisions.map((item) =>
3352
        item.revisionId === revision.revisionId ? revision : item,
3353
      ),
3354
      updatedAt: now,
3355
    });
3356
    if (current.status === 'pending' && decision === 'accept' && fullyAccepted)
3357
      await this.brokerLiveSession(next);
3358
    const updated = await this.updateFederatedInteraction(
3359
      interactionId,
3360
      current.status,
3361
      next,
3362
      current.updatedAt,
3363
    );
3364
    if (!updated) throw new Error('Contract changed concurrently; fetch it and retry');
3365
    return next;
3366
  }
3367
3368
  private async updateFederatedInteraction(
3369
    interactionId: string,
3370
    expectedStatus: string,
3371
    value: FederatedInteraction,
3372
    expectedUpdatedAt?: string,
3373
  ): Promise<boolean> {
3374
    const encoded = JSON.stringify(FederatedInteractionSchema.parse(value));
3375
    const rows = await this.sql`
3376
      UPDATE openclasp_federated_interactions
3377
      SET status = ${value.status}, payload = ${encoded}::jsonb, updated_at = NOW()
3378
      WHERE interaction_id = ${interactionId} AND status = ${expectedStatus}
3379
        AND (${expectedUpdatedAt ?? null}::text IS NULL OR payload->>'updatedAt' = ${expectedUpdatedAt ?? null})
3380
      RETURNING interaction_id
3381
    `;
3382
    if (rows.length) await this.journalFederatedInteraction(interactionId);
3383
    return rows.length > 0;
3384
  }
3385
3386
  async getSettings(operatorId: string): Promise<AccountSettings> {
3387
    await this.ensureSchema();
3388
    const rows = await this.sql`
3389
      SELECT display_name, contribution_enabled
3390
      FROM openclasp_account_settings
3391
      WHERE operator_id = ${operatorId}
3392
    `;
3393
    const row = rows[0] as
3394
      | {
3395
          display_name: string;
3396
          contribution_enabled: boolean;
3397
        }
3398
      | undefined;
3399
    return row
3400
      ? {
3401
          displayName: row.display_name,
3402
          contributionEnabled: row.contribution_enabled,
3403
          rawConversationsStored: false,
3404
        }
3405
      : defaults;
3406
  }
3407
3408
  async saveSettings(
3409
    operatorId: string,
3410
    settings: Omit<AccountSettings, 'rawConversationsStored'>,
3411
  ): Promise<AccountSettings> {
3412
    await this.ensureSchema();
3413
    await this.sql`
3414
      INSERT INTO openclasp_account_settings(
3415
        operator_id, display_name, contribution_enabled
3416
      ) VALUES (
3417
        ${operatorId}, ${settings.displayName}, ${settings.contributionEnabled}
3418
      )
3419
      ON CONFLICT (operator_id) DO UPDATE SET
3420
        display_name = EXCLUDED.display_name,
3421
        contribution_enabled = EXCLUDED.contribution_enabled,
3422
        updated_at = NOW()
3423
    `;
3424
    return { ...settings, rawConversationsStored: false };
3425
  }
3426
}