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

Viewing files at commit c6b86fb80ff9Commit detailsView latest

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