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

Viewing files at commit b95ad82c1c50Commit detailsView latest

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