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

Viewing files at commit af23d8c9b675Commit detailsView latest

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