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

Viewing files at commit 18b70a0f11a5Commit detailsView latest

packages/persistence/src/hosted.ts 2981 lines · 115.4 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
  async listFeedbackRequests(operatorId: string, agentId: string) {
1759
    const rows = await this.sql`
1760
      SELECT payload FROM openclasp_records
1761
      WHERE operator_id = ${operatorId}
1762
        AND kind = 'feedback_request'
1763
        AND payload->>'reviewerAgentId' = ${agentId}
1764
      ORDER BY updated_at DESC
1765
    `;
1766
    return rows.map((row) => FeedbackRequestSchema.parse(row.payload));
1767
  }
1768
1769
  async submitCompletionReport(
1770
    operatorId: string,
1771
    agentId: string,
1772
    value: InteractionCompletionReport,
1773
    submissionMethod: 'oauth_installation' | 'agent_access_token' | 'runtime_session',
1774
  ) {
1775
    const participant = await this.interactionParticipant(operatorId, value.interactionId, agentId);
1776
    const report = InteractionCompletionReportSchema.parse(value);
1777
    if (report.platformAttestation)
1778
      throw new Error('Platform attestation is assigned by OpenClasp');
1779
    if (report.reportingAgentId !== agentId)
1780
      throw new Error('Completion report identity does not match the authenticated agent');
1781
    if (report.counterpartyAgentId !== participant.counterpartyAgentId)
1782
      throw new Error('Completion report counterparty does not match the interaction');
1783
    if (report.contractHash !== participant.interaction.termsHash)
1784
      throw new Error('Completion report contract hash does not match the interaction');
1785
    if (report.requestedOutcome !== participant.interaction.contract.requestedOutcome)
1786
      throw new Error('Completion report requested outcome does not match the contract');
1787
    if (
1788
      report.criteria.some(
1789
        (criterion) =>
1790
          !participant.interaction.contract.successCriteria.includes(criterion.criterion),
1791
      )
1792
    )
1793
      throw new Error('Completion report contains a criterion outside the contract');
1794
    if (!['active', 'completed'].includes(participant.interaction.status))
1795
      throw new Error('Only active or completed interactions accept completion reports');
1796
    const profiles = await this.sql`
1797
      SELECT payload FROM openclasp_records
1798
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
1799
      LIMIT 1
1800
    `;
1801
    const profile = profiles[0]?.payload as Partial<AgentProfile> | undefined;
1802
    if (!profile || profile.agentVersion !== report.agentVersion)
1803
      throw new Error('Completion report agent version does not match the registered agent');
1804
    let verifiedSubmissionMethod: InteractionCompletionReport['submissionMethod'] =
1805
      submissionMethod;
1806
    if (report.signature) {
1807
      if (report.submissionMethod !== 'agent_signature')
1808
        throw new Error('Signed completion reports must declare agent_signature submission');
1809
      const identities = await this.sql`
1810
        SELECT payload FROM openclasp_records
1811
        WHERE operator_id = ${operatorId} AND kind = 'agent' AND record_id = ${agentId}
1812
        LIMIT 1
1813
      `;
1814
      const identity = AgentIdentitySchema.safeParse(identities[0]?.payload);
1815
      if (
1816
        !identity.success ||
1817
        !verifyObject(report as unknown as Record<string, unknown>, identity.data.publicKey)
1818
      )
1819
        throw new Error('Completion report agent signature is invalid or unverifiable');
1820
      verifiedSubmissionMethod = 'agent_signature';
1821
    }
1822
    const unsigned = InteractionCompletionReportSchema.parse({
1823
      ...report,
1824
      submissionMethod: verifiedSubmissionMethod,
1825
    });
1826
    const stored = InteractionCompletionReportSchema.parse({
1827
      ...unsigned,
1828
      platformAttestation: attestSessionRecord(this.gatewaySecret(), unsigned),
1829
    });
1830
    const existing = await this.sql`
1831
      SELECT payload FROM openclasp_records
1832
      WHERE kind = 'completion_report' AND record_id = ${stored.reportId}
1833
        AND payload->>'interactionId' = ${stored.interactionId}
1834
      LIMIT 1
1835
    `;
1836
    if (existing[0] && canonicalHash(existing[0].payload) !== canonicalHash(stored))
1837
      throw new Error('Conflicting completion report ID');
1838
    await Promise.all([
1839
      this.upsert(participant.participantOperatorId, 'completion_report', stored.reportId, stored),
1840
      this.upsert(participant.counterpartyOperatorId, 'completion_report', stored.reportId, stored),
1841
    ]);
1842
    const [feedbackRequest] = await Promise.all([
1843
      this.ensureFeedbackRequest(
1844
        participant.participantOperatorId,
1845
        stored.interactionId,
1846
        stored.reportingAgentId,
1847
        stored.counterpartyAgentId,
1848
      ),
1849
      this.ensureFeedbackRequest(
1850
        participant.counterpartyOperatorId,
1851
        stored.interactionId,
1852
        stored.counterpartyAgentId,
1853
        stored.reportingAgentId,
1854
      ),
1855
    ]);
1856
    const completed = await this.sql`
1857
      SELECT COUNT(DISTINCT payload->>'reportingAgentId') AS count
1858
      FROM openclasp_records
1859
      WHERE kind = 'completion_report'
1860
        AND payload->>'interactionId' = ${stored.interactionId}
1861
    `;
1862
    if (Number(completed[0]?.count ?? 0) >= 2) {
1863
      const completedAt = new Date().toISOString();
1864
      await Promise.all([
1865
        this.sql`
1866
          UPDATE openclasp_live_sessions SET status = 'completed', completed_at = NOW()
1867
          WHERE interaction_id = ${stored.interactionId} AND status = 'active'
1868
        `,
1869
        this.sql`
1870
          UPDATE openclasp_federated_interactions
1871
          SET status = 'completed',
1872
            payload = jsonb_set(
1873
              jsonb_set(payload, '{status}', '"completed"'::jsonb),
1874
              '{updatedAt}', to_jsonb(${completedAt}::text)
1875
            ),
1876
            updated_at = NOW()
1877
          WHERE interaction_id = ${stored.interactionId} AND status = 'active'
1878
        `,
1879
      ]);
1880
    }
1881
    return { report: stored, feedbackRequest };
1882
  }
1883
1884
  async recordSessionCompletionReport(token: string, value: InteractionCompletionReport) {
1885
    const report = InteractionCompletionReportSchema.parse(value);
1886
    const grant = verifySessionGrant(this.gatewaySecret(), token);
1887
    if (
1888
      grant.interactionId !== report.interactionId ||
1889
      grant.senderAgentId !== report.reportingAgentId ||
1890
      grant.recipientAgentId !== report.counterpartyAgentId
1891
    )
1892
      throw new Error('Session credential does not match the completion report');
1893
    const owners = await this.sql`
1894
      SELECT initiator_operator_id, responder_operator_id,
1895
        initiator_agent_id, responder_agent_id
1896
      FROM openclasp_federated_interactions
1897
      WHERE interaction_id = ${report.interactionId}
1898
      LIMIT 1
1899
    `;
1900
    const row = owners[0];
1901
    if (!row) throw new Error('Interaction not found');
1902
    const operatorId = String(
1903
      row.initiator_agent_id === report.reportingAgentId
1904
        ? row.initiator_operator_id
1905
        : row.responder_operator_id,
1906
    );
1907
    return this.submitCompletionReport(
1908
      operatorId,
1909
      report.reportingAgentId,
1910
      report,
1911
      'runtime_session',
1912
    );
1913
  }
1914
1915
  private async applyInteractionLearning(input: {
1916
    interaction: FederatedInteraction;
1917
    initiatorOperatorId: string;
1918
    responderOperatorId: string;
1919
    reports: InteractionCompletionReport[];
1920
    feedback: InteractionFeedback[];
1921
    conclusion: InteractionConclusion;
1922
  }) {
1923
    const { interaction, reports, feedback, conclusion } = input;
1924
    const [initiatorSettings, responderSettings, cards] = await Promise.all([
1925
      this.getSettings(input.initiatorOperatorId),
1926
      this.getSettings(input.responderOperatorId),
1927
      this.sql`
1928
        SELECT agent_id, card FROM openclasp_public_agents
1929
        WHERE agent_id IN (${interaction.initiatorAgentId}, ${interaction.responderAgentId})
1930
      `,
1931
    ]);
1932
    const contributionMode =
1933
      initiatorSettings.contributionEnabled && responderSettings.contributionEnabled
1934
        ? ('network_aggregate' as const)
1935
        : ('local_only' as const);
1936
    const eligibilityBase = evaluateLearningEligibility({
1937
      interactionId: interaction.interactionId,
1938
      reports,
1939
      feedback,
1940
      consensus: conclusion.consensus,
1941
      contributionMode,
1942
      reviewerCredibility: Object.fromEntries(
1943
        feedback.map((item) => [item.reviewerAgentId, reviewerCredibility(item)]),
1944
      ),
1945
      decisionId: deterministicUuid(`learning-eligibility:${interaction.interactionId}`),
1946
      decidedAt: conclusion.generatedAt,
1947
    });
1948
    const eligibility = LearningEligibilityDecisionSchema.parse({
1949
      ...eligibilityBase,
1950
      platformAttestation: attestSessionRecord(this.gatewaySecret(), eligibilityBase),
1951
    });
1952
    await Promise.all([
1953
      this.upsert(
1954
        input.initiatorOperatorId,
1955
        'learning_eligibility',
1956
        eligibility.decisionId,
1957
        eligibility,
1958
      ),
1959
      this.upsert(
1960
        input.responderOperatorId,
1961
        'learning_eligibility',
1962
        eligibility.decisionId,
1963
        eligibility,
1964
      ),
1965
    ]);
1966
    if (!eligibility.eligible || eligibility.sampleWeight <= 0)
1967
      return { eligibility, profileDeltas: [] as BehaviouralProfileDelta[] };
1968
1969
    const appliedAt = new Date().toISOString();
1970
    const versions = new Map<string, string>();
1971
    for (const report of reports) versions.set(report.reportingAgentId, report.agentVersion);
1972
    for (const row of cards) {
1973
      const card = PublicAgentCardSchema.safeParse(row.card);
1974
      if (card.success && !versions.has(String(row.agent_id)))
1975
        versions.set(String(row.agent_id), card.data.agentVersion);
1976
    }
1977
    const participants = [
1978
      {
1979
        viewerOperatorId: input.initiatorOperatorId,
1980
        reviewerAgentId: interaction.initiatorAgentId,
1981
        subjectAgentId: interaction.responderAgentId,
1982
      },
1983
      {
1984
        viewerOperatorId: input.responderOperatorId,
1985
        reviewerAgentId: interaction.responderAgentId,
1986
        subjectAgentId: interaction.initiatorAgentId,
1987
      },
1988
    ];
1989
    const profileDeltas = await Promise.all(
1990
      participants.map(async (participant) => {
1991
        const agentVersion = versions.get(participant.subjectAgentId);
1992
        if (!agentVersion) return undefined;
1993
        const deltaId = deterministicUuid(
1994
          `profile-delta:${interaction.interactionId}:${participant.viewerOperatorId}:${participant.subjectAgentId}`,
1995
        );
1996
        const existingDelta = await this.sql`
1997
          SELECT payload FROM openclasp_records
1998
          WHERE operator_id = ${participant.viewerOperatorId}
1999
            AND kind = 'profile_delta' AND record_id = ${deltaId}
2000
          LIMIT 1
2001
        `;
2002
        const parsedDelta = BehaviouralProfileDeltaSchema.safeParse(existingDelta[0]?.payload);
2003
        if (parsedDelta.success) return parsedDelta.data;
2004
        const profileId = deterministicUuid(
2005
          `contextual-profile:${participant.subjectAgentId}:${agentVersion}:${interaction.contract.taskCategory}`,
2006
        );
2007
        const currentRows = await this.sql`
2008
          SELECT payload FROM openclasp_records
2009
          WHERE operator_id = ${participant.viewerOperatorId}
2010
            AND kind = 'profile' AND record_id = ${profileId}
2011
          LIMIT 1
2012
        `;
2013
        const current = currentRows[0]?.payload as Partial<ContextualProfile> | undefined;
2014
        const reviewerFeedback = feedback.find(
2015
          (item) =>
2016
            item.reviewerAgentId === participant.reviewerAgentId &&
2017
            item.subjectAgentId === participant.subjectAgentId,
2018
        );
2019
        const observations = deriveBehaviouralObservations({
2020
          subjectAgentId: participant.subjectAgentId,
2021
          reports,
2022
          ...(reviewerFeedback ? { reviewerFeedback } : {}),
2023
          conclusion,
2024
        });
2025
        const applied = updateContextualBehaviouralProfile({
2026
          ...(current ? { current } : {}),
2027
          observations,
2028
          sampleWeight: eligibility.sampleWeight,
2029
          appliedAt,
2030
        });
2031
        const profile: ContextualProfile = {
2032
          recordId: profileId,
2033
          agentId: participant.subjectAgentId,
2034
          agentVersion,
2035
          taskCategory: interaction.contract.taskCategory,
2036
          ...applied.profile,
2037
        };
2038
        const deltaBase = BehaviouralProfileDeltaSchema.parse({
2039
          deltaId,
2040
          interactionId: interaction.interactionId,
2041
          agentId: participant.subjectAgentId,
2042
          agentVersion,
2043
          taskCategory: interaction.contract.taskCategory,
2044
          sampleWeight: eligibility.sampleWeight,
2045
          dimensionDeltas: applied.dimensionDeltas,
2046
          explanation:
2047
            'Applied platform-attested structured outcome and eligible reviewer signals; private comments and raw conversation content were excluded.',
2048
          appliedAt,
2049
        });
2050
        const delta = BehaviouralProfileDeltaSchema.parse({
2051
          ...deltaBase,
2052
          platformAttestation: attestSessionRecord(this.gatewaySecret(), deltaBase),
2053
        });
2054
        await Promise.all([
2055
          this.upsert(participant.viewerOperatorId, 'profile', profileId, profile),
2056
          this.upsert(participant.viewerOperatorId, 'profile_delta', deltaId, delta),
2057
        ]);
2058
        return delta;
2059
      }),
2060
    );
2061
    return {
2062
      eligibility,
2063
      profileDeltas: profileDeltas.filter(
2064
        (delta): delta is BehaviouralProfileDelta => delta !== undefined,
2065
      ),
2066
    };
2067
  }
2068
2069
  private async finalizeInteractionConclusion(interactionId: string) {
2070
    const [interactionRows, reportRows, feedbackRows, requestRows, existingRows] =
2071
      await Promise.all([
2072
        this.sql`
2073
        SELECT payload, initiator_operator_id, responder_operator_id
2074
        FROM openclasp_federated_interactions
2075
        WHERE interaction_id = ${interactionId}
2076
        LIMIT 1
2077
      `,
2078
        this.sql`
2079
        SELECT record_id, payload FROM openclasp_records
2080
        WHERE kind = 'completion_report' AND payload->>'interactionId' = ${interactionId}
2081
      `,
2082
        this.sql`
2083
        SELECT record_id, payload FROM openclasp_records
2084
        WHERE kind = 'interaction_feedback' AND payload->>'interactionId' = ${interactionId}
2085
      `,
2086
        this.sql`
2087
        SELECT record_id, payload FROM openclasp_records
2088
        WHERE kind = 'feedback_request' AND payload->>'interactionId' = ${interactionId}
2089
      `,
2090
        this.sql`
2091
        SELECT payload FROM openclasp_records
2092
        WHERE kind = 'interaction_conclusion' AND payload->>'interactionId' = ${interactionId}
2093
        LIMIT 1
2094
      `,
2095
      ]);
2096
    const interactionRow = interactionRows[0];
2097
    if (!interactionRow) throw new Error('Interaction not found');
2098
    const interaction = FederatedInteractionSchema.parse(interactionRow.payload);
2099
    const reports = [
2100
      ...new Map(
2101
        reportRows
2102
          .map((row) => InteractionCompletionReportSchema.parse(row.payload))
2103
          .map((report) => [report.reportId, report]),
2104
      ).values(),
2105
    ];
2106
    const feedback = [
2107
      ...new Map(
2108
        feedbackRows
2109
          .map((row) => InteractionFeedbackSchema.parse(row.payload))
2110
          .map((item) => [item.feedbackId, item]),
2111
      ).values(),
2112
    ];
2113
    const requests = [
2114
      ...new Map(
2115
        requestRows
2116
          .map((row) => FeedbackRequestSchema.parse(row.payload))
2117
          .map((request) => [request.requestId, request]),
2118
      ).values(),
2119
    ];
2120
    const existing = InteractionConclusionSchema.safeParse(existingRows[0]?.payload);
2121
    if (existing.success) {
2122
      await this.applyInteractionLearning({
2123
        interaction,
2124
        initiatorOperatorId: String(interactionRow.initiator_operator_id),
2125
        responderOperatorId: String(interactionRow.responder_operator_id),
2126
        reports,
2127
        feedback,
2128
        conclusion: existing.data,
2129
      });
2130
      return { released: true, conclusion: existing.data };
2131
    }
2132
    if (requests.length < 2 || requests.some((request) => request.status === 'pending'))
2133
      return { released: false as const };
2134
    const base = buildInteractionConclusion({
2135
      interaction,
2136
      reports,
2137
      feedback,
2138
      conclusionId: deterministicUuid(`interaction-conclusion:${interactionId}`),
2139
    });
2140
    const conclusion = InteractionConclusionSchema.parse({
2141
      ...base,
2142
      platformAttestation: attestSessionRecord(this.gatewaySecret(), base),
2143
    });
2144
    const startedAt = reports
2145
      .map((report) => report.startedAt)
2146
      .filter((value): value is string => typeof value === 'string')
2147
      .sort()[0];
2148
    const receiptBase = {
2149
      receiptId: deterministicUuid(`interaction-receipt:${interactionId}`),
2150
      interactionId,
2151
      participants: [interaction.initiatorAgentId, interaction.responderAgentId],
2152
      agentVersions: Object.fromEntries(
2153
        reports.map((report) => [report.reportingAgentId, report.agentVersion]),
2154
      ),
2155
      contractHash: interaction.termsHash,
2156
      startedAt: startedAt ?? interaction.createdAt,
2157
      completedAt:
2158
        reports
2159
          .map((report) => report.completedAt)
2160
          .sort()
2161
          .at(-1) ?? conclusion.generatedAt,
2162
      outcome: conclusion.outcome,
2163
      commitmentsFulfilled: conclusion.criteria
2164
        .filter((criterion) => criterion.status === 'met')
2165
        .map((criterion) => criterion.criterion),
2166
      commitmentsMissed: conclusion.criteria
2167
        .filter((criterion) => criterion.status !== 'met')
2168
        .map((criterion) => criterion.criterion),
2169
      evidenceHashes: conclusion.evidenceReferences.map((reference) => canonicalHash(reference)),
2170
      policyWarnings: reports.flatMap((report) => report.blockers),
2171
      policyViolations: [],
2172
      disputeStatus: conclusion.consensus === 'conflicting' ? ('open' as const) : ('none' as const),
2173
      delegationChainHash: canonicalHash(interaction.contract.delegationRules),
2174
      unilateral: reports.length < 2,
2175
      signatures: {},
2176
      completionReportIds: conclusion.reportIds,
2177
      conclusionId: conclusion.conclusionId,
2178
    };
2179
    const receipt = ReceiptSchema.parse({
2180
      ...receiptBase,
2181
      platformAttestation: attestSessionRecord(this.gatewaySecret(), receiptBase),
2182
    });
2183
    await Promise.all([
2184
      this.upsert(
2185
        String(interactionRow.initiator_operator_id),
2186
        'interaction_conclusion',
2187
        conclusion.conclusionId,
2188
        conclusion,
2189
      ),
2190
      this.upsert(
2191
        String(interactionRow.responder_operator_id),
2192
        'interaction_conclusion',
2193
        conclusion.conclusionId,
2194
        conclusion,
2195
      ),
2196
      this.upsert(
2197
        String(interactionRow.initiator_operator_id),
2198
        'receipt',
2199
        receipt.receiptId,
2200
        receipt,
2201
      ),
2202
      this.upsert(
2203
        String(interactionRow.responder_operator_id),
2204
        'receipt',
2205
        receipt.receiptId,
2206
        receipt,
2207
      ),
2208
    ]);
2209
    const learning = await this.applyInteractionLearning({
2210
      interaction,
2211
      initiatorOperatorId: String(interactionRow.initiator_operator_id),
2212
      responderOperatorId: String(interactionRow.responder_operator_id),
2213
      reports,
2214
      feedback,
2215
      conclusion,
2216
    });
2217
    return { released: true as const, conclusion, receipt, learning };
2218
  }
2219
2220
  async submitInteractionFeedback(
2221
    operatorId: string,
2222
    agentId: string,
2223
    value: InteractionFeedback,
2224
    submissionMethod: 'oauth_installation' | 'agent_access_token' | 'runtime_session',
2225
  ) {
2226
    const feedback = InteractionFeedbackSchema.parse(value);
2227
    if (feedback.platformAttestation)
2228
      throw new Error('Platform attestation is assigned by OpenClasp');
2229
    if (feedback.reviewerAgentId !== agentId)
2230
      throw new Error('Feedback reviewer does not match the authenticated agent');
2231
    const participant = await this.interactionParticipant(
2232
      operatorId,
2233
      feedback.interactionId,
2234
      agentId,
2235
    );
2236
    if (feedback.subjectAgentId !== participant.counterpartyAgentId)
2237
      throw new Error('Feedback subject does not match the interaction counterparty');
2238
    const requestRows = await this.sql`
2239
      SELECT payload FROM openclasp_records
2240
      WHERE operator_id = ${operatorId}
2241
        AND kind = 'feedback_request'
2242
        AND record_id = ${feedback.requestId}
2243
      LIMIT 1
2244
    `;
2245
    const request = FeedbackRequestSchema.parse(requestRows[0]?.payload);
2246
    if (
2247
      request.interactionId !== feedback.interactionId ||
2248
      request.reviewerAgentId !== feedback.reviewerAgentId ||
2249
      request.subjectAgentId !== feedback.subjectAgentId
2250
    )
2251
      throw new Error('Feedback does not match its request');
2252
    if (request.status !== 'pending') throw new Error('Feedback request is no longer pending');
2253
    if (Date.parse(request.dueAt) <= Date.now()) throw new Error('Feedback request has expired');
2254
    if (
2255
      request.requestedDimensions.some(
2256
        (dimension) => typeof feedback.ratings[dimension] !== 'number',
2257
      )
2258
    )
2259
      throw new Error('Feedback must rate every requested dimension');
2260
    const profiles = await this.sql`
2261
      SELECT payload FROM openclasp_records
2262
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
2263
      LIMIT 1
2264
    `;
2265
    const profile = profiles[0]?.payload as Partial<AgentProfile> | undefined;
2266
    if (!profile || profile.agentVersion !== feedback.reviewerAgentVersion)
2267
      throw new Error('Feedback reviewer version does not match the registered agent');
2268
    let verifiedSubmissionMethod: InteractionFeedback['submissionMethod'] = submissionMethod;
2269
    if (feedback.signature) {
2270
      if (feedback.submissionMethod !== 'agent_signature')
2271
        throw new Error('Signed feedback must declare agent_signature submission');
2272
      const identities = await this.sql`
2273
        SELECT payload FROM openclasp_records
2274
        WHERE operator_id = ${operatorId} AND kind = 'agent' AND record_id = ${agentId}
2275
        LIMIT 1
2276
      `;
2277
      const identity = AgentIdentitySchema.safeParse(identities[0]?.payload);
2278
      if (
2279
        !identity.success ||
2280
        !verifyObject(feedback as unknown as Record<string, unknown>, identity.data.publicKey)
2281
      )
2282
        throw new Error('Feedback agent signature is invalid or unverifiable');
2283
      verifiedSubmissionMethod = 'agent_signature';
2284
    }
2285
    const unattested = InteractionFeedbackSchema.parse({
2286
      ...feedback,
2287
      submissionMethod: verifiedSubmissionMethod,
2288
    });
2289
    const stored = InteractionFeedbackSchema.parse({
2290
      ...unattested,
2291
      platformAttestation: attestSessionRecord(this.gatewaySecret(), unattested),
2292
    });
2293
    const existing = await this.sql`
2294
      SELECT payload FROM openclasp_records
2295
      WHERE operator_id = ${operatorId}
2296
        AND kind = 'interaction_feedback'
2297
        AND record_id = ${stored.feedbackId}
2298
      LIMIT 1
2299
    `;
2300
    if (existing[0] && canonicalHash(existing[0].payload) !== canonicalHash(stored))
2301
      throw new Error('Conflicting feedback ID');
2302
    await this.upsert(operatorId, 'interaction_feedback', stored.feedbackId, stored);
2303
    const requestBase = { ...request, status: 'submitted' as const };
2304
    delete requestBase.platformAttestation;
2305
    const updatedRequest = FeedbackRequestSchema.parse({
2306
      ...requestBase,
2307
      platformAttestation: attestSessionRecord(this.gatewaySecret(), requestBase),
2308
    });
2309
    await this.upsert(operatorId, 'feedback_request', request.requestId, updatedRequest);
2310
    const release = await this.finalizeInteractionConclusion(feedback.interactionId);
2311
    return {
2312
      feedbackId: stored.feedbackId,
2313
      status: 'submitted' as const,
2314
      revealed: release.released,
2315
      ...(release.released ? { conclusion: release.conclusion } : {}),
2316
    };
2317
  }
2318
2319
  async processDueFeedback(now = new Date()) {
2320
    await this.ensureSchema();
2321
    const [rows, backfillRows] = await Promise.all([
2322
      this.sql`
2323
        SELECT operator_id, record_id, payload FROM openclasp_records
2324
        WHERE kind = 'feedback_request'
2325
          AND payload->>'status' = 'pending'
2326
          AND (payload->>'dueAt')::timestamptz <= ${now.toISOString()}
2327
        LIMIT 500
2328
      `,
2329
      this.sql`
2330
        SELECT conclusion.payload->>'interactionId' AS interaction_id
2331
        FROM openclasp_records conclusion
2332
        WHERE conclusion.kind = 'interaction_conclusion'
2333
          AND NOT EXISTS (
2334
            SELECT 1 FROM openclasp_records eligibility
2335
            WHERE eligibility.operator_id = conclusion.operator_id
2336
              AND eligibility.kind = 'learning_eligibility'
2337
              AND eligibility.payload->>'interactionId' = conclusion.payload->>'interactionId'
2338
          )
2339
        GROUP BY conclusion.payload->>'interactionId'
2340
        ORDER BY MIN(conclusion.created_at) ASC
2341
        LIMIT 200
2342
      `,
2343
    ]);
2344
    const interactions = new Set<string>();
2345
    for (const row of rows) {
2346
      const request = FeedbackRequestSchema.parse(row.payload);
2347
      const base = { ...request, status: 'expired' as const };
2348
      delete base.platformAttestation;
2349
      const expired = FeedbackRequestSchema.parse({
2350
        ...base,
2351
        platformAttestation: attestSessionRecord(this.gatewaySecret(), base),
2352
      });
2353
      await this.upsert(
2354
        String(row.operator_id),
2355
        'feedback_request',
2356
        String(row.record_id),
2357
        expired,
2358
      );
2359
      interactions.add(request.interactionId);
2360
    }
2361
    for (const row of backfillRows) interactions.add(String(row.interaction_id));
2362
    let released = 0;
2363
    for (const interactionId of interactions) {
2364
      const result = await this.finalizeInteractionConclusion(interactionId);
2365
      if (result.released) released += 1;
2366
    }
2367
    return { expired: rows.length, released, backfilled: backfillRows.length };
2368
  }
2369
2370
  async recordSessionFeedback(token: string, value: InteractionFeedback) {
2371
    const feedback = InteractionFeedbackSchema.parse(value);
2372
    const grant = verifySessionGrant(this.gatewaySecret(), token);
2373
    if (
2374
      grant.interactionId !== feedback.interactionId ||
2375
      grant.senderAgentId !== feedback.reviewerAgentId ||
2376
      grant.recipientAgentId !== feedback.subjectAgentId
2377
    )
2378
      throw new Error('Session credential does not match the feedback');
2379
    const owners = await this.sql`
2380
      SELECT initiator_operator_id, responder_operator_id,
2381
        initiator_agent_id, responder_agent_id
2382
      FROM openclasp_federated_interactions
2383
      WHERE interaction_id = ${feedback.interactionId}
2384
      LIMIT 1
2385
    `;
2386
    const row = owners[0];
2387
    if (!row) throw new Error('Interaction not found');
2388
    const operatorId = String(
2389
      row.initiator_agent_id === feedback.reviewerAgentId
2390
        ? row.initiator_operator_id
2391
        : row.responder_operator_id,
2392
    );
2393
    return this.submitInteractionFeedback(
2394
      operatorId,
2395
      feedback.reviewerAgentId,
2396
      feedback,
2397
      'runtime_session',
2398
    );
2399
  }
2400
2401
  private async ownedTemporaryAgent(operatorId: string, agentId: string) {
2402
    const rows = await this.sql`
2403
      SELECT payload FROM openclasp_records
2404
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
2405
      LIMIT 1
2406
    `;
2407
    const profile = rows[0]?.payload as Partial<AgentProfile> | undefined;
2408
    if (!profile) throw new Error('Agent is not owned by this account');
2409
    const mode =
2410
      profile.agentMode ?? (profile.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat');
2411
    if (mode !== 'temporary_chat')
2412
      throw new Error('Hosted conversations are available only to temporary chat agents');
2413
    return profile;
2414
  }
2415
2416
  private hostedMessage(row: Record<string, any>): HostedMessage {
2417
    const decrypted = decryptGatewayPayload(this.gatewaySecret(), {
2418
      ciphertext: String(row.content_ciphertext),
2419
      iv: String(row.content_iv),
2420
      authTag: String(row.content_auth_tag),
2421
    }) as { content?: unknown };
2422
    return HostedMessageSchema.parse({
2423
      messageId: String(row.message_id),
2424
      threadId: String(row.thread_id),
2425
      interactionId: String(row.interaction_id),
2426
      senderAgentId: String(row.sender_agent_id),
2427
      recipientAgentId: String(row.recipient_agent_id),
2428
      contentType: 'text/plain',
2429
      content: decrypted.content,
2430
      contentHash: String(row.content_hash),
2431
      delivery: row.delivery,
2432
      createdAt: new Date(String(row.created_at)).toISOString(),
2433
      ...(row.read_at ? { readAt: new Date(String(row.read_at)).toISOString() } : {}),
2434
    });
2435
  }
2436
2437
  private async storeHostedMessage(input: {
2438
    interactionId: string;
2439
    senderAgentId: string;
2440
    recipientAgentId: string;
2441
    requestKey: string;
2442
    content: string;
2443
    delivery: 'accepted' | 'delivered';
2444
  }) {
2445
    const content = input.content.trim();
2446
    if (!content || content.length > 20_000)
2447
      throw new Error('Message must contain 1-20000 characters');
2448
    const sessions = await this.sql`
2449
      SELECT session.status, interaction.status AS interaction_status,
2450
        session.initiator_agent_id, session.responder_agent_id
2451
      FROM openclasp_live_sessions session
2452
      INNER JOIN openclasp_federated_interactions interaction
2453
        ON interaction.interaction_id = session.interaction_id
2454
      WHERE session.interaction_id = ${input.interactionId}
2455
      LIMIT 1
2456
    `;
2457
    const session = sessions[0];
2458
    if (!session || session.status !== 'active' || session.interaction_status !== 'active')
2459
      throw new Error('Active interaction not found');
2460
    const participants = new Set([
2461
      String(session.initiator_agent_id),
2462
      String(session.responder_agent_id),
2463
    ]);
2464
    if (!participants.has(input.senderAgentId) || !participants.has(input.recipientAgentId))
2465
      throw new Error('Message participants do not match the active interaction');
2466
    const now = new Date();
2467
    const threadId = input.interactionId;
2468
    const expiresAt = new Date(now.getTime() + 30 * 86_400_000).toISOString();
2469
    await this.sql`
2470
      INSERT INTO openclasp_hosted_threads(
2471
        thread_id, interaction_id, participant_a_agent_id, participant_b_agent_id,
2472
        status, expires_at
2473
      ) VALUES (
2474
        ${threadId}, ${input.interactionId}, ${String(session.initiator_agent_id)},
2475
        ${String(session.responder_agent_id)}, 'open', ${expiresAt}
2476
      )
2477
      ON CONFLICT (thread_id) DO UPDATE SET updated_at = NOW()
2478
      WHERE openclasp_hosted_threads.status = 'open'
2479
    `;
2480
    const thread = await this.sql`
2481
      SELECT status, expires_at FROM openclasp_hosted_threads WHERE thread_id = ${threadId}
2482
    `;
2483
    if (thread[0]?.status !== 'open') throw new Error('Hosted thread is closed');
2484
    if (Date.parse(String(thread[0].expires_at)) <= Date.now())
2485
      throw new Error('Hosted thread has expired');
2486
    const encrypted = encryptGatewayPayload(this.gatewaySecret(), { content });
2487
    const messageId = crypto.randomUUID();
2488
    const contentHash = canonicalHash(content);
2489
    const inserted = await this.sql`
2490
      INSERT INTO openclasp_hosted_messages(
2491
        message_id, thread_id, interaction_id, sender_agent_id, recipient_agent_id,
2492
        request_key, content_ciphertext, content_iv, content_auth_tag, content_hash, delivery
2493
      ) VALUES (
2494
        ${messageId}, ${threadId}, ${input.interactionId}, ${input.senderAgentId},
2495
        ${input.recipientAgentId}, ${input.requestKey}, ${encrypted.ciphertext}, ${encrypted.iv},
2496
        ${encrypted.authTag}, ${contentHash}, ${input.delivery}
2497
      )
2498
      ON CONFLICT (thread_id, sender_agent_id, request_key) DO NOTHING
2499
      RETURNING *
2500
    `;
2501
    if (!inserted[0]) {
2502
      const existing = await this.sql`
2503
        SELECT * FROM openclasp_hosted_messages
2504
        WHERE thread_id = ${threadId} AND sender_agent_id = ${input.senderAgentId}
2505
          AND request_key = ${input.requestKey}
2506
        LIMIT 1
2507
      `;
2508
      return { message: this.hostedMessage(existing[0]!), deduplicated: true };
2509
    }
2510
    const sequenceRows = await this.sql`
2511
      SELECT COALESCE(MAX(sequence), -1) + 1 AS sequence
2512
      FROM openclasp_live_session_events
2513
      WHERE interaction_id = ${input.interactionId} AND agent_id = ${input.senderAgentId}
2514
    `;
2515
    const event = LiveSessionEventSchema.parse({
2516
      eventId: crypto.randomUUID(),
2517
      interactionId: input.interactionId,
2518
      agentId: input.senderAgentId,
2519
      sequence: Number(sequenceRows[0]?.sequence ?? 0),
2520
      type: 'message_sent',
2521
      occurredAt: now.toISOString(),
2522
      messageHash: contentHash,
2523
      evidenceReferences: [],
2524
      details: { labels: ['hosted_temporary'], metrics: {}, flags: { rawStoredEncrypted: true } },
2525
    });
2526
    const eventWithAttestation = JSON.stringify({
2527
      ...event,
2528
      attestation: attestSessionRecord(this.gatewaySecret(), event),
2529
    });
2530
    await this.sql`
2531
      INSERT INTO openclasp_live_session_events(interaction_id, event_id, agent_id, sequence, event)
2532
      VALUES (${event.interactionId}, ${event.eventId}, ${event.agentId}, ${event.sequence}, ${eventWithAttestation}::jsonb)
2533
      ON CONFLICT DO NOTHING
2534
    `;
2535
    return { message: this.hostedMessage(inserted[0]), deduplicated: false };
2536
  }
2537
2538
  async receiveTemporaryMessage(
2539
    token: string,
2540
    recipientAgentId: string,
2541
    requestKey: string,
2542
    content: string,
2543
  ) {
2544
    await this.ensureSchema();
2545
    const grant = verifySessionGrant(this.gatewaySecret(), token);
2546
    if (grant.recipientAgentId !== recipientAgentId)
2547
      throw new Error('Session credential is not valid for this temporary agent');
2548
    const recipient = await this.sessionParticipant(recipientAgentId);
2549
    if (recipient.mode !== 'temporary_chat')
2550
      throw new Error('Target is not a temporary chat agent');
2551
    const stored = await this.storeHostedMessage({
2552
      interactionId: grant.interactionId,
2553
      senderAgentId: grant.senderAgentId,
2554
      recipientAgentId,
2555
      requestKey,
2556
      content,
2557
      delivery: 'delivered',
2558
    });
2559
    return stored;
2560
  }
2561
2562
  async sendTemporaryMessage(
2563
    operatorId: string,
2564
    senderAgentId: string,
2565
    interactionId: string,
2566
    content: string,
2567
  ) {
2568
    await this.ensureSchema();
2569
    await this.ownedTemporaryAgent(operatorId, senderAgentId);
2570
    const session = await this.getLiveSession(operatorId, interactionId, senderAgentId);
2571
    const peer = await this.sessionParticipant(session.peer.agentId);
2572
    if (peer.mode !== 'persistent_runtime')
2573
      throw new Error('Temporary-to-temporary hosted conversations are not supported');
2574
    const requestKey = crypto.randomUUID();
2575
    const stored = await this.storeHostedMessage({
2576
      interactionId,
2577
      senderAgentId,
2578
      recipientAgentId: session.peer.agentId,
2579
      requestKey,
2580
      content,
2581
      delivery: 'accepted',
2582
    });
2583
    const response = await postRuntimeJson(
2584
      session.peer.endpoint,
2585
      {
2586
        jsonrpc: '2.0',
2587
        id: requestKey,
2588
        method: 'message/send',
2589
        params: {
2590
          message: {
2591
            role: 'user',
2592
            parts: [{ kind: 'text', text: content.trim() }],
2593
            metadata: {
2594
              [DEFAULT_EXTENSION_URI]: {
2595
                interactionId,
2596
                termsHash: session.contractHash,
2597
                initiatorAgentId:
2598
                  session.role === 'initiator' ? senderAgentId : session.peer.agentId,
2599
                responderAgentId:
2600
                  session.role === 'responder' ? senderAgentId : session.peer.agentId,
2601
              },
2602
            },
2603
          },
2604
        },
2605
      },
2606
      {
2607
        authorization: `Bearer ${session.peer.bearerToken}`,
2608
        'A2A-Extensions': DEFAULT_EXTENSION_URI,
2609
      },
2610
    );
2611
    if (response.status < 200 || response.status >= 300)
2612
      throw new Error(`Peer A2A endpoint returned HTTP ${response.status}`);
2613
    await this.sql`
2614
      UPDATE openclasp_hosted_messages SET delivery = 'delivered'
2615
      WHERE message_id = ${stored.message.messageId}
2616
    `;
2617
    return {
2618
      ...stored,
2619
      message: { ...stored.message, delivery: 'delivered' as const },
2620
      peerResponse: response.body,
2621
    };
2622
  }
2623
2624
  async listHostedThreads(operatorId: string, agentId: string): Promise<HostedThread[]> {
2625
    await this.ensureSchema();
2626
    await this.ownedTemporaryAgent(operatorId, agentId);
2627
    await this.sql`DELETE FROM openclasp_hosted_threads WHERE expires_at <= NOW()`;
2628
    const rows = await this.sql`
2629
      SELECT thread.*,
2630
        (SELECT COUNT(*) FROM openclasp_hosted_messages message
2631
          WHERE message.thread_id = thread.thread_id
2632
            AND message.recipient_agent_id = ${agentId} AND message.read_at IS NULL) AS unread_count
2633
      FROM openclasp_hosted_threads thread
2634
      WHERE ${agentId} IN (thread.participant_a_agent_id, thread.participant_b_agent_id)
2635
      ORDER BY thread.updated_at DESC
2636
    `;
2637
    return rows.map((row) =>
2638
      HostedThreadSchema.parse({
2639
        threadId: String(row.thread_id),
2640
        interactionId: String(row.interaction_id),
2641
        participantAgentIds: [
2642
          String(row.participant_a_agent_id),
2643
          String(row.participant_b_agent_id),
2644
        ],
2645
        status: row.status,
2646
        privacyMode: 'openclasp_hosted_temporary',
2647
        unreadCount: Number(row.unread_count ?? 0),
2648
        createdAt: new Date(String(row.created_at)).toISOString(),
2649
        updatedAt: new Date(String(row.updated_at)).toISOString(),
2650
        expiresAt: new Date(String(row.expires_at)).toISOString(),
2651
      }),
2652
    );
2653
  }
2654
2655
  async getHostedThread(operatorId: string, agentId: string, threadId: string) {
2656
    const threads = await this.listHostedThreads(operatorId, agentId);
2657
    const thread = threads.find((value) => value.threadId === threadId);
2658
    if (!thread) throw new Error('Hosted thread not found');
2659
    const rows = await this.sql`
2660
      SELECT * FROM openclasp_hosted_messages
2661
      WHERE thread_id = ${threadId} ORDER BY created_at ASC
2662
    `;
2663
    const peerAgentId = thread.participantAgentIds.find((value) => value !== agentId);
2664
    if (!peerAgentId) throw new Error('Hosted thread has invalid participants');
2665
    const peer = await this.sessionParticipant(peerAgentId);
2666
    const interactions = await this.sql`
2667
      SELECT payload FROM openclasp_federated_interactions
2668
      WHERE interaction_id = ${thread.interactionId} LIMIT 1
2669
    `;
2670
    const interaction = FederatedInteractionSchema.parse(interactions[0]?.payload);
2671
    const insights = await this.privateCounterpartyInsights(
2672
      operatorId,
2673
      peer,
2674
      interaction.contract.taskCategory,
2675
    );
2676
    return { thread, messages: rows.map((row) => this.hostedMessage(row)), insights };
2677
  }
2678
2679
  async markHostedThreadRead(operatorId: string, agentId: string, threadId: string) {
2680
    await this.getHostedThread(operatorId, agentId, threadId);
2681
    const rows = await this.sql`
2682
      UPDATE openclasp_hosted_messages
2683
      SET delivery = 'read', read_at = COALESCE(read_at, NOW())
2684
      WHERE thread_id = ${threadId} AND recipient_agent_id = ${agentId} AND read_at IS NULL
2685
      RETURNING message_id
2686
    `;
2687
    return { threadId, markedRead: rows.length };
2688
  }
2689
2690
  async closeHostedThread(operatorId: string, agentId: string, threadId: string) {
2691
    await this.getHostedThread(operatorId, agentId, threadId);
2692
    await this.sql`
2693
      UPDATE openclasp_hosted_threads SET status = 'closed', updated_at = NOW()
2694
      WHERE thread_id = ${threadId}
2695
    `;
2696
    return { threadId, status: 'closed' as const };
2697
  }
2698
2699
  async recordLiveSessionEvent(token: string, value: LiveSessionEvent) {
2700
    await this.ensureSchema();
2701
    const event = LiveSessionEventSchema.parse(value);
2702
    const grant = verifySessionGrant(this.gatewaySecret(), token);
2703
    if (grant.interactionId !== event.interactionId || grant.senderAgentId !== event.agentId)
2704
      throw new Error('Session credential does not match the event');
2705
    const sessions = await this.sql`
2706
      SELECT 1 FROM openclasp_live_sessions
2707
      WHERE interaction_id = ${event.interactionId}
2708
        AND status = 'active'
2709
        AND ${event.agentId} IN (initiator_agent_id, responder_agent_id)
2710
    `;
2711
    if (!sessions.length) throw new Error('Active live session not found');
2712
    const attestation = attestSessionRecord(this.gatewaySecret(), event);
2713
    const encoded = JSON.stringify({ ...event, attestation });
2714
    const rows = await this.sql`
2715
      INSERT INTO openclasp_live_session_events(
2716
        interaction_id, event_id, agent_id, sequence, event
2717
      ) SELECT ${event.interactionId}, ${event.eventId}, ${event.agentId}, ${event.sequence}, ${encoded}::jsonb
2718
      WHERE (
2719
        SELECT COUNT(*) FROM openclasp_live_session_events
2720
        WHERE interaction_id = ${event.interactionId}
2721
      ) < 1000
2722
      ON CONFLICT DO NOTHING
2723
      RETURNING event_id
2724
    `;
2725
    if (event.type === 'session_completed' || event.type === 'session_failed') {
2726
      const terminal = await this.sql`
2727
        SELECT COUNT(DISTINCT agent_id) AS count
2728
        FROM openclasp_live_session_events
2729
        WHERE interaction_id = ${event.interactionId}
2730
          AND event->>'type' IN ('session_completed', 'session_failed')
2731
      `;
2732
      if (Number(terminal[0]?.count ?? 0) >= 2) {
2733
        await this.sql`
2734
          UPDATE openclasp_live_sessions SET status = 'completed', completed_at = NOW()
2735
          WHERE interaction_id = ${event.interactionId} AND status = 'active'
2736
        `;
2737
        const completedAt = new Date().toISOString();
2738
        await this.sql`
2739
          UPDATE openclasp_federated_interactions
2740
          SET status = 'completed',
2741
            payload = jsonb_set(
2742
              jsonb_set(payload, '{status}', '"completed"'::jsonb),
2743
              '{updatedAt}', to_jsonb(${completedAt}::text)
2744
            ),
2745
            updated_at = NOW()
2746
          WHERE interaction_id = ${event.interactionId} AND status = 'active'
2747
        `;
2748
      }
2749
    }
2750
    return {
2751
      recorded: rows.length > 0,
2752
      deduplicated: rows.length === 0,
2753
      eventId: event.eventId,
2754
      attestation,
2755
    };
2756
  }
2757
2758
  async createFederatedInteraction(
2759
    operatorId: string,
2760
    value: FederatedInteraction,
2761
  ): Promise<FederatedInteraction> {
2762
    await this.ensureSchema();
2763
    const interaction = FederatedInteractionSchema.parse(value);
2764
    if (interaction.status !== 'pending') throw new Error('New interactions must be pending');
2765
    if (interaction.contract.interactionId !== interaction.interactionId)
2766
      throw new Error('Contract interaction ID does not match');
2767
    if (canonicalHash(interaction.contract) !== interaction.termsHash)
2768
      throw new Error('Contract hash does not match the immutable terms');
2769
    if (
2770
      interaction.contract.parties.length !== 2 ||
2771
      interaction.contract.parties[0] !== interaction.initiatorAgentId ||
2772
      interaction.contract.parties[1] !== interaction.responderAgentId
2773
    )
2774
      throw new Error('Contract parties do not match the interaction participants');
2775
    const acceptanceEntries = Object.entries(interaction.acceptances);
2776
    if (
2777
      acceptanceEntries.length !== 1 ||
2778
      acceptanceEntries[0]?.[0] !== interaction.initiatorAgentId ||
2779
      acceptanceEntries[0]?.[1].agentId !== interaction.initiatorAgentId ||
2780
      acceptanceEntries[0]?.[1].termsHash !== interaction.termsHash ||
2781
      !['oauth_installation', 'oauth_account'].includes(acceptanceEntries[0]?.[1].method ?? '')
2782
    )
2783
      throw new Error('A pending interaction requires only the initiator acceptance');
2784
    if (Date.parse(interaction.expiresAt) <= Date.now())
2785
      throw new Error('Interaction expiry must be in the future');
2786
    const owners = await this.sql`
2787
      SELECT agent_id, operator_id, card FROM openclasp_public_agents
2788
      WHERE agent_id IN (${interaction.initiatorAgentId}, ${interaction.responderAgentId})
2789
    `;
2790
    const initiator = owners.find((row) => row.agent_id === interaction.initiatorAgentId);
2791
    const responder = owners.find((row) => row.agent_id === interaction.responderAgentId);
2792
    if (!initiator || initiator.operator_id !== operatorId)
2793
      throw new Error('The initiating agent must be published and owned by this account');
2794
    if (!responder) throw new Error('Responder is not a published OpenClasp agent');
2795
    if (responder.operator_id === operatorId)
2796
      throw new Error('Federated interactions require agents from different accounts');
2797
    const responderCard = normalizePublicAgentCard(responder.card);
2798
    if (
2799
      !responderCard.transports.some(
2800
        (transport) =>
2801
          transport.endpoint === interaction.responderTransport.endpoint &&
2802
          transport.protocolBinding === interaction.responderTransport.protocolBinding,
2803
      )
2804
    )
2805
      throw new Error('Responder transport does not match its published Agent Card');
2806
    const encoded = JSON.stringify(interaction);
2807
    const rows = await this.sql`
2808
      INSERT INTO openclasp_federated_interactions(
2809
        interaction_id, initiator_operator_id, responder_operator_id,
2810
        initiator_agent_id, responder_agent_id, status, payload, expires_at
2811
      ) VALUES (
2812
        ${interaction.interactionId}, ${operatorId}, ${responder.operator_id},
2813
        ${interaction.initiatorAgentId}, ${interaction.responderAgentId},
2814
        ${interaction.status}, ${encoded}::jsonb, ${interaction.expiresAt}
2815
      )
2816
      RETURNING payload
2817
    `;
2818
    const stored = FederatedInteractionSchema.parse(rows[0]?.payload);
2819
    const profiles = await this.sql`
2820
      SELECT payload FROM openclasp_records
2821
      WHERE operator_id = ${responder.operator_id}
2822
        AND kind = 'agent_profile'
2823
        AND record_id = ${interaction.responderAgentId}
2824
      LIMIT 1
2825
    `;
2826
    const responderProfile = profiles[0]?.payload as AgentProfile | undefined;
2827
    if (responderProfile && canAutoAcceptInteraction(responderProfile, stored))
2828
      return this.respondToFederatedInteraction(
2829
        String(responder.operator_id),
2830
        stored.interactionId,
2831
        stored.responderAgentId,
2832
        'accept',
2833
        'policy_auto_accept',
2834
      );
2835
    return stored;
2836
  }
2837
2838
  async listFederatedInteractions(operatorId: string): Promise<FederatedInteraction[]> {
2839
    await this.ensureSchema();
2840
    await this.sql`
2841
      UPDATE openclasp_federated_interactions
2842
      SET status = 'expired', payload = jsonb_set(payload, '{status}', '"expired"'::jsonb), updated_at = NOW()
2843
      WHERE status = 'pending' AND expires_at <= NOW()
2844
    `;
2845
    const rows = await this.sql`
2846
      SELECT payload FROM openclasp_federated_interactions
2847
      WHERE initiator_operator_id = ${operatorId} OR responder_operator_id = ${operatorId}
2848
      ORDER BY updated_at DESC
2849
    `;
2850
    return rows.map((row) => FederatedInteractionSchema.parse(row.payload));
2851
  }
2852
2853
  async getFederatedInteraction(
2854
    operatorId: string,
2855
    interactionId: string,
2856
  ): Promise<FederatedInteraction | undefined> {
2857
    const values = await this.listFederatedInteractions(operatorId);
2858
    return values.find((value) => value.interactionId === interactionId);
2859
  }
2860
2861
  async respondToFederatedInteraction(
2862
    operatorId: string,
2863
    interactionId: string,
2864
    agentId: string,
2865
    decision: 'accept' | 'reject',
2866
    method: 'oauth_installation' | 'oauth_account' | 'policy_auto_accept' = 'oauth_account',
2867
  ): Promise<FederatedInteraction> {
2868
    await this.ensureSchema();
2869
    const rows = await this.sql`
2870
      SELECT payload, responder_operator_id, responder_agent_id
2871
      FROM openclasp_federated_interactions WHERE interaction_id = ${interactionId}
2872
    `;
2873
    const row = rows[0];
2874
    if (!row) throw new Error('Interaction invitation not found');
2875
    if (row.responder_operator_id !== operatorId || row.responder_agent_id !== agentId)
2876
      throw new Error('Only the invited agent may respond');
2877
    const current = FederatedInteractionSchema.parse(row.payload);
2878
    if (current.status !== 'pending') throw new Error('Invitation is no longer pending');
2879
    const now = new Date().toISOString();
2880
    if (Date.parse(current.expiresAt) <= Date.now()) {
2881
      const expired = { ...current, status: 'expired' as const, updatedAt: now };
2882
      await this.updateFederatedInteraction(interactionId, current.status, expired);
2883
      throw new Error('Invitation has expired');
2884
    }
2885
    const next: FederatedInteraction =
2886
      decision === 'reject'
2887
        ? { ...current, status: 'rejected', updatedAt: now }
2888
        : {
2889
            ...current,
2890
            status: 'active',
2891
            updatedAt: now,
2892
            acceptances: {
2893
              ...current.acceptances,
2894
              [agentId]: {
2895
                agentId,
2896
                method,
2897
                termsHash: current.termsHash,
2898
                acceptedAt: now,
2899
              },
2900
            },
2901
          };
2902
    if (decision === 'accept') {
2903
      try {
2904
        await this.brokerLiveSession(next);
2905
      } catch (error) {
2906
        const reason = error instanceof Error ? error.message.slice(0, 500) : 'Live session failed';
2907
        await this.sql`
2908
          UPDATE openclasp_agent_runtimes SET last_error = ${reason}, updated_at = NOW()
2909
          WHERE agent_id IN (${current.initiatorAgentId}, ${current.responderAgentId})
2910
        `;
2911
        throw error;
2912
      }
2913
    }
2914
    const updated = await this.updateFederatedInteraction(interactionId, current.status, next);
2915
    if (!updated) throw new Error('Invitation was already handled');
2916
    return next;
2917
  }
2918
2919
  private async updateFederatedInteraction(
2920
    interactionId: string,
2921
    expectedStatus: string,
2922
    value: FederatedInteraction,
2923
  ): Promise<boolean> {
2924
    const encoded = JSON.stringify(FederatedInteractionSchema.parse(value));
2925
    const rows = await this.sql`
2926
      UPDATE openclasp_federated_interactions
2927
      SET status = ${value.status}, payload = ${encoded}::jsonb, updated_at = NOW()
2928
      WHERE interaction_id = ${interactionId} AND status = ${expectedStatus}
2929
      RETURNING interaction_id
2930
    `;
2931
    return rows.length > 0;
2932
  }
2933
2934
  async getSettings(operatorId: string): Promise<AccountSettings> {
2935
    await this.ensureSchema();
2936
    const rows = await this.sql`
2937
      SELECT display_name, contribution_enabled, retention_days, evidence_sharing
2938
      FROM openclasp_account_settings
2939
      WHERE operator_id = ${operatorId}
2940
    `;
2941
    const row = rows[0] as
2942
      | {
2943
          display_name: string;
2944
          contribution_enabled: boolean;
2945
          retention_days: number;
2946
          evidence_sharing: AccountSettings['evidenceSharing'];
2947
        }
2948
      | undefined;
2949
    return row
2950
      ? {
2951
          displayName: row.display_name,
2952
          contributionEnabled: row.contribution_enabled,
2953
          retentionDays: row.retention_days,
2954
          evidenceSharing: row.evidence_sharing,
2955
          rawConversationsStored: false,
2956
        }
2957
      : defaults;
2958
  }
2959
2960
  async saveSettings(
2961
    operatorId: string,
2962
    settings: Omit<AccountSettings, 'rawConversationsStored'>,
2963
  ): Promise<AccountSettings> {
2964
    await this.ensureSchema();
2965
    await this.sql`
2966
      INSERT INTO openclasp_account_settings(
2967
        operator_id, display_name, contribution_enabled, retention_days, evidence_sharing
2968
      ) VALUES (
2969
        ${operatorId}, ${settings.displayName}, ${settings.contributionEnabled},
2970
        ${settings.retentionDays}, ${settings.evidenceSharing}
2971
      )
2972
      ON CONFLICT (operator_id) DO UPDATE SET
2973
        display_name = EXCLUDED.display_name,
2974
        contribution_enabled = EXCLUDED.contribution_enabled,
2975
        retention_days = EXCLUDED.retention_days,
2976
        evidence_sharing = EXCLUDED.evidence_sharing,
2977
        updated_at = NOW()
2978
    `;
2979
    return { ...settings, rawConversationsStored: false };
2980
  }
2981
}