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

Viewing files at commit 66ff6831b5d2Commit detailsView latest

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