@gnolith/taproot 0.1.0-rc.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/COMPATIBILITY.md +15 -3
  3. package/LICENSE +21 -21
  4. package/README.md +122 -46
  5. package/SECURITY.md +10 -3
  6. package/SUPPORT.md +6 -1
  7. package/dist/authorization-maintenance.d.ts +55 -0
  8. package/dist/authorization-maintenance.d.ts.map +1 -0
  9. package/dist/authorization-maintenance.js +686 -0
  10. package/dist/authorization-maintenance.js.map +1 -0
  11. package/dist/authorization.d.ts +98 -0
  12. package/dist/authorization.d.ts.map +1 -0
  13. package/dist/authorization.js +1137 -0
  14. package/dist/authorization.js.map +1 -0
  15. package/dist/canonical.d.ts +2 -1
  16. package/dist/canonical.d.ts.map +1 -1
  17. package/dist/canonical.js +9 -1
  18. package/dist/canonical.js.map +1 -1
  19. package/dist/errors.d.ts +10 -0
  20. package/dist/errors.d.ts.map +1 -1
  21. package/dist/errors.js +10 -0
  22. package/dist/errors.js.map +1 -1
  23. package/dist/index.d.ts +127 -50
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +352 -44
  26. package/dist/index.js.map +1 -1
  27. package/dist/migrations.d.ts +42 -0
  28. package/dist/migrations.d.ts.map +1 -0
  29. package/dist/migrations.js +550 -0
  30. package/dist/migrations.js.map +1 -0
  31. package/dist/rdf.js +1 -1
  32. package/dist/rdf.js.map +1 -1
  33. package/dist/repository.d.ts +22 -11
  34. package/dist/repository.d.ts.map +1 -1
  35. package/dist/repository.js +596 -63
  36. package/dist/repository.js.map +1 -1
  37. package/dist/schema.d.ts +41 -7
  38. package/dist/schema.d.ts.map +1 -1
  39. package/dist/schema.js +722 -83
  40. package/dist/schema.js.map +1 -1
  41. package/dist/types.d.ts +59 -0
  42. package/dist/types.d.ts.map +1 -1
  43. package/docs/api.md +70 -18
  44. package/docs/architecture.md +21 -8
  45. package/docs/authorization.md +82 -0
  46. package/docs/completion-audit.md +22 -16
  47. package/docs/operations.md +48 -9
  48. package/docs/product-scope.md +11 -4
  49. package/docs/release-policy.md +4 -0
  50. package/docs/testing.md +31 -5
  51. package/docs/threat-model.md +38 -6
  52. package/examples/d1-diamond-interop/README.md +12 -0
  53. package/examples/d1-diamond-interop/demo.ts +281 -0
  54. package/migrations/0002_audit_operations.sql +4 -3
  55. package/migrations/0003_canonical_statement_text.sql +31 -0
  56. package/migrations/0004_canonical_authorization_policy.sql +213 -0
  57. package/package.json +12 -7
  58. package/examples/codex-site/.openai/hosting.json +0 -3
  59. package/examples/codex-site/README.md +0 -13
  60. package/examples/codex-site/demo.ts +0 -152
package/dist/schema.js CHANGED
@@ -1,12 +1,13 @@
1
- import { encodeTerm, initializeStore, prepareQuadPatch, } from '@gnolith/diamond';
1
+ import { encodeTerm, prepareQuadPatch, } from '@gnolith/diamond';
2
2
  import { DataFactory } from 'rdf-data-factory';
3
3
  import { parseEntityJson } from './canonical.js';
4
4
  import { buildEntityQuads } from './rdf.js';
5
5
  import { SchemaMismatchError } from './errors.js';
6
- export const TAPROOT_SCHEMA_VERSION = '2';
7
- export const TAPROOT_JSON_VERSION = '1';
6
+ export const TAPROOT_SCHEMA_VERSION = '3';
7
+ export const TAPROOT_JSON_VERSION = '2';
8
8
  export const TAPROOT_RDF_VERSION = '2';
9
- export const taprootSchemaStatements = [
9
+ const PRE_AUTHORIZATION_SCHEMA_VERSION = '2';
10
+ const preAuthorizationTaprootSchemaStatements = [
10
11
  `CREATE TABLE IF NOT EXISTS taproot_entities (
11
12
  entity_id TEXT PRIMARY KEY,
12
13
  entity_type TEXT NOT NULL CHECK (entity_type IN ('item', 'property')),
@@ -113,41 +114,333 @@ export const taprootSchemaStatements = [
113
114
  ON CONFLICT(entity_type) DO NOTHING`,
114
115
  `INSERT INTO taproot_metadata(metadata_key, metadata_value)
115
116
  VALUES
116
- ('schema_version', '${TAPROOT_SCHEMA_VERSION}'),
117
+ ('schema_version', '${PRE_AUTHORIZATION_SCHEMA_VERSION}'),
117
118
  ('canonical_json_version', '${TAPROOT_JSON_VERSION}'),
118
119
  ('rdf_mapping_version', '${TAPROOT_RDF_VERSION}')
119
120
  ON CONFLICT(metadata_key) DO UPDATE SET metadata_value = excluded.metadata_value`,
120
- `INSERT INTO taproot_migrations(version, name) VALUES (1, 'initial'), (2, 'audit-and-operations')
121
+ `INSERT INTO taproot_migrations(version, name) VALUES (1, 'initial'), (2, 'audit-and-operations'), (3, 'canonical-statement-text')
121
122
  ON CONFLICT(version) DO NOTHING`,
122
123
  ];
123
- export async function initializeTaproot(db) {
124
- const previousRdfVersion = (await readMetadata(db, 'rdf_migration_from')) ??
125
- (await readMetadata(db, 'rdf_mapping_version'));
126
- if (previousRdfVersion && previousRdfVersion !== TAPROOT_RDF_VERSION) {
127
- await db
128
- .prepare(`INSERT INTO taproot_metadata(metadata_key, metadata_value) VALUES ('rdf_migration_from', ?)
129
- ON CONFLICT(metadata_key) DO NOTHING`)
130
- .bind(previousRdfVersion)
131
- .run();
132
- }
133
- await initializeStore(db);
134
- await upgradeLegacySchema(db);
135
- await db.batch(taprootSchemaStatements.map((sql) => db.prepare(sql)));
136
- await db
137
- .prepare(`INSERT INTO taproot_audit_events(
138
- event_id, entity_id, revision, event_type, attribution_json, edit_summary,
139
- tags_json, request_id, content_hash, parent_hash, details_json, created_at
140
- ) SELECT event_id, entity_id, revision,
141
- CASE WHEN revision = 1 THEN 'import' ELSE 'update' END,
142
- attribution_json, edit_summary, tags_json, NULL, content_hash, parent_hash,
143
- json_object('deletedAt', deleted_at, 'redirectTo', redirect_to), created_at
144
- FROM taproot_entity_revisions WHERE event_id IS NOT NULL AND content_hash IS NOT NULL
145
- ON CONFLICT(event_id) DO NOTHING`)
146
- .run();
147
- await backfillRdfOwnership(db, previousRdfVersion);
148
- await db
149
- .prepare(`DELETE FROM taproot_metadata WHERE metadata_key = 'rdf_migration_from'`)
150
- .run();
124
+ /** Additive canonical authorization catalog introduced by migration 0004. */
125
+ export const taprootAuthorizationSchemaStatements = [
126
+ `CREATE TABLE IF NOT EXISTS taproot_installation_authorization (
127
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
128
+ installation_id TEXT NOT NULL UNIQUE,
129
+ authorization_revision INTEGER NOT NULL CHECK (authorization_revision >= 1),
130
+ search_generation INTEGER NOT NULL CHECK (search_generation >= 1),
131
+ last_advance_id TEXT NOT NULL,
132
+ created_at TEXT NOT NULL,
133
+ updated_at TEXT NOT NULL
134
+ ) STRICT`,
135
+ `CREATE TABLE IF NOT EXISTS taproot_entity_authorization (
136
+ entity_id TEXT PRIMARY KEY,
137
+ installation_id TEXT NOT NULL,
138
+ workspace_id TEXT,
139
+ owner_principal_id TEXT NOT NULL,
140
+ visibility_json TEXT NOT NULL CHECK (json_valid(visibility_json)),
141
+ effective_visibility_json TEXT NOT NULL CHECK (json_valid(effective_visibility_json)),
142
+ source_revision INTEGER NOT NULL,
143
+ authorization_revision INTEGER NOT NULL,
144
+ deleted_at TEXT,
145
+ event_id TEXT NOT NULL UNIQUE,
146
+ updated_at TEXT NOT NULL,
147
+ FOREIGN KEY (entity_id) REFERENCES taproot_entities(entity_id),
148
+ FOREIGN KEY (event_id) REFERENCES taproot_audit_events(event_id)
149
+ ) STRICT`,
150
+ `CREATE TABLE IF NOT EXISTS taproot_entity_authorization_revisions (
151
+ entity_id TEXT NOT NULL,
152
+ source_revision INTEGER NOT NULL,
153
+ installation_id TEXT NOT NULL,
154
+ workspace_id TEXT,
155
+ owner_principal_id TEXT NOT NULL,
156
+ visibility_json TEXT NOT NULL CHECK (json_valid(visibility_json)),
157
+ effective_visibility_json TEXT NOT NULL CHECK (json_valid(effective_visibility_json)),
158
+ authorization_revision INTEGER NOT NULL,
159
+ deleted_at TEXT,
160
+ event_id TEXT NOT NULL UNIQUE,
161
+ created_at TEXT NOT NULL,
162
+ PRIMARY KEY (entity_id, source_revision),
163
+ FOREIGN KEY (entity_id, source_revision)
164
+ REFERENCES taproot_entity_revisions(entity_id, revision),
165
+ FOREIGN KEY (event_id) REFERENCES taproot_audit_events(event_id)
166
+ ) STRICT`,
167
+ `CREATE TABLE IF NOT EXISTS taproot_statement_authorization (
168
+ entity_id TEXT NOT NULL,
169
+ statement_id TEXT NOT NULL,
170
+ source_revision INTEGER NOT NULL,
171
+ restrictions_json TEXT NOT NULL CHECK (json_valid(restrictions_json)),
172
+ effective_visibility_json TEXT NOT NULL CHECK (json_valid(effective_visibility_json)),
173
+ authorization_revision INTEGER NOT NULL,
174
+ PRIMARY KEY (entity_id, statement_id),
175
+ FOREIGN KEY (entity_id) REFERENCES taproot_entities(entity_id)
176
+ ) STRICT`,
177
+ `CREATE TABLE IF NOT EXISTS taproot_statement_authorization_revisions (
178
+ entity_id TEXT NOT NULL,
179
+ source_revision INTEGER NOT NULL,
180
+ statement_id TEXT NOT NULL,
181
+ restrictions_json TEXT NOT NULL CHECK (json_valid(restrictions_json)),
182
+ effective_visibility_json TEXT NOT NULL CHECK (json_valid(effective_visibility_json)),
183
+ authorization_revision INTEGER NOT NULL,
184
+ PRIMARY KEY (entity_id, source_revision, statement_id),
185
+ FOREIGN KEY (entity_id, source_revision)
186
+ REFERENCES taproot_entity_revisions(entity_id, revision)
187
+ ) STRICT`,
188
+ `CREATE TABLE IF NOT EXISTS taproot_authorization_projection_outbox (
189
+ event_id TEXT PRIMARY KEY,
190
+ entity_id TEXT NOT NULL,
191
+ source_revision INTEGER NOT NULL,
192
+ authorization_revision INTEGER NOT NULL,
193
+ search_generation INTEGER NOT NULL,
194
+ operation TEXT NOT NULL CHECK (operation IN ('upsert', 'delete', 'repair', 'backfill')),
195
+ state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'claimed', 'complete')),
196
+ created_at TEXT NOT NULL,
197
+ FOREIGN KEY (event_id) REFERENCES taproot_audit_events(event_id)
198
+ ) STRICT`,
199
+ `CREATE TABLE IF NOT EXISTS taproot_authorization_backfill_plans (
200
+ plan_id TEXT PRIMARY KEY,
201
+ installation_id TEXT NOT NULL,
202
+ base_authorization_revision INTEGER NOT NULL,
203
+ manifest_json TEXT NOT NULL CHECK (json_valid(manifest_json)),
204
+ manifest_hash TEXT NOT NULL,
205
+ entity_count INTEGER NOT NULL CHECK (entity_count > 0 AND entity_count <= 100),
206
+ revision_count INTEGER NOT NULL CHECK (revision_count > 0),
207
+ status TEXT NOT NULL CHECK (status IN ('planned', 'applying', 'complete')),
208
+ created_by TEXT NOT NULL,
209
+ created_at TEXT NOT NULL,
210
+ completed_at TEXT
211
+ ) STRICT`,
212
+ `CREATE TABLE IF NOT EXISTS taproot_authorization_admin_audit (
213
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT,
214
+ audit_id TEXT NOT NULL UNIQUE,
215
+ event_type TEXT NOT NULL CHECK (event_type IN ('backfill-plan', 'backfill-apply')),
216
+ principal_id TEXT NOT NULL,
217
+ plan_id TEXT NOT NULL,
218
+ authorization_revision INTEGER NOT NULL,
219
+ details_json TEXT NOT NULL CHECK (json_valid(details_json)),
220
+ created_at TEXT NOT NULL,
221
+ FOREIGN KEY (plan_id) REFERENCES taproot_authorization_backfill_plans(plan_id)
222
+ ) STRICT`,
223
+ `CREATE TABLE IF NOT EXISTS taproot_installation_authorization_advances (
224
+ advance_id TEXT PRIMARY KEY,
225
+ installation_id TEXT NOT NULL,
226
+ from_revision INTEGER NOT NULL,
227
+ to_revision INTEGER NOT NULL,
228
+ search_generation INTEGER NOT NULL,
229
+ domain TEXT NOT NULL,
230
+ principal_id TEXT NOT NULL,
231
+ reason TEXT NOT NULL,
232
+ created_at TEXT NOT NULL,
233
+ CHECK (to_revision = from_revision + 1)
234
+ ) STRICT`,
235
+ `CREATE INDEX IF NOT EXISTS taproot_entity_authorization_candidate_idx
236
+ ON taproot_entity_authorization(installation_id, deleted_at, entity_id)`,
237
+ `CREATE INDEX IF NOT EXISTS taproot_entity_authorization_revision_idx
238
+ ON taproot_entity_authorization_revisions(entity_id, source_revision DESC)`,
239
+ `CREATE INDEX IF NOT EXISTS taproot_statement_authorization_candidate_idx
240
+ ON taproot_statement_authorization(entity_id, source_revision, statement_id)`,
241
+ `CREATE INDEX IF NOT EXISTS taproot_authorization_outbox_state_idx
242
+ ON taproot_authorization_projection_outbox(state, authorization_revision, event_id)`,
243
+ `CREATE TRIGGER IF NOT EXISTS taproot_revisions_no_replace
244
+ BEFORE INSERT ON taproot_entity_revisions
245
+ WHEN EXISTS (
246
+ SELECT 1 FROM taproot_entity_revisions
247
+ WHERE entity_id = NEW.entity_id AND revision = NEW.revision
248
+ )
249
+ BEGIN SELECT RAISE(ABORT, 'taproot revisions cannot be replaced'); END`,
250
+ `CREATE TRIGGER IF NOT EXISTS taproot_audit_no_replace
251
+ BEFORE INSERT ON taproot_audit_events
252
+ WHEN EXISTS (
253
+ SELECT 1 FROM taproot_audit_events WHERE event_id = NEW.event_id
254
+ )
255
+ BEGIN SELECT RAISE(ABORT, 'taproot audit events cannot be replaced'); END`,
256
+ `CREATE TRIGGER IF NOT EXISTS taproot_installation_identity_no_update
257
+ BEFORE UPDATE OF installation_id ON taproot_installation_authorization
258
+ BEGIN SELECT RAISE(ABORT, 'taproot installation identity is immutable'); END`,
259
+ `CREATE TRIGGER IF NOT EXISTS taproot_installation_authorization_no_delete
260
+ BEFORE DELETE ON taproot_installation_authorization
261
+ BEGIN SELECT RAISE(ABORT, 'taproot installation authorization is durable'); END`,
262
+ `CREATE TRIGGER IF NOT EXISTS taproot_installation_authorization_no_replace
263
+ BEFORE INSERT ON taproot_installation_authorization
264
+ WHEN EXISTS (SELECT 1 FROM taproot_installation_authorization WHERE singleton = NEW.singleton)
265
+ BEGIN SELECT RAISE(ABORT, 'taproot installation authorization cannot be replaced'); END`,
266
+ `CREATE TRIGGER IF NOT EXISTS taproot_entity_authorization_revisions_no_update
267
+ BEFORE UPDATE ON taproot_entity_authorization_revisions
268
+ BEGIN SELECT RAISE(ABORT, 'taproot authorization revisions are immutable'); END`,
269
+ `CREATE TRIGGER IF NOT EXISTS taproot_entity_authorization_revisions_no_delete
270
+ BEFORE DELETE ON taproot_entity_authorization_revisions
271
+ BEGIN SELECT RAISE(ABORT, 'taproot authorization revisions are immutable'); END`,
272
+ `CREATE TRIGGER IF NOT EXISTS taproot_entity_authorization_revisions_no_replace
273
+ BEFORE INSERT ON taproot_entity_authorization_revisions
274
+ WHEN EXISTS (
275
+ SELECT 1 FROM taproot_entity_authorization_revisions
276
+ WHERE (entity_id = NEW.entity_id AND source_revision = NEW.source_revision)
277
+ OR event_id = NEW.event_id
278
+ )
279
+ BEGIN SELECT RAISE(ABORT, 'taproot authorization revisions cannot be replaced'); END`,
280
+ `CREATE TRIGGER IF NOT EXISTS taproot_statement_authorization_revisions_no_update
281
+ BEFORE UPDATE ON taproot_statement_authorization_revisions
282
+ BEGIN SELECT RAISE(ABORT, 'taproot statement authorization revisions are immutable'); END`,
283
+ `CREATE TRIGGER IF NOT EXISTS taproot_statement_authorization_revisions_no_delete
284
+ BEFORE DELETE ON taproot_statement_authorization_revisions
285
+ BEGIN SELECT RAISE(ABORT, 'taproot statement authorization revisions are immutable'); END`,
286
+ `CREATE TRIGGER IF NOT EXISTS taproot_statement_authorization_revisions_no_replace
287
+ BEFORE INSERT ON taproot_statement_authorization_revisions
288
+ WHEN EXISTS (
289
+ SELECT 1 FROM taproot_statement_authorization_revisions
290
+ WHERE entity_id = NEW.entity_id AND source_revision = NEW.source_revision
291
+ AND statement_id = NEW.statement_id
292
+ )
293
+ BEGIN SELECT RAISE(ABORT, 'taproot statement authorization revisions cannot be replaced'); END`,
294
+ `CREATE TRIGGER IF NOT EXISTS taproot_authorization_admin_audit_no_update
295
+ BEFORE UPDATE ON taproot_authorization_admin_audit
296
+ BEGIN SELECT RAISE(ABORT, 'taproot authorization administration audit is immutable'); END`,
297
+ `CREATE TRIGGER IF NOT EXISTS taproot_authorization_admin_audit_no_delete
298
+ BEFORE DELETE ON taproot_authorization_admin_audit
299
+ BEGIN SELECT RAISE(ABORT, 'taproot authorization administration audit is immutable'); END`,
300
+ `CREATE TRIGGER IF NOT EXISTS taproot_authorization_admin_audit_no_replace
301
+ BEFORE INSERT ON taproot_authorization_admin_audit
302
+ WHEN EXISTS (
303
+ SELECT 1 FROM taproot_authorization_admin_audit
304
+ WHERE sequence = NEW.sequence OR audit_id = NEW.audit_id
305
+ )
306
+ BEGIN SELECT RAISE(ABORT, 'taproot authorization administration audit cannot be replaced'); END`,
307
+ `CREATE TRIGGER IF NOT EXISTS taproot_installation_authorization_advances_no_update
308
+ BEFORE UPDATE ON taproot_installation_authorization_advances
309
+ BEGIN SELECT RAISE(ABORT, 'taproot authorization advances are immutable'); END`,
310
+ `CREATE TRIGGER IF NOT EXISTS taproot_installation_authorization_advances_no_delete
311
+ BEFORE DELETE ON taproot_installation_authorization_advances
312
+ BEGIN SELECT RAISE(ABORT, 'taproot authorization advances are immutable'); END`,
313
+ `CREATE TRIGGER IF NOT EXISTS taproot_installation_authorization_advances_no_replace
314
+ BEFORE INSERT ON taproot_installation_authorization_advances
315
+ WHEN EXISTS (
316
+ SELECT 1 FROM taproot_installation_authorization_advances
317
+ WHERE advance_id = NEW.advance_id
318
+ )
319
+ BEGIN SELECT RAISE(ABORT, 'taproot authorization advances cannot be replaced'); END`,
320
+ `INSERT INTO taproot_metadata(metadata_key, metadata_value)
321
+ VALUES ('schema_version', '${TAPROOT_SCHEMA_VERSION}')
322
+ ON CONFLICT(metadata_key) DO UPDATE SET metadata_value = excluded.metadata_value`,
323
+ `INSERT INTO taproot_migrations(version, name)
324
+ VALUES (4, 'canonical-authorization-policy')
325
+ ON CONFLICT(version) DO NOTHING`,
326
+ ];
327
+ export const taprootSchemaStatements = [
328
+ ...preAuthorizationTaprootSchemaStatements,
329
+ ...taprootAuthorizationSchemaStatements,
330
+ ];
331
+ /** Exact package-created schema before authored statement text became required. */
332
+ export const preStatementTextTaprootSchemaStatements = preAuthorizationTaprootSchemaStatements.map((sql) => sql
333
+ .replace("('canonical_json_version', '2')", "('canonical_json_version', '1')")
334
+ .replace(", (3, 'canonical-statement-text')", ''));
335
+ const schemaStatement = (prefix) => {
336
+ const statement = preAuthorizationTaprootSchemaStatements.find((sql) => sql.trimStart().startsWith(prefix));
337
+ if (!statement)
338
+ throw new Error(`Missing Taproot schema statement: ${prefix}`);
339
+ return statement;
340
+ };
341
+ /** Exact package-created schema used before the v2 audit migration. */
342
+ export const legacyTaprootV1Statements = [
343
+ schemaStatement('CREATE TABLE IF NOT EXISTS taproot_entities'),
344
+ `CREATE TABLE IF NOT EXISTS taproot_entity_revisions (
345
+ entity_id TEXT NOT NULL,
346
+ revision INTEGER NOT NULL,
347
+ entity_json TEXT NOT NULL CHECK (json_valid(entity_json)),
348
+ actor TEXT,
349
+ edit_summary TEXT,
350
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
351
+ PRIMARY KEY (entity_id, revision),
352
+ FOREIGN KEY (entity_id) REFERENCES taproot_entities(entity_id)
353
+ ) STRICT`,
354
+ schemaStatement('CREATE TABLE IF NOT EXISTS taproot_id_counters'),
355
+ schemaStatement('CREATE TABLE IF NOT EXISTS taproot_terms'),
356
+ schemaStatement('CREATE TABLE IF NOT EXISTS taproot_metadata'),
357
+ schemaStatement('CREATE TABLE IF NOT EXISTS taproot_assertions'),
358
+ schemaStatement('CREATE INDEX IF NOT EXISTS taproot_entities_type_idx'),
359
+ schemaStatement('CREATE INDEX IF NOT EXISTS taproot_entities_modified_idx'),
360
+ schemaStatement('CREATE INDEX IF NOT EXISTS taproot_revisions_entity_idx'),
361
+ schemaStatement('CREATE INDEX IF NOT EXISTS taproot_terms_lookup_idx'),
362
+ `INSERT INTO taproot_id_counters(entity_type, next_numeric_id)
363
+ VALUES ('item', 1), ('property', 1)`,
364
+ `INSERT INTO taproot_metadata(metadata_key, metadata_value)
365
+ VALUES
366
+ ('schema_version', '1'),
367
+ ('canonical_json_version', '1'),
368
+ ('rdf_mapping_version', '1')`,
369
+ ];
370
+ const currentRevisionStatement = schemaStatement('CREATE TABLE IF NOT EXISTS taproot_entity_revisions');
371
+ const transitionalRevisionStatement = currentRevisionStatement
372
+ .replace('event_id TEXT NOT NULL', 'event_id TEXT')
373
+ .replace('content_hash TEXT NOT NULL', 'content_hash TEXT');
374
+ export const taprootUpgradeCatalogStatements = preAuthorizationTaprootSchemaStatements
375
+ .filter((sql) => /^\s*CREATE\s+(?:TABLE|INDEX)\s+/iu.test(sql) &&
376
+ !/^\s*CREATE\s+TRIGGER\s+/iu.test(sql) &&
377
+ !sql.includes('taproot_audit'))
378
+ .map((sql) => sql === currentRevisionStatement ? transitionalRevisionStatement : sql);
379
+ export const taprootPreFinalizeCatalogStatements = preAuthorizationTaprootSchemaStatements.filter((sql) => /^\s*CREATE\s+(?:TABLE|INDEX)\s+/iu.test(sql) &&
380
+ !/^\s*CREATE\s+TRIGGER\s+/iu.test(sql));
381
+ export const legacyTaprootStructureStatements = [
382
+ `ALTER TABLE taproot_entity_revisions RENAME TO taproot_entity_revisions_v1`,
383
+ transitionalRevisionStatement,
384
+ `INSERT INTO taproot_entity_revisions(
385
+ entity_id, revision, entity_json, actor, attribution_json, edit_summary,
386
+ tags_json, event_id, content_hash, parent_hash, deleted_at, redirect_to, created_at
387
+ )
388
+ SELECT entity_id, revision, entity_json, actor, NULL, edit_summary,
389
+ '[]', NULL, NULL, NULL, NULL, NULL, created_at
390
+ FROM taproot_entity_revisions_v1`,
391
+ `DROP TABLE taproot_entity_revisions_v1`,
392
+ ...taprootUpgradeCatalogStatements.filter((sql) => !sql.includes('taproot_entity_revisions (')),
393
+ ];
394
+ export const legacyRevisionFinalizeStatements = [
395
+ `ALTER TABLE taproot_entity_revisions RENAME TO taproot_entity_revisions_backfill`,
396
+ currentRevisionStatement,
397
+ `INSERT INTO taproot_entity_revisions(
398
+ entity_id, revision, entity_json, actor, attribution_json, edit_summary,
399
+ tags_json, event_id, content_hash, parent_hash, deleted_at, redirect_to, created_at
400
+ ) SELECT entity_id, revision, entity_json, actor, attribution_json, edit_summary,
401
+ tags_json, event_id, content_hash, parent_hash, deleted_at, redirect_to, created_at
402
+ FROM taproot_entity_revisions_backfill`,
403
+ `DROP TABLE taproot_entity_revisions_backfill`,
404
+ schemaStatement('CREATE TABLE IF NOT EXISTS taproot_audit_events'),
405
+ schemaStatement('CREATE INDEX IF NOT EXISTS taproot_revisions_entity_idx'),
406
+ schemaStatement('CREATE INDEX IF NOT EXISTS taproot_audit_entity_idx'),
407
+ schemaStatement('CREATE INDEX IF NOT EXISTS taproot_audit_request_idx'),
408
+ ];
409
+ export const taprootFinalizeStatements = [
410
+ ...preAuthorizationTaprootSchemaStatements.filter((sql) => /^\s*CREATE\s+TRIGGER\s+/iu.test(sql) ||
411
+ sql.includes(`('schema_version', '${PRE_AUTHORIZATION_SCHEMA_VERSION}')`) ||
412
+ sql.includes('INSERT INTO taproot_migrations')),
413
+ ...taprootAuthorizationSchemaStatements,
414
+ ];
415
+ export async function initializeTaproot(db, options = {}) {
416
+ const { applyTaprootMigrations } = await import('./migrations.js');
417
+ await applyTaprootMigrations(db, options);
418
+ }
419
+ export async function isRecognizedLegacyV1(db, tables) {
420
+ const expectedTables = [
421
+ 'taproot_assertions',
422
+ 'taproot_entities',
423
+ 'taproot_entity_revisions',
424
+ 'taproot_id_counters',
425
+ 'taproot_metadata',
426
+ 'taproot_terms',
427
+ ];
428
+ if (JSON.stringify(tables.map(({ name }) => name)) !==
429
+ JSON.stringify(expectedTables))
430
+ return false;
431
+ if (!(await matchesExactCatalog(db, legacyTaprootV1Statements)))
432
+ return false;
433
+ const metadata = await db
434
+ .prepare(`SELECT metadata_key, metadata_value FROM taproot_metadata
435
+ WHERE metadata_key IN ('schema_version', 'canonical_json_version', 'rdf_mapping_version')
436
+ ORDER BY metadata_key`)
437
+ .all();
438
+ return (JSON.stringify(metadata.results) ===
439
+ JSON.stringify([
440
+ { metadata_key: 'canonical_json_version', metadata_value: '1' },
441
+ { metadata_key: 'rdf_mapping_version', metadata_value: '1' },
442
+ { metadata_key: 'schema_version', metadata_value: '1' },
443
+ ]));
151
444
  }
152
445
  async function readMetadata(db, key) {
153
446
  const table = await db
@@ -161,7 +454,7 @@ async function readMetadata(db, key) {
161
454
  .all();
162
455
  return result.results[0]?.metadata_value;
163
456
  }
164
- async function backfillRdfOwnership(db, previousVersion) {
457
+ export async function backfillRdfOwnership(db, previousVersion, previousBaseIri) {
165
458
  const rows = await db
166
459
  .prepare(`SELECT entity_id, entity_json, deleted_at, redirect_to FROM taproot_entities ORDER BY entity_id`)
167
460
  .all();
@@ -169,20 +462,14 @@ async function backfillRdfOwnership(db, previousVersion) {
169
462
  return;
170
463
  const baseIri = await readMetadata(db, 'base_iri');
171
464
  if (!baseIri)
172
- return;
465
+ throw new SchemaMismatchError('Taproot RDF ownership cannot be rebuilt without a database identity');
173
466
  const factory = new DataFactory();
174
467
  for (const row of rows.results) {
175
468
  const entity = parseEntityJson(row.entity_json);
176
469
  const current = lifecycleQuads(entity, row.deleted_at, row.redirect_to, baseIri, TAPROOT_RDF_VERSION, factory);
177
- const existingOwnership = await db
178
- .prepare(`SELECT COUNT(*) AS count FROM taproot_rdf_ownership WHERE entity_id = ?`)
179
- .bind(row.entity_id)
180
- .all();
181
- if (previousVersion === TAPROOT_RDF_VERSION &&
182
- Number(existingOwnership.results[0]?.count ?? 0) > 0)
183
- continue;
184
- const old = previousVersion && previousVersion !== TAPROOT_RDF_VERSION
185
- ? lifecycleQuads(entity, row.deleted_at, row.redirect_to, baseIri, previousVersion, factory)
470
+ const old = previousVersion &&
471
+ (previousVersion !== TAPROOT_RDF_VERSION || previousBaseIri !== baseIri)
472
+ ? lifecycleQuads(entity, row.deleted_at, row.redirect_to, previousBaseIri ?? baseIri, previousVersion, factory)
186
473
  : [];
187
474
  const patch = prepareQuadPatch(db, { delete: old, insert: current });
188
475
  const ownershipRows = current.map((quad) => ({
@@ -207,7 +494,10 @@ async function backfillRdfOwnership(db, previousVersion) {
207
494
  function lifecycleQuads(entity, deletedAt, redirectTo, baseIri, mappingVersion, factory) {
208
495
  if (!deletedAt && !redirectTo)
209
496
  return buildEntityQuads(entity, { baseIri, mappingVersion, factory });
210
- const base = baseIri.replace(/\/+$/u, '');
497
+ let end = baseIri.length;
498
+ while (end > 0 && baseIri.charCodeAt(end - 1) === 47)
499
+ end -= 1;
500
+ const base = baseIri.slice(0, end);
211
501
  const subject = factory.namedNode(`${base}/entity/${entity.id}`);
212
502
  const quads = [
213
503
  factory.quad(subject, factory.namedNode(`${base}/vocab/revision`), factory.literal(String(entity.lastrevid), factory.namedNode('http://www.w3.org/2001/XMLSchema#integer'))),
@@ -218,35 +508,7 @@ function lifecycleQuads(entity, deletedAt, redirectTo, baseIri, mappingVersion,
218
508
  quads.push(factory.quad(subject, factory.namedNode('http://www.w3.org/2002/07/owl#sameAs'), factory.namedNode(`${base}/entity/${redirectTo}`)));
219
509
  return quads;
220
510
  }
221
- async function upgradeLegacySchema(db) {
222
- const tables = await db
223
- .prepare(`SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'taproot_entity_revisions'`)
224
- .all();
225
- if (!tables.results.length)
226
- return;
227
- const columns = await db
228
- .prepare(`PRAGMA table_info(taproot_entity_revisions)`)
229
- .all();
230
- const names = new Set(columns.results.map(({ name }) => name));
231
- const additions = [
232
- [
233
- 'attribution_json',
234
- `TEXT CHECK (attribution_json IS NULL OR json_valid(attribution_json))`,
235
- ],
236
- ['tags_json', `TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(tags_json))`],
237
- ['event_id', `TEXT`],
238
- ['content_hash', `TEXT`],
239
- ['parent_hash', `TEXT`],
240
- ['deleted_at', `TEXT`],
241
- ['redirect_to', `TEXT`],
242
- ];
243
- for (const [name, definition] of additions) {
244
- if (!names.has(name)) {
245
- await db
246
- .prepare(`ALTER TABLE taproot_entity_revisions ADD COLUMN ${name} ${definition}`)
247
- .run();
248
- }
249
- }
511
+ export async function backfillLegacyRevisions(db) {
250
512
  const revisions = await db
251
513
  .prepare(`SELECT r.entity_id, r.revision, r.entity_json, r.actor, r.event_id, r.content_hash,
252
514
  CASE WHEN r.revision = e.revision THEN e.deleted_at ELSE r.deleted_at END AS deleted_at,
@@ -259,25 +521,195 @@ async function upgradeLegacySchema(db) {
259
521
  for (const revision of revisions.results) {
260
522
  if (revision.entity_id !== previousEntity)
261
523
  parentHash = null;
262
- const contentHash = revision.content_hash ??
263
- (await hash(`${revision.entity_json}\n${JSON.stringify({ deletedAt: revision.deleted_at, redirectTo: revision.redirect_to })}`));
524
+ const contentHash = await hash(`${revision.entity_json}\n${JSON.stringify({ deletedAt: revision.deleted_at, redirectTo: revision.redirect_to })}`);
525
+ const eventId = `legacy-${revision.entity_id}-${revision.revision}`;
526
+ if ((revision.event_id !== null && revision.event_id !== eventId) ||
527
+ (revision.content_hash !== null && revision.content_hash !== contentHash)) {
528
+ throw new SchemaMismatchError(`Legacy revision ${revision.entity_id}@${revision.revision} has conflicting durable identity`);
529
+ }
264
530
  if (!revision.event_id || !revision.content_hash) {
265
531
  const attribution = revision.actor
266
532
  ? JSON.stringify({ id: revision.actor, kind: 'human' })
267
533
  : null;
268
- await db
269
- .prepare(`UPDATE taproot_entity_revisions SET event_id = ?, content_hash = ?,
534
+ await db.batch([
535
+ db
536
+ .prepare(`UPDATE taproot_entity_revisions SET event_id = ?, content_hash = ?,
270
537
  parent_hash = ?, attribution_json = COALESCE(attribution_json, ?),
271
538
  tags_json = COALESCE(tags_json, '[]'), deleted_at = ?, redirect_to = ?
272
539
  WHERE entity_id = ? AND revision = ?`)
273
- .bind(revision.event_id ??
274
- `legacy-${revision.entity_id}-${revision.revision}`, contentHash, parentHash, attribution, revision.deleted_at, revision.redirect_to, revision.entity_id, revision.revision)
275
- .run();
540
+ .bind(eventId, contentHash, parentHash, attribution, revision.deleted_at, revision.redirect_to, revision.entity_id, revision.revision),
541
+ ]);
542
+ }
543
+ else {
544
+ const storedParent = await db
545
+ .prepare(`SELECT parent_hash FROM taproot_entity_revisions
546
+ WHERE entity_id = ? AND revision = ?`)
547
+ .bind(revision.entity_id, revision.revision)
548
+ .all();
549
+ if (storedParent.results[0]?.parent_hash !== parentHash)
550
+ throw new SchemaMismatchError(`Legacy revision ${revision.entity_id}@${revision.revision} has a conflicting parent hash`);
276
551
  }
277
552
  previousEntity = revision.entity_id;
278
553
  parentHash = contentHash;
279
554
  }
280
555
  }
556
+ export async function backfillTaprootAudit(db) {
557
+ await db.batch([
558
+ db.prepare(`INSERT INTO taproot_audit_events(
559
+ event_id, entity_id, revision, event_type, attribution_json,
560
+ edit_summary, tags_json, content_hash, parent_hash, details_json, created_at
561
+ )
562
+ SELECT event_id, entity_id, revision, 'import',
563
+ attribution_json, edit_summary, tags_json, content_hash, parent_hash,
564
+ json_object('source', 'legacy-v1'), created_at
565
+ FROM taproot_entity_revisions revision
566
+ WHERE NOT EXISTS (
567
+ SELECT 1 FROM taproot_audit_events audit
568
+ WHERE audit.event_id = revision.event_id
569
+ )`),
570
+ ]);
571
+ }
572
+ export async function verifyTaprootSemanticState(db, baseIri) {
573
+ const incomplete = await db
574
+ .prepare(`SELECT COUNT(*) AS count
575
+ FROM taproot_entity_revisions r
576
+ LEFT JOIN taproot_audit_events a ON a.event_id = r.event_id
577
+ WHERE r.event_id IS NULL OR r.content_hash IS NULL OR a.event_id IS NULL
578
+ OR a.entity_id IS NOT r.entity_id OR a.revision IS NOT r.revision
579
+ OR a.content_hash IS NOT r.content_hash
580
+ OR a.parent_hash IS NOT r.parent_hash
581
+ OR a.attribution_json IS NOT r.attribution_json
582
+ OR a.edit_summary IS NOT r.edit_summary
583
+ OR a.tags_json IS NOT r.tags_json
584
+ OR a.created_at IS NOT r.created_at`)
585
+ .all();
586
+ if (Number(incomplete.results[0]?.count ?? 0) !== 0)
587
+ throw new SchemaMismatchError('Taproot revision identity or audit backfill is incomplete');
588
+ const dangling = await db
589
+ .prepare(`SELECT COUNT(*) AS count
590
+ FROM taproot_rdf_ownership o
591
+ LEFT JOIN taproot_entities e ON e.entity_id = o.entity_id
592
+ WHERE e.entity_id IS NULL`)
593
+ .all();
594
+ if (Number(dangling.results[0]?.count ?? 0) !== 0)
595
+ throw new SchemaMismatchError('Taproot RDF ownership contains dangling rows');
596
+ const missingProjection = await db
597
+ .prepare(`SELECT COUNT(*) AS count
598
+ FROM taproot_rdf_ownership o
599
+ LEFT JOIN rdf_quads q
600
+ ON q.subject_key = o.subject_key
601
+ AND q.predicate_key = o.predicate_key
602
+ AND q.object_key = o.object_key
603
+ AND q.graph_key = o.graph_key
604
+ WHERE q.id IS NULL`)
605
+ .all();
606
+ if (Number(missingProjection.results[0]?.count ?? 0) !== 0)
607
+ throw new SchemaMismatchError('Taproot RDF ownership is missing its Diamond quad projection');
608
+ const rows = await db
609
+ .prepare(`SELECT entity_id, entity_json, deleted_at, redirect_to
610
+ FROM taproot_entities ORDER BY entity_id`)
611
+ .all();
612
+ const factory = new DataFactory();
613
+ for (const row of rows.results) {
614
+ const expected = lifecycleQuads(parseEntityJson(row.entity_json), row.deleted_at, row.redirect_to, baseIri, TAPROOT_RDF_VERSION, factory)
615
+ .map((quad) => [
616
+ encodeTerm(quad.subject).key,
617
+ encodeTerm(quad.predicate).key,
618
+ encodeTerm(quad.object).key,
619
+ encodeTerm(quad.graph).key,
620
+ ].join('\u0000'))
621
+ .sort();
622
+ const actual = await db
623
+ .prepare(`SELECT subject_key, predicate_key, object_key, graph_key
624
+ FROM taproot_rdf_ownership WHERE entity_id = ?
625
+ ORDER BY subject_key, predicate_key, object_key, graph_key`)
626
+ .bind(row.entity_id)
627
+ .all();
628
+ const keys = actual.results
629
+ .map(({ subject_key, predicate_key, object_key, graph_key }) => [subject_key, predicate_key, object_key, graph_key].join('\u0000'))
630
+ .sort();
631
+ if (JSON.stringify(keys) !== JSON.stringify(expected))
632
+ throw new SchemaMismatchError(`Taproot RDF ownership backfill is incomplete for ${row.entity_id}`);
633
+ }
634
+ }
635
+ /** Uses JavaScript trim semantics, including Unicode whitespace, for parity with runtime validation. */
636
+ export const PERSISTED_STATEMENT_TEXT_PAGE_SIZE = 100;
637
+ export async function verifyPersistedStatementText(db) {
638
+ let currentEntityId = '';
639
+ while (true) {
640
+ const page = await db
641
+ .prepare(`/* taproot:statement-text-current-page */
642
+ SELECT entity_id, revision, entity_json
643
+ FROM taproot_entities
644
+ WHERE entity_id > ?
645
+ ORDER BY entity_id
646
+ LIMIT ?`)
647
+ .bind(currentEntityId, PERSISTED_STATEMENT_TEXT_PAGE_SIZE)
648
+ .all();
649
+ for (const row of page.results)
650
+ validatePersistedStatementText(row, 'current');
651
+ if (page.results.length < PERSISTED_STATEMENT_TEXT_PAGE_SIZE)
652
+ break;
653
+ currentEntityId = page.results.at(-1)
654
+ .entity_id;
655
+ }
656
+ let historicalEntityId = '';
657
+ let historicalRevision = -1;
658
+ while (true) {
659
+ const page = await db
660
+ .prepare(`/* taproot:statement-text-history-page */
661
+ SELECT entity_id, revision, entity_json
662
+ FROM taproot_entity_revisions
663
+ WHERE entity_id > ? OR (entity_id = ? AND revision > ?)
664
+ ORDER BY entity_id, revision
665
+ LIMIT ?`)
666
+ .bind(historicalEntityId, historicalEntityId, historicalRevision, PERSISTED_STATEMENT_TEXT_PAGE_SIZE)
667
+ .all();
668
+ for (const row of page.results)
669
+ validatePersistedStatementText(row, 'historical');
670
+ if (page.results.length < PERSISTED_STATEMENT_TEXT_PAGE_SIZE)
671
+ break;
672
+ const last = page.results.at(-1);
673
+ historicalEntityId = last.entity_id;
674
+ historicalRevision = last.revision;
675
+ }
676
+ }
677
+ function validatePersistedStatementText(row, source) {
678
+ let entity;
679
+ try {
680
+ entity = JSON.parse(row.entity_json);
681
+ }
682
+ catch (cause) {
683
+ throw new SchemaMismatchError(`Taproot ${source} entity ${row.entity_id}@${row.revision} is not valid JSON`, { cause });
684
+ }
685
+ if (!isRecord(entity) || !isRecord(entity.claims))
686
+ throw new SchemaMismatchError(`Taproot ${source} entity ${row.entity_id}@${row.revision} has invalid claims`);
687
+ for (const statements of Object.values(entity.claims)) {
688
+ if (!Array.isArray(statements))
689
+ throw new SchemaMismatchError(`Taproot ${source} entity ${row.entity_id}@${row.revision} has an invalid claim group`);
690
+ for (const statement of statements) {
691
+ if (!isRecord(statement) ||
692
+ typeof statement.text !== 'string' ||
693
+ statement.text.trim().length === 0)
694
+ throw new SchemaMismatchError(`Taproot ${source} statement text is missing or blank at ${row.entity_id}@${row.revision}`);
695
+ }
696
+ }
697
+ }
698
+ export async function verifyTaprootPackageSeeds(db) {
699
+ const migrationSeeds = await db
700
+ .prepare(`SELECT COUNT(*) AS count FROM taproot_migrations
701
+ WHERE (version = 1 AND name = 'initial')
702
+ OR (version = 2 AND name = 'audit-and-operations')
703
+ OR (version = 3 AND name = 'canonical-statement-text')
704
+ OR (version = 4 AND name = 'canonical-authorization-policy')`)
705
+ .all();
706
+ const migrationSeedTotal = await db
707
+ .prepare(`SELECT COUNT(*) AS count FROM taproot_migrations`)
708
+ .all();
709
+ if (Number(migrationSeeds.results[0]?.count ?? 0) !== 4 ||
710
+ Number(migrationSeedTotal.results[0]?.count ?? 0) !== 4)
711
+ throw new SchemaMismatchError('Taproot package migration seeds are incomplete');
712
+ }
281
713
  async function hash(value) {
282
714
  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
283
715
  return [...new Uint8Array(digest)]
@@ -295,6 +727,15 @@ export async function inspectTaprootSchema(db) {
295
727
  'taproot_audit_events',
296
728
  'taproot_migrations',
297
729
  'taproot_rdf_ownership',
730
+ 'taproot_installation_authorization',
731
+ 'taproot_entity_authorization',
732
+ 'taproot_entity_authorization_revisions',
733
+ 'taproot_statement_authorization',
734
+ 'taproot_statement_authorization_revisions',
735
+ 'taproot_authorization_projection_outbox',
736
+ 'taproot_authorization_backfill_plans',
737
+ 'taproot_authorization_admin_audit',
738
+ 'taproot_installation_authorization_advances',
298
739
  ];
299
740
  const tables = await db
300
741
  .prepare(`SELECT name, sql FROM sqlite_schema
@@ -337,6 +778,120 @@ export async function inspectTaprootSchema(db) {
337
778
  : { results: [] };
338
779
  const presentColumns = new Set(revisionColumns.results.map(({ name }) => name));
339
780
  const missingColumns = requiredRevisionColumns.filter((name) => !presentColumns.has(name));
781
+ const authorizationColumns = {
782
+ taproot_installation_authorization: [
783
+ 'singleton',
784
+ 'installation_id',
785
+ 'authorization_revision',
786
+ 'search_generation',
787
+ 'last_advance_id',
788
+ 'created_at',
789
+ 'updated_at',
790
+ ],
791
+ taproot_entity_authorization: [
792
+ 'entity_id',
793
+ 'installation_id',
794
+ 'workspace_id',
795
+ 'owner_principal_id',
796
+ 'visibility_json',
797
+ 'effective_visibility_json',
798
+ 'source_revision',
799
+ 'authorization_revision',
800
+ 'deleted_at',
801
+ 'event_id',
802
+ 'updated_at',
803
+ ],
804
+ taproot_entity_authorization_revisions: [
805
+ 'entity_id',
806
+ 'source_revision',
807
+ 'installation_id',
808
+ 'workspace_id',
809
+ 'owner_principal_id',
810
+ 'visibility_json',
811
+ 'effective_visibility_json',
812
+ 'authorization_revision',
813
+ 'deleted_at',
814
+ 'event_id',
815
+ 'created_at',
816
+ ],
817
+ taproot_statement_authorization: [
818
+ 'entity_id',
819
+ 'statement_id',
820
+ 'source_revision',
821
+ 'restrictions_json',
822
+ 'effective_visibility_json',
823
+ 'authorization_revision',
824
+ ],
825
+ taproot_statement_authorization_revisions: [
826
+ 'entity_id',
827
+ 'source_revision',
828
+ 'statement_id',
829
+ 'restrictions_json',
830
+ 'effective_visibility_json',
831
+ 'authorization_revision',
832
+ ],
833
+ taproot_authorization_projection_outbox: [
834
+ 'event_id',
835
+ 'entity_id',
836
+ 'source_revision',
837
+ 'authorization_revision',
838
+ 'search_generation',
839
+ 'operation',
840
+ 'state',
841
+ 'created_at',
842
+ ],
843
+ taproot_authorization_backfill_plans: [
844
+ 'plan_id',
845
+ 'installation_id',
846
+ 'base_authorization_revision',
847
+ 'manifest_json',
848
+ 'manifest_hash',
849
+ 'entity_count',
850
+ 'revision_count',
851
+ 'status',
852
+ 'created_by',
853
+ 'created_at',
854
+ 'completed_at',
855
+ ],
856
+ taproot_authorization_admin_audit: [
857
+ 'sequence',
858
+ 'audit_id',
859
+ 'event_type',
860
+ 'principal_id',
861
+ 'plan_id',
862
+ 'authorization_revision',
863
+ 'details_json',
864
+ 'created_at',
865
+ ],
866
+ taproot_installation_authorization_advances: [
867
+ 'advance_id',
868
+ 'installation_id',
869
+ 'from_revision',
870
+ 'to_revision',
871
+ 'search_generation',
872
+ 'domain',
873
+ 'principal_id',
874
+ 'reason',
875
+ 'created_at',
876
+ ],
877
+ };
878
+ for (const [table, expectedColumns] of Object.entries(authorizationColumns)) {
879
+ if (!names.has(table))
880
+ continue;
881
+ const columns = await db
882
+ .prepare(`PRAGMA table_info(${table})`)
883
+ .all();
884
+ const actualColumns = columns.results.map(({ name }) => name);
885
+ if (JSON.stringify(actualColumns) !== JSON.stringify(expectedColumns)) {
886
+ errors.push(`${table} columns are ${actualColumns.join(',')}, expected ${expectedColumns.join(',')}`);
887
+ }
888
+ const actualSql = tables.results.find(({ name }) => name === table)?.sql;
889
+ const expectedSql = taprootAuthorizationSchemaStatements.find((sql) => new RegExp(`^\\s*CREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${table}\\b`, 'iu').test(sql));
890
+ if (actualSql == null ||
891
+ expectedSql === undefined ||
892
+ normalizeCatalogSql(actualSql) !== normalizeCatalogSql(expectedSql))
893
+ errors.push(`${table} definition does not match the package catalog`);
894
+ }
340
895
  const requiredIndexes = [
341
896
  'taproot_entities_type_idx',
342
897
  'taproot_entities_modified_idx',
@@ -345,6 +900,10 @@ export async function inspectTaprootSchema(db) {
345
900
  'taproot_audit_entity_idx',
346
901
  'taproot_audit_request_idx',
347
902
  'taproot_rdf_ownership_quad_idx',
903
+ 'taproot_entity_authorization_candidate_idx',
904
+ 'taproot_entity_authorization_revision_idx',
905
+ 'taproot_statement_authorization_candidate_idx',
906
+ 'taproot_authorization_outbox_state_idx',
348
907
  ];
349
908
  const indexRows = await db
350
909
  .prepare(`SELECT name FROM sqlite_schema WHERE type = 'index' AND name LIKE 'taproot_%'`)
@@ -356,12 +915,39 @@ export async function inspectTaprootSchema(db) {
356
915
  'taproot_revisions_no_delete',
357
916
  'taproot_audit_no_update',
358
917
  'taproot_audit_no_delete',
918
+ 'taproot_revisions_no_replace',
919
+ 'taproot_audit_no_replace',
920
+ 'taproot_installation_identity_no_update',
921
+ 'taproot_installation_authorization_no_delete',
922
+ 'taproot_installation_authorization_no_replace',
923
+ 'taproot_entity_authorization_revisions_no_update',
924
+ 'taproot_entity_authorization_revisions_no_delete',
925
+ 'taproot_entity_authorization_revisions_no_replace',
926
+ 'taproot_statement_authorization_revisions_no_update',
927
+ 'taproot_statement_authorization_revisions_no_delete',
928
+ 'taproot_statement_authorization_revisions_no_replace',
929
+ 'taproot_authorization_admin_audit_no_update',
930
+ 'taproot_authorization_admin_audit_no_delete',
931
+ 'taproot_authorization_admin_audit_no_replace',
932
+ 'taproot_installation_authorization_advances_no_update',
933
+ 'taproot_installation_authorization_advances_no_delete',
934
+ 'taproot_installation_authorization_advances_no_replace',
359
935
  ];
360
936
  const triggerRows = await db
361
- .prepare(`SELECT name FROM sqlite_schema WHERE type = 'trigger' AND name LIKE 'taproot_%'`)
937
+ .prepare(`SELECT name, sql FROM sqlite_schema
938
+ WHERE type = 'trigger' AND name LIKE 'taproot_%'`)
362
939
  .all();
363
940
  const presentTriggers = new Set(triggerRows.results.map(({ name }) => name));
364
941
  const missingTriggers = requiredTriggers.filter((name) => !presentTriggers.has(name));
942
+ for (const trigger of requiredTriggers) {
943
+ const actualSql = triggerRows.results.find(({ name }) => name === trigger)?.sql;
944
+ if (actualSql == null)
945
+ continue;
946
+ const expectedSql = taprootSchemaStatements.find((sql) => new RegExp(`^\\s*CREATE\\s+TRIGGER\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${trigger}\\b`, 'iu').test(sql));
947
+ if (expectedSql === undefined ||
948
+ normalizeCatalogSql(actualSql) !== normalizeCatalogSql(expectedSql))
949
+ errors.push(`${trigger} definition does not match the package catalog`);
950
+ }
365
951
  errors.push(...missingColumns.map((name) => `taproot_entity_revisions.${name} is missing`));
366
952
  errors.push(...missingIndexes.map((name) => `${name} is missing`));
367
953
  errors.push(...missingTriggers.map((name) => `${name} is missing`));
@@ -381,6 +967,59 @@ export async function inspectTaprootSchema(db) {
381
967
  errors,
382
968
  };
383
969
  }
970
+ /**
971
+ * Verify the exact package-owned catalog before adopting a current pre-ledger
972
+ * database. This is intentionally stricter than the operational inspection:
973
+ * names alone must never authorize stamping an arbitrary look-alike schema.
974
+ */
975
+ export async function isExactTaprootSchema(db) {
976
+ return matchesExactCatalog(db, taprootSchemaStatements);
977
+ }
978
+ /** Exact package catalog immediately before canonical authorization migration 0004. */
979
+ export async function isExactPreAuthorizationTaprootSchema(db) {
980
+ return matchesExactCatalog(db, preAuthorizationTaprootSchemaStatements);
981
+ }
982
+ export async function isExactTaprootUpgradeSchema(db) {
983
+ return matchesExactCatalog(db, taprootUpgradeCatalogStatements);
984
+ }
985
+ export async function isExactTaprootPreFinalizeSchema(db) {
986
+ return matchesExactCatalog(db, taprootPreFinalizeCatalogStatements);
987
+ }
988
+ function isRecord(value) {
989
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
990
+ }
991
+ async function matchesExactCatalog(db, statements) {
992
+ const expected = new Map();
993
+ for (const sql of statements) {
994
+ const match = /^\s*CREATE\s+(TABLE|INDEX|TRIGGER)\s+(?:IF\s+NOT\s+EXISTS\s+)?([a-z0-9_]+)/iu.exec(sql);
995
+ if (!match?.[1] || !match[2])
996
+ continue;
997
+ expected.set(`${match[1].toLowerCase()}:${match[2]}`, normalizeCatalogSql(sql));
998
+ }
999
+ const catalog = await db
1000
+ .prepare(`SELECT type, name, sql FROM sqlite_schema
1001
+ WHERE name LIKE 'taproot_%' AND type IN ('table', 'index', 'trigger')
1002
+ ORDER BY type, name`)
1003
+ .all();
1004
+ if (catalog.results.length !== expected.size)
1005
+ return false;
1006
+ for (const entry of catalog.results) {
1007
+ const expectedSql = expected.get(`${entry.type}:${entry.name}`);
1008
+ if (expectedSql === undefined ||
1009
+ entry.sql === null ||
1010
+ normalizeCatalogSql(entry.sql) !== expectedSql)
1011
+ return false;
1012
+ }
1013
+ return true;
1014
+ }
1015
+ function normalizeCatalogSql(sql) {
1016
+ return sql
1017
+ .replace(/\bIF\s+NOT\s+EXISTS\s+/giu, '')
1018
+ .replace(/\s+/gu, ' ')
1019
+ .replace(/;\s*$/u, '')
1020
+ .trim()
1021
+ .toLowerCase();
1022
+ }
384
1023
  export async function assertTaprootSchema(db) {
385
1024
  const inspection = await inspectTaprootSchema(db);
386
1025
  if (!inspection.valid) {