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

Viewing files at commit 89b58109a0e6Commit detailsView latest

packages/persistence/src/hosted-migrations.ts 305 lines · 10.6 KB · typescript
1
import type { NeonQueryFunction } from '@neondatabase/serverless';
2
3
type Sql = NeonQueryFunction<false, false>;
4
5
export const HOSTED_MIGRATIONS = [
6
  { version: 1, name: 'hosted_baseline' },
7
  { version: 2, name: 'append_only_source_records' },
8
  { version: 3, name: 'remove_hosted_conversations' },
9
] as const;
10
11
async function applyBaseline(sql: Sql) {
12
  await sql`
13
    CREATE TABLE IF NOT EXISTS openclasp_records (
14
      operator_id TEXT NOT NULL,
15
      kind TEXT NOT NULL,
16
      record_id TEXT NOT NULL,
17
      payload JSONB NOT NULL,
18
      created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
19
      updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
20
      PRIMARY KEY (operator_id, kind, record_id)
21
    )
22
  `;
23
  await sql`
24
    CREATE INDEX IF NOT EXISTS openclasp_records_operator_created
25
    ON openclasp_records(operator_id, created_at DESC)
26
  `;
27
  await sql`
28
    CREATE TABLE IF NOT EXISTS openclasp_account_settings (
29
      operator_id TEXT PRIMARY KEY,
30
      display_name TEXT NOT NULL DEFAULT '',
31
      contribution_enabled BOOLEAN NOT NULL DEFAULT FALSE,
32
      retention_days INTEGER NOT NULL DEFAULT 30 CHECK (retention_days BETWEEN 0 AND 3650),
33
      evidence_sharing TEXT NOT NULL DEFAULT 'ask'
34
        CHECK (evidence_sharing IN ('never', 'ask', 'contract_only')),
35
      updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
36
    )
37
  `;
38
  await sql`
39
    CREATE TABLE IF NOT EXISTS openclasp_public_agents (
40
      agent_id TEXT PRIMARY KEY,
41
      operator_id TEXT NOT NULL,
42
      card JSONB NOT NULL,
43
      published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
44
      updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
45
    )
46
  `;
47
  await sql`
48
    CREATE INDEX IF NOT EXISTS openclasp_public_agents_updated
49
    ON openclasp_public_agents(updated_at DESC)
50
  `;
51
  await sql`
52
    CREATE TABLE IF NOT EXISTS openclasp_federated_interactions (
53
      interaction_id TEXT PRIMARY KEY,
54
      initiator_operator_id TEXT NOT NULL,
55
      responder_operator_id TEXT NOT NULL,
56
      initiator_agent_id TEXT NOT NULL,
57
      responder_agent_id TEXT NOT NULL,
58
      status TEXT NOT NULL,
59
      payload JSONB NOT NULL,
60
      expires_at TIMESTAMPTZ NOT NULL,
61
      created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
62
      updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
63
    )
64
  `;
65
  await sql`
66
    CREATE INDEX IF NOT EXISTS openclasp_federated_interactions_participants
67
    ON openclasp_federated_interactions(
68
      initiator_operator_id,
69
      responder_operator_id,
70
      updated_at DESC
71
    )
72
  `;
73
  await sql`
74
    CREATE TABLE IF NOT EXISTS openclasp_agent_runtimes (
75
      agent_id TEXT PRIMARY KEY,
76
      operator_id TEXT NOT NULL,
77
      endpoint TEXT NOT NULL,
78
      secret_ciphertext TEXT NOT NULL,
79
      secret_iv TEXT NOT NULL,
80
      secret_auth_tag TEXT NOT NULL,
81
      status TEXT NOT NULL CHECK (status IN ('verified', 'disabled')),
82
      verified_at TIMESTAMPTZ NOT NULL,
83
      last_delivery_at TIMESTAMPTZ,
84
      last_error TEXT,
85
      created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
86
      updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
87
    )
88
  `;
89
  await sql`
90
    CREATE TABLE IF NOT EXISTS openclasp_agent_access_tokens (
91
      token_id TEXT PRIMARY KEY,
92
      operator_id TEXT NOT NULL,
93
      agent_id TEXT NOT NULL,
94
      name TEXT NOT NULL,
95
      token_hash TEXT NOT NULL,
96
      scopes JSONB NOT NULL,
97
      expires_at TIMESTAMPTZ NOT NULL,
98
      last_used_at TIMESTAMPTZ,
99
      revoked_at TIMESTAMPTZ,
100
      created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
101
    )
102
  `;
103
  await sql`
104
    CREATE INDEX IF NOT EXISTS openclasp_agent_access_tokens_owner_agent
105
    ON openclasp_agent_access_tokens(operator_id, agent_id, created_at DESC)
106
  `;
107
  await sql`
108
    ALTER TABLE openclasp_agent_runtimes
109
    ADD COLUMN IF NOT EXISTS a2a_endpoint TEXT,
110
    ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ
111
  `;
112
  await sql`
113
    CREATE TABLE IF NOT EXISTS openclasp_live_sessions (
114
      interaction_id TEXT PRIMARY KEY,
115
      initiator_agent_id TEXT NOT NULL,
116
      responder_agent_id TEXT NOT NULL,
117
      initiator_session_id TEXT NOT NULL,
118
      responder_session_id TEXT NOT NULL,
119
      initiator_endpoint TEXT NOT NULL,
120
      responder_endpoint TEXT NOT NULL,
121
      status TEXT NOT NULL CHECK (status IN ('preparing', 'active', 'completed', 'failed')),
122
      expires_at TIMESTAMPTZ NOT NULL,
123
      created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
124
      activated_at TIMESTAMPTZ,
125
      completed_at TIMESTAMPTZ,
126
      last_error TEXT
127
    )
128
  `;
129
  await sql`
130
    CREATE TABLE IF NOT EXISTS openclasp_live_session_events (
131
      interaction_id TEXT NOT NULL,
132
      event_id TEXT NOT NULL,
133
      agent_id TEXT NOT NULL,
134
      sequence INTEGER NOT NULL,
135
      event JSONB NOT NULL,
136
      created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
137
      PRIMARY KEY (interaction_id, event_id),
138
      UNIQUE (interaction_id, agent_id, sequence)
139
    )
140
  `;
141
  await sql`
142
    CREATE TABLE IF NOT EXISTS openclasp_hosted_threads (
143
      thread_id TEXT PRIMARY KEY,
144
      interaction_id TEXT NOT NULL UNIQUE,
145
      participant_a_agent_id TEXT NOT NULL,
146
      participant_b_agent_id TEXT NOT NULL,
147
      status TEXT NOT NULL CHECK (status IN ('open', 'closed')) DEFAULT 'open',
148
      created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
149
      updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
150
      expires_at TIMESTAMPTZ NOT NULL
151
    )
152
  `;
153
  await sql`
154
    CREATE INDEX IF NOT EXISTS openclasp_hosted_threads_participants
155
    ON openclasp_hosted_threads(
156
      participant_a_agent_id,
157
      participant_b_agent_id,
158
      updated_at DESC
159
    )
160
  `;
161
  await sql`
162
    CREATE TABLE IF NOT EXISTS openclasp_hosted_messages (
163
      message_id TEXT PRIMARY KEY,
164
      thread_id TEXT NOT NULL REFERENCES openclasp_hosted_threads(thread_id) ON DELETE CASCADE,
165
      interaction_id TEXT NOT NULL,
166
      sender_agent_id TEXT NOT NULL,
167
      recipient_agent_id TEXT NOT NULL,
168
      request_key TEXT NOT NULL,
169
      content_ciphertext TEXT NOT NULL,
170
      content_iv TEXT NOT NULL,
171
      content_auth_tag TEXT NOT NULL,
172
      content_hash TEXT NOT NULL,
173
      delivery TEXT NOT NULL CHECK (delivery IN ('accepted', 'delivered', 'read')),
174
      created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
175
      read_at TIMESTAMPTZ
176
    )
177
  `;
178
  await sql`
179
    CREATE UNIQUE INDEX IF NOT EXISTS openclasp_hosted_messages_dedup
180
    ON openclasp_hosted_messages(thread_id, sender_agent_id, request_key)
181
  `;
182
  await sql`
183
    CREATE INDEX IF NOT EXISTS openclasp_hosted_messages_thread_created
184
    ON openclasp_hosted_messages(thread_id, created_at ASC)
185
  `;
186
}
187
188
async function applyAppendOnlySourceRecords(sql: Sql) {
189
  await sql`
190
    CREATE TABLE IF NOT EXISTS openclasp_source_records (
191
      event_id UUID PRIMARY KEY,
192
      operator_id TEXT NOT NULL,
193
      kind TEXT NOT NULL,
194
      record_id TEXT NOT NULL,
195
      schema_name TEXT NOT NULL,
196
      schema_version TEXT NOT NULL,
197
      payload JSONB NOT NULL,
198
      payload_digest TEXT NOT NULL,
199
      entity_refs JSONB NOT NULL DEFAULT '{}'::jsonb,
200
      provenance TEXT NOT NULL,
201
      visibility TEXT NOT NULL,
202
      retention_class TEXT NOT NULL,
203
      learning_scope TEXT NOT NULL,
204
      reported_at TIMESTAMPTZ NOT NULL,
205
      ingested_at TIMESTAMPTZ NOT NULL,
206
      UNIQUE (operator_id, kind, record_id, payload_digest),
207
      CHECK (provenance IN (
208
        'self_declared',
209
        'operator_attested',
210
        'cryptographically_verified',
211
        'domain_verified',
212
        'third_party_verified',
213
        'observed',
214
        'disputed'
215
      )),
216
      CHECK (visibility IN (
217
        'local_only',
218
        'private_requester',
219
        'private_responder',
220
        'shared_participants',
221
        'network_aggregate'
222
      )),
223
      CHECK (retention_class IN ('account', 'audit', 'operational', 'temporary')),
224
      CHECK (learning_scope IN (
225
        'not_evaluated',
226
        'excluded',
227
        'local_only',
228
        'network_aggregate'
229
      ))
230
    )
231
  `;
232
  await sql`
233
    CREATE INDEX IF NOT EXISTS openclasp_source_records_operator_ingested
234
    ON openclasp_source_records(operator_id, ingested_at DESC)
235
  `;
236
  await sql`
237
    CREATE INDEX IF NOT EXISTS openclasp_source_records_history
238
    ON openclasp_source_records(operator_id, kind, record_id, ingested_at ASC)
239
  `;
240
  await sql`
241
    CREATE INDEX IF NOT EXISTS openclasp_source_records_entities
242
    ON openclasp_source_records USING GIN(entity_refs)
243
  `;
244
}
245
246
async function removeHostedConversations(sql: Sql) {
247
  await sql`DROP TABLE IF EXISTS openclasp_hosted_messages`;
248
  await sql`DROP TABLE IF EXISTS openclasp_hosted_threads`;
249
}
250
251
export async function runHostedMigrations(sql: Sql): Promise<void> {
252
  await sql`
253
    CREATE TABLE IF NOT EXISTS openclasp_schema_migrations (
254
      version INTEGER PRIMARY KEY,
255
      name TEXT NOT NULL UNIQUE,
256
      applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
257
    )
258
  `;
259
  const appliedRows = await sql`
260
    SELECT version, name FROM openclasp_schema_migrations ORDER BY version ASC
261
  `;
262
  const applied = new Map(appliedRows.map((row) => [Number(row.version), String(row.name)]));
263
  for (const migration of HOSTED_MIGRATIONS) {
264
    const appliedName = applied.get(migration.version);
265
    if (appliedName && appliedName !== migration.name)
266
      throw new Error(
267
        `Hosted migration ${migration.version} name mismatch: ${appliedName} != ${migration.name}`,
268
      );
269
    if (appliedName) continue;
270
    if (migration.version === 1) await applyBaseline(sql);
271
    if (migration.version === 2) await applyAppendOnlySourceRecords(sql);
272
    if (migration.version === 3) await removeHostedConversations(sql);
273
    await sql`
274
      INSERT INTO openclasp_schema_migrations(version, name)
275
      VALUES (${migration.version}, ${migration.name})
276
      ON CONFLICT (version) DO NOTHING
277
    `;
278
  }
279
}
280
281
export async function verifyHostedMigrations(sql: Sql): Promise<void> {
282
  const tableRows = await sql`
283
    SELECT to_regclass('public.openclasp_schema_migrations') AS table_name
284
  `;
285
  if (!tableRows[0]?.table_name)
286
    throw new Error('Hosted database is not migrated; run `pnpm migrate` before starting the API');
287
  const appliedRows = await sql`
288
    SELECT version, name FROM openclasp_schema_migrations ORDER BY version ASC
289
  `;
290
  const applied = new Map(appliedRows.map((row) => [Number(row.version), String(row.name)]));
291
  for (const migration of HOSTED_MIGRATIONS) {
292
    const name = applied.get(migration.version);
293
    if (name !== migration.name)
294
      throw new Error(
295
        `Hosted database migration ${migration.version} is missing or invalid; run \`pnpm migrate\``,
296
      );
297
  }
298
  const removedConversationTables = await sql`
299
    SELECT
300
      to_regclass('public.openclasp_hosted_messages') AS messages,
301
      to_regclass('public.openclasp_hosted_threads') AS threads
302
  `;
303
  if (removedConversationTables[0]?.messages || removedConversationTables[0]?.threads)
304
    throw new Error('Hosted conversation tables still exist; run `pnpm migrate`');
305
}