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

Viewing files at commit 1f74f1f0e3abCommit detailsView latest

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