@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
@@ -1,10 +1,13 @@
1
1
  import { QuadPatchConflictError, encodeTerm, decodeTerm, prepareQuadPatch, } from '@gnolith/diamond';
2
2
  import { DataFactory } from 'rdf-data-factory';
3
- import { cloneEntity, entityNumericId, exportEntityJson, MAX_ENTITY_BYTES, parseEntityJson, validateEntity, validateSnak, } from './canonical.js';
4
- import { EntityAlreadyExistsError, EntityNotFoundError, InvalidEntityError, InvalidStatementError, InvalidCursorError, BulkLimitError, PropertyDatatypeMismatchError, PropertyNotFoundError, QuadPatchTooLargeError, RevisionConflictError, SchemaMismatchError, } from './errors.js';
3
+ import { cloneEntity, assertAuthoredStatementText, entityNumericId, exportEntityJson, MAX_ENTITY_BYTES, parseEntityJson, validateEntity, validateSnak, } from './canonical.js';
4
+ import { EntityAlreadyExistsError, AuthorizationDeniedError, EntityNotFoundError, InvalidEntityError, InvalidStatementError, InvalidCursorError, BulkLimitError, PropertyDatatypeMismatchError, PropertyNotFoundError, QuadPatchTooLargeError, RevisionConflictError, SchemaMismatchError, } from './errors.js';
5
5
  import { buildEntityQuads } from './rdf.js';
6
6
  import { withoutTrailingSlashes } from './iri.js';
7
+ import { canonicalizeTaprootBaseIri } from './migrations.js';
7
8
  import { TAPROOT_RDF_VERSION } from './schema.js';
9
+ import { intersectVisibilityScopes, isVisibleTo, normalizeCanonicalAuthorizationPolicy, normalizeAuthorizationContext, normalizeVisibilityScope, serializeVisibilityScope, KNOWLEDGE_POLICY_CAPABILITY, KNOWLEDGE_WRITE_CAPABILITY, } from './authorization.js';
10
+ /** Package-internal persistence implementation. Not exported by the package. */
8
11
  export class TaprootRepository {
9
12
  #db;
10
13
  #options;
@@ -13,19 +16,10 @@ export class TaprootRepository {
13
16
  options.mappingVersion !== TAPROOT_RDF_VERSION) {
14
17
  throw new SchemaMismatchError(`RDF mapping version ${options.mappingVersion} is not supported`);
15
18
  }
16
- try {
17
- const base = new URL(options.baseIri);
18
- if (base.protocol !== 'http:' && base.protocol !== 'https:')
19
- throw new TypeError();
20
- }
21
- catch (cause) {
22
- throw new InvalidEntityError('baseIri must be an absolute HTTP(S) IRI', {
23
- cause,
24
- });
25
- }
19
+ const baseIri = canonicalizeTaprootBaseIri(options.baseIri);
26
20
  this.#db = db;
27
21
  this.#options = {
28
- baseIri: options.baseIri,
22
+ baseIri,
29
23
  mappingVersion: options.mappingVersion ?? TAPROOT_RDF_VERSION,
30
24
  maxEntityBytes: options.maxEntityBytes ?? MAX_ENTITY_BYTES,
31
25
  maxBulkEntities: options.maxBulkEntities ?? 100,
@@ -37,6 +31,79 @@ export class TaprootRepository {
37
31
  ...(options.factory ? { factory: options.factory } : {}),
38
32
  };
39
33
  }
34
+ /** Assembly-only pristine bootstrap. Canonical writes remain policy-bound. */
35
+ async bootstrapAuthorization(installationId) {
36
+ if (typeof installationId !== 'string' ||
37
+ !installationId.trim() ||
38
+ installationId.length > 256)
39
+ throw new InvalidEntityError('installationId must contain 1 through 256 characters');
40
+ const createdAt = this.#options.clock().toISOString();
41
+ try {
42
+ await this.#db.batch([
43
+ this.#assertion(`NOT EXISTS (SELECT 1 FROM taproot_entities)`),
44
+ this.#db
45
+ .prepare(`INSERT INTO taproot_installation_authorization(
46
+ singleton, installation_id, authorization_revision,
47
+ search_generation, last_advance_id, created_at, updated_at
48
+ ) VALUES (1, ?, 1, 1, 'bootstrap', ?, ?)`)
49
+ .bind(installationId, createdAt, createdAt),
50
+ ]);
51
+ }
52
+ catch (cause) {
53
+ throw new RevisionConflictError('Authorization bootstrap requires a pristine uninitialized installation', { cause: toError(cause) });
54
+ }
55
+ }
56
+ /** Explicit bootstrap for a fully quarantined pre-0004 installation. */
57
+ async bootstrapLegacyAuthorization(installationId, attestation) {
58
+ if (typeof installationId !== 'string' ||
59
+ !installationId.trim() ||
60
+ installationId.length > 256)
61
+ throw new InvalidEntityError('installationId must contain 1 through 256 characters');
62
+ if (!Number.isSafeInteger(attestation.entityCount) ||
63
+ attestation.entityCount < 1 ||
64
+ !Number.isSafeInteger(attestation.revisionCount) ||
65
+ attestation.revisionCount < attestation.entityCount ||
66
+ !/^[a-f0-9]{64}$/u.test(attestation.revisionManifestHash))
67
+ throw new InvalidEntityError('Legacy authorization attestation is invalid');
68
+ const counts = await this.#db
69
+ .prepare(`SELECT
70
+ (SELECT COUNT(*) FROM taproot_entities) AS entity_count,
71
+ (SELECT COUNT(*) FROM taproot_entity_revisions) AS revision_count,
72
+ (SELECT COUNT(*) FROM taproot_entity_authorization) AS policy_count`)
73
+ .all();
74
+ const count = counts.results[0];
75
+ const revisions = await this.#db
76
+ .prepare(`SELECT entity_id, revision, content_hash
77
+ FROM taproot_entity_revisions ORDER BY entity_id, revision`)
78
+ .all();
79
+ const manifestHash = await sha256(JSON.stringify(revisions.results.map(({ entity_id, revision, content_hash }) => [
80
+ entity_id,
81
+ Number(revision),
82
+ content_hash,
83
+ ])));
84
+ if (Number(count?.entity_count ?? 0) !== attestation.entityCount ||
85
+ Number(count?.revision_count ?? 0) !== attestation.revisionCount ||
86
+ Number(count?.policy_count ?? 0) !== 0 ||
87
+ manifestHash !== attestation.revisionManifestHash)
88
+ throw new RevisionConflictError('Legacy authorization attestation does not match quarantined canonical state');
89
+ const createdAt = this.#options.clock().toISOString();
90
+ try {
91
+ await this.#db.batch([
92
+ this.#assertion(`(SELECT COUNT(*) FROM taproot_entities) = ?
93
+ AND (SELECT COUNT(*) FROM taproot_entity_revisions) = ?
94
+ AND NOT EXISTS (SELECT 1 FROM taproot_entity_authorization)`, attestation.entityCount, attestation.revisionCount),
95
+ this.#db
96
+ .prepare(`INSERT INTO taproot_installation_authorization(
97
+ singleton, installation_id, authorization_revision,
98
+ search_generation, last_advance_id, created_at, updated_at
99
+ ) VALUES (1, ?, 1, 1, 'legacy-bootstrap', ?, ?)`)
100
+ .bind(installationId, createdAt, createdAt),
101
+ ]);
102
+ }
103
+ catch (cause) {
104
+ throw new RevisionConflictError('Legacy authorization bootstrap lost its state attestation race', { cause: toError(cause) });
105
+ }
106
+ }
40
107
  async getEntity(id) {
41
108
  const row = await this.#loadRow(id);
42
109
  if (!row)
@@ -273,11 +340,15 @@ export class TaprootRepository {
273
340
  async replaceEntity(id, replacement, edit) {
274
341
  if (replacement.id !== id)
275
342
  throw new InvalidEntityError('Replacement entity id cannot change');
276
- return this.#mutate(id, edit, () => cloneEntity(replacement), undefined, 'update');
343
+ return this.#mutate(id, edit, () => resupplyStatementTexts(cloneEntity(replacement), edit.statementTexts), undefined, 'update');
277
344
  }
278
345
  async revertEntity(id, targetRevision, edit) {
346
+ if (edit.authorization || edit.authorizationContext)
347
+ await this.#preauthorizeCanonicalWrite(id, edit.authorization, edit.authorizationContext, edit.expectedRevision);
279
348
  const target = await this.getEntityRevision(id, targetRevision);
280
- return this.#mutate(id, edit, () => cloneEntity(target.entity), { deletedAt: target.deletedAt, redirectTo: target.redirectTo }, 'revert');
349
+ if (target.redirectTo)
350
+ await this.#validateRedirectTarget(id, target.redirectTo, target.entity.type, edit.authorizationContext);
351
+ return this.#mutate(id, edit, () => resupplyStatementTexts(cloneEntity(target.entity), edit.statementTexts), { deletedAt: target.deletedAt, redirectTo: target.redirectTo }, 'revert');
281
352
  }
282
353
  async softDeleteEntity(id, edit) {
283
354
  return this.#mutate(id, edit, (entity) => entity, {
@@ -294,22 +365,16 @@ export class TaprootRepository {
294
365
  async redirectEntity(id, target, edit) {
295
366
  if (id === target)
296
367
  throw new InvalidEntityError('An entity cannot redirect to itself');
297
- const source = await this.getEntity(id);
298
- const targetEntity = await this.getEntity(target);
299
- if (source.entity.type !== targetEntity.entity.type)
300
- throw new InvalidEntityError('Redirect source and target must have the same entity type');
301
- if (targetEntity.deletedAt)
302
- throw new InvalidEntityError('An entity cannot redirect to a deleted target');
303
- const seen = new Set([id]);
304
- let cursor = target;
305
- for (let depth = 0; cursor && depth < 100; depth += 1) {
306
- if (seen.has(cursor))
307
- throw new InvalidEntityError('Redirect would create a cycle');
308
- seen.add(cursor);
309
- cursor = (await this.getEntity(cursor)).redirectTo;
310
- }
311
- if (cursor)
312
- throw new InvalidEntityError('Redirect chain exceeds 100 entities');
368
+ if (edit.authorization || edit.authorizationContext)
369
+ await this.#preauthorizeCanonicalWrite(id, edit.authorization, edit.authorizationContext, edit.expectedRevision);
370
+ const source = edit.authorizationContext
371
+ ? await this.#authorizedLifecycle(id, edit.authorizationContext)
372
+ : await this.getEntity(id).then(({ entity, deletedAt, redirectTo }) => ({
373
+ type: entity.type,
374
+ deletedAt,
375
+ redirectTo,
376
+ }));
377
+ await this.#validateRedirectTarget(id, target, source.type, edit.authorizationContext);
313
378
  return this.#mutate(id, edit, (entity) => entity, {
314
379
  deletedAt: null,
315
380
  redirectTo: target,
@@ -374,6 +439,7 @@ export class TaprootRepository {
374
439
  });
375
440
  }
376
441
  async addStatement(id, statement, edit) {
442
+ assertAuthoredStatementText(statement.text, statement.id);
377
443
  return this.#mutate(id, edit, (entity) => {
378
444
  const property = statement.mainsnak.property;
379
445
  entity.claims[property] ??= [];
@@ -382,6 +448,7 @@ export class TaprootRepository {
382
448
  });
383
449
  }
384
450
  async replaceStatement(id, statementId, statement, edit) {
451
+ assertAuthoredStatementText(statement.text, statement.id);
385
452
  return this.#mutate(id, edit, (entity) => {
386
453
  const located = locateStatement(entity, statementId);
387
454
  located.statements.splice(located.index, 1);
@@ -402,16 +469,21 @@ export class TaprootRepository {
402
469
  return entity;
403
470
  });
404
471
  }
405
- async setStatementRank(id, statementId, rank, edit) {
472
+ async setStatementRank(id, statementId, rank, text, edit) {
473
+ assertAuthoredStatementText(text, statementId);
406
474
  return this.#mutate(id, edit, (entity) => {
407
- locateStatement(entity, statementId).statement.rank = rank;
475
+ const statement = locateStatement(entity, statementId).statement;
476
+ statement.rank = rank;
477
+ statement.text = text;
408
478
  return entity;
409
479
  });
410
480
  }
411
- async addQualifier(id, statementId, snak, edit) {
481
+ async addQualifier(id, statementId, snak, text, edit) {
482
+ assertAuthoredStatementText(text, statementId);
412
483
  validateSnak(snak);
413
484
  return this.#mutate(id, edit, (entity) => {
414
485
  const statement = locateStatement(entity, statementId).statement;
486
+ statement.text = text;
415
487
  const property = snak.property;
416
488
  if (!statement.qualifiers[property]) {
417
489
  statement.qualifiers[property] = [];
@@ -421,9 +493,11 @@ export class TaprootRepository {
421
493
  return entity;
422
494
  });
423
495
  }
424
- async removeQualifier(id, statementId, property, ordinal, edit) {
496
+ async removeQualifier(id, statementId, property, ordinal, text, edit) {
497
+ assertAuthoredStatementText(text, statementId);
425
498
  return this.#mutate(id, edit, (entity) => {
426
499
  const statement = locateStatement(entity, statementId).statement;
500
+ statement.text = text;
427
501
  const snaks = statement.qualifiers[property];
428
502
  if (!snaks?.[ordinal])
429
503
  throw new InvalidStatementError('Qualifier does not exist');
@@ -435,16 +509,21 @@ export class TaprootRepository {
435
509
  return entity;
436
510
  });
437
511
  }
438
- async addReference(id, statementId, reference, edit) {
512
+ async addReference(id, statementId, reference, text, edit) {
513
+ assertAuthoredStatementText(text, statementId);
439
514
  return this.#mutate(id, edit, (entity) => {
440
- locateStatement(entity, statementId).statement.references.push(structuredClone(reference));
515
+ const statement = locateStatement(entity, statementId).statement;
516
+ statement.text = text;
517
+ statement.references.push(structuredClone(reference));
441
518
  return entity;
442
519
  });
443
520
  }
444
- async removeReference(id, statementId, hash, edit) {
521
+ async removeReference(id, statementId, hash, text, edit) {
522
+ assertAuthoredStatementText(text, statementId);
445
523
  return this.#mutate(id, edit, (entity) => {
446
- const references = locateStatement(entity, statementId).statement
447
- .references;
524
+ const statement = locateStatement(entity, statementId).statement;
525
+ statement.text = text;
526
+ const references = statement.references;
448
527
  const index = references.findIndex((reference) => reference.hash === hash);
449
528
  if (index < 0)
450
529
  throw new InvalidStatementError(`Reference ${hash} does not exist`);
@@ -452,10 +531,12 @@ export class TaprootRepository {
452
531
  return entity;
453
532
  });
454
533
  }
455
- async replaceReference(id, statementId, hash, reference, edit) {
534
+ async replaceReference(id, statementId, hash, reference, text, edit) {
535
+ assertAuthoredStatementText(text, statementId);
456
536
  return this.#mutate(id, edit, (entity) => {
457
- const references = locateStatement(entity, statementId).statement
458
- .references;
537
+ const statement = locateStatement(entity, statementId).statement;
538
+ statement.text = text;
539
+ const references = statement.references;
459
540
  const index = references.findIndex((item) => item.hash === hash);
460
541
  if (index < 0)
461
542
  throw new InvalidStatementError(`Reference ${hash} does not exist`);
@@ -470,6 +551,7 @@ export class TaprootRepository {
470
551
  }
471
552
  const succeeded = new Map();
472
553
  const failed = [];
554
+ let authorizationContext = options.metadata?.authorizationContext;
473
555
  let pending = values
474
556
  .map((entity, index) => ({ entity, index }))
475
557
  .sort((a, b) => Number(a.entity.type !== 'property') -
@@ -479,7 +561,20 @@ export class TaprootRepository {
479
561
  let progress = false;
480
562
  for (const entry of pending) {
481
563
  try {
482
- succeeded.set(entry.index, await this.#importOne(entry.entity, options));
564
+ const effectiveOptions = authorizationContext
565
+ ? {
566
+ ...options,
567
+ metadata: { ...options.metadata, authorizationContext },
568
+ }
569
+ : options;
570
+ const result = await this.#importOne(entry.entity, effectiveOptions);
571
+ succeeded.set(entry.index, result);
572
+ if (authorizationContext &&
573
+ result.authorizationRevision !== undefined)
574
+ authorizationContext = {
575
+ ...authorizationContext,
576
+ authorizationRevision: result.authorizationRevision,
577
+ };
483
578
  progress = true;
484
579
  }
485
580
  catch (error) {
@@ -526,11 +621,31 @@ export class TaprootRepository {
526
621
  }
527
622
  async #importOne(entity, options) {
528
623
  if (options.mode === 'upsert') {
624
+ const authorization = options.authorizations?.[entity.id];
625
+ const authorizationContext = options.metadata?.authorizationContext;
626
+ if (authorization || authorizationContext) {
627
+ const currentRevision = await this.#preauthorizeCanonicalWrite(entity.id, authorization, authorizationContext);
628
+ if (currentRevision !== null)
629
+ return this.replaceEntity(entity.id, entity, {
630
+ expectedRevision: currentRevision,
631
+ statementTexts: authoredStatementTexts(entity),
632
+ ...options.metadata,
633
+ ...(authorization ? { authorization } : {}),
634
+ });
635
+ return this.importEntity(entity, {
636
+ ...options.metadata,
637
+ ...(authorization ? { authorization } : {}),
638
+ });
639
+ }
529
640
  try {
530
641
  const current = await this.getEntity(entity.id);
531
642
  return await this.replaceEntity(entity.id, entity, {
532
643
  expectedRevision: current.entity.lastrevid,
644
+ statementTexts: authoredStatementTexts(entity),
533
645
  ...options.metadata,
646
+ ...(options.authorizations?.[entity.id]
647
+ ? { authorization: options.authorizations[entity.id] }
648
+ : {}),
534
649
  });
535
650
  }
536
651
  catch (error) {
@@ -538,7 +653,12 @@ export class TaprootRepository {
538
653
  throw error;
539
654
  }
540
655
  }
541
- return this.importEntity(entity, options.metadata);
656
+ return this.importEntity(entity, {
657
+ ...options.metadata,
658
+ ...(options.authorizations?.[entity.id]
659
+ ? { authorization: options.authorizations[entity.id] }
660
+ : {}),
661
+ });
542
662
  }
543
663
  async applyCommands(id, commands, edit) {
544
664
  if (!commands.length)
@@ -729,12 +849,48 @@ export class TaprootRepository {
729
849
  deletedAt: stored.deletedAt,
730
850
  redirectTo: stored.redirectTo,
731
851
  });
852
+ const authorization = await this.#db
853
+ .prepare(`SELECT s.installation_id, s.authorization_revision, s.search_generation,
854
+ s.last_advance_id
855
+ FROM taproot_installation_authorization s
856
+ JOIN taproot_entity_authorization p
857
+ ON p.installation_id = s.installation_id
858
+ WHERE s.singleton = 1 AND p.entity_id = ?
859
+ AND p.source_revision = ?`)
860
+ .bind(id, stored.entity.lastrevid)
861
+ .all();
862
+ const authorizationState = authorization.results[0];
863
+ if (!authorizationState)
864
+ throw new RevisionConflictError('Projection repair requires current canonical authorization policy');
865
+ const nextSearchGeneration = Number(authorizationState.search_generation) + 1;
732
866
  const statements = [
733
867
  this.#db
734
868
  .prepare(`DELETE FROM taproot_terms WHERE entity_id = ?`)
735
869
  .bind(id),
736
870
  this.#termsInsert(stored.entity),
737
871
  this.#auditInsert(stored.entity, metadata, context),
872
+ this.#db
873
+ .prepare(`UPDATE taproot_installation_authorization
874
+ SET search_generation = ?, last_advance_id = ?, updated_at = ?
875
+ WHERE singleton = 1 AND installation_id = ?
876
+ AND authorization_revision = ? AND search_generation = ?
877
+ AND last_advance_id = ?`)
878
+ .bind(nextSearchGeneration, context.eventId, context.createdAt, authorizationState.installation_id, authorizationState.authorization_revision, authorizationState.search_generation, authorizationState.last_advance_id),
879
+ this.#assertion(`EXISTS (
880
+ SELECT 1 FROM taproot_installation_authorization s
881
+ JOIN taproot_entity_authorization p
882
+ ON p.installation_id = s.installation_id
883
+ WHERE s.singleton = 1 AND p.entity_id = ?
884
+ AND p.source_revision = ? AND s.authorization_revision = ?
885
+ AND s.search_generation = ?
886
+ AND s.last_advance_id = ?
887
+ )`, id, stored.entity.lastrevid, authorizationState.authorization_revision, nextSearchGeneration, context.eventId),
888
+ this.#db
889
+ .prepare(`INSERT INTO taproot_authorization_projection_outbox(
890
+ event_id, entity_id, source_revision, authorization_revision,
891
+ search_generation, operation, created_at
892
+ ) VALUES (?, ?, ?, ?, ?, 'repair', ?)`)
893
+ .bind(context.eventId, id, stored.entity.lastrevid, authorizationState.authorization_revision, nextSearchGeneration, context.createdAt),
738
894
  ...patch.statements,
739
895
  ...this.#ownershipPatch(id, actual, expected).statements,
740
896
  ];
@@ -746,6 +902,8 @@ export class TaprootRepository {
746
902
  this.#emitObservation('repair', started, 'error', id, stored.entity.lastrevid, cause);
747
903
  if (isEventIdUniqueError(cause))
748
904
  throw new RevisionConflictError('Generated audit event id already exists', { cause });
905
+ if (isAssertionError(cause))
906
+ throw new RevisionConflictError('Canonical policy changed during projection repair', { cause });
749
907
  throw cause;
750
908
  }
751
909
  const after = await this.inspectEntityIntegrity(id);
@@ -760,6 +918,8 @@ export class TaprootRepository {
760
918
  async #create(entity, metadata, allocated, eventType) {
761
919
  const started = performance.now();
762
920
  validateEntity(entity);
921
+ if (metadata.authorization || metadata.authorizationContext)
922
+ await this.#preauthorizeCanonicalWrite(entity.id, metadata.authorization, metadata.authorizationContext);
763
923
  if (await this.#loadRow(entity.id))
764
924
  throw new EntityAlreadyExistsError(`Entity ${entity.id} already exists`);
765
925
  await this.#validatePropertyDatatypes(entity);
@@ -767,6 +927,9 @@ export class TaprootRepository {
767
927
  const json = exportEntityJson(entity, this.#options.maxEntityBytes);
768
928
  const lifecycle = { deletedAt: null, redirectTo: null };
769
929
  const context = await this.#writeContext(entity, json, metadata, null, eventType, lifecycle);
930
+ const authorization = metadata.authorization
931
+ ? await this.#prepareAuthorizationWrite(entity, metadata.authorization, metadata.authorizationContext, undefined)
932
+ : undefined;
770
933
  const newQuads = this.#lifecycleQuads(entity, lifecycle);
771
934
  const marker = revisionQuad(entity, this.#options);
772
935
  const patch = this.#preparePatch({ forbid: [marker], insert: newQuads });
@@ -785,7 +948,9 @@ export class TaprootRepository {
785
948
  }
786
949
  statements.push(this.#db
787
950
  .prepare(`INSERT INTO taproot_entities(entity_id, entity_type, datatype, revision, entity_json, modified_at) VALUES (?, ?, ?, ?, ?, ?)`)
788
- .bind(entity.id, entity.type, entity.type === 'property' ? entity.datatype : null, entity.lastrevid, json, entity.modified), this.#revisionInsert(entity, json, metadata, context), this.#auditInsert(entity, metadata, context), this.#termsInsert(entity));
951
+ .bind(entity.id, entity.type, entity.type === 'property' ? entity.datatype : null, entity.lastrevid, json, entity.modified), this.#revisionInsert(entity, json, metadata, context), this.#auditInsert(entity, metadata, context, authorization), this.#termsInsert(entity));
952
+ if (authorization)
953
+ statements.push(...this.#authorizationWriteStatements(entity, lifecycle, context, authorization, undefined));
789
954
  const patchOffset = statements.length;
790
955
  statements.push(...patch.statements);
791
956
  const ownership = this.#ownershipPatch(entity.id, [], newQuads);
@@ -804,6 +969,12 @@ export class TaprootRepository {
804
969
  },
805
970
  eventId: context.eventId,
806
971
  contentHash: context.contentHash,
972
+ ...(authorization
973
+ ? {
974
+ authorizationRevision: authorization.authorizationRevision,
975
+ searchGeneration: authorization.searchGeneration,
976
+ }
977
+ : {}),
807
978
  };
808
979
  this.#emitObservation(eventType, started, 'success', entity.id, entity.lastrevid);
809
980
  return result;
@@ -820,13 +991,17 @@ export class TaprootRepository {
820
991
  }
821
992
  if (isEventIdUniqueError(mapped))
822
993
  throw new RevisionConflictError('Generated audit event id already exists', { cause: mapped });
823
- if (allocated && isAssertionError(mapped))
824
- throw new RevisionConflictError(`ID allocation for ${entity.id} was stale`, { cause: mapped });
994
+ if (isAssertionError(mapped))
995
+ throw new RevisionConflictError(allocated
996
+ ? `ID allocation for ${entity.id} was stale`
997
+ : `Authorization state changed while creating ${entity.id}`, { cause: mapped });
825
998
  throw mapped;
826
999
  }
827
1000
  }
828
1001
  async #mutate(id, edit, transform, lifecycleOverride, eventType = 'update') {
829
1002
  const started = performance.now();
1003
+ if (edit.authorization || edit.authorizationContext)
1004
+ await this.#preauthorizeCanonicalWrite(id, edit.authorization, edit.authorizationContext, edit.expectedRevision);
830
1005
  const row = await this.#loadRow(id);
831
1006
  if (!row)
832
1007
  throw new EntityNotFoundError(`Entity ${id} was not found`);
@@ -858,6 +1033,9 @@ export class TaprootRepository {
858
1033
  (await revisionContentHash(row.entity_json, oldLifecycle));
859
1034
  const newLifecycle = lifecycleOverride ?? oldLifecycle;
860
1035
  const context = await this.#writeContext(next, json, edit, parentHash, eventType, newLifecycle);
1036
+ const authorization = edit.authorization
1037
+ ? await this.#prepareAuthorizationWrite(next, edit.authorization, edit.authorizationContext, previous)
1038
+ : undefined;
861
1039
  const oldQuads = this.#lifecycleQuads(stored.entity, oldLifecycle);
862
1040
  const newQuads = this.#lifecycleQuads(next, newLifecycle);
863
1041
  const patch = this.#preparePatch({
@@ -871,12 +1049,14 @@ export class TaprootRepository {
871
1049
  .bind(next.type === 'property' ? next.datatype : null, next.lastrevid, json, next.modified, newLifecycle.deletedAt, newLifecycle.redirectTo, id, previous),
872
1050
  this.#assertion(`EXISTS (SELECT 1 FROM taproot_entities WHERE entity_id = ? AND revision = ?)`, id, next.lastrevid),
873
1051
  this.#revisionInsert(next, json, edit, context),
874
- this.#auditInsert(next, edit, context),
1052
+ this.#auditInsert(next, edit, context, authorization),
875
1053
  this.#db
876
1054
  .prepare('DELETE FROM taproot_terms WHERE entity_id = ?')
877
1055
  .bind(id),
878
1056
  this.#termsInsert(next),
879
1057
  ];
1058
+ if (authorization)
1059
+ statements.push(...this.#authorizationWriteStatements(next, newLifecycle, context, authorization, previous));
880
1060
  const patchOffset = statements.length;
881
1061
  statements.push(...patch.statements);
882
1062
  const ownership = this.#ownershipPatch(id, oldQuads, newQuads);
@@ -895,6 +1075,12 @@ export class TaprootRepository {
895
1075
  },
896
1076
  eventId: context.eventId,
897
1077
  contentHash: context.contentHash,
1078
+ ...(authorization
1079
+ ? {
1080
+ authorizationRevision: authorization.authorizationRevision,
1081
+ searchGeneration: authorization.searchGeneration,
1082
+ }
1083
+ : {}),
898
1084
  };
899
1085
  this.#emitObservation(eventType, started, 'success', id, next.lastrevid);
900
1086
  return result;
@@ -915,6 +1101,295 @@ export class TaprootRepository {
915
1101
  throw mapped;
916
1102
  }
917
1103
  }
1104
+ async #prepareAuthorizationWrite(entity, rawPolicy, rawContext, previousRevision) {
1105
+ const policy = normalizeCanonicalAuthorizationPolicy(rawPolicy);
1106
+ if (!rawContext)
1107
+ throw new InvalidEntityError('Guarded canonical writes require AuthorizationContext');
1108
+ const caller = normalizeAuthorizationContext(rawContext);
1109
+ if (!caller.capabilities.includes(KNOWLEDGE_WRITE_CAPABILITY))
1110
+ throw new InvalidEntityError(`Canonical writes require ${KNOWLEDGE_WRITE_CAPABILITY}`);
1111
+ const expectedStatementIds = statementIds(entity);
1112
+ const providedStatementIds = Object.keys(policy.statementRestrictions).sort(compareCodeUnits);
1113
+ if (JSON.stringify(expectedStatementIds) !==
1114
+ JSON.stringify(providedStatementIds))
1115
+ throw new InvalidEntityError('statementRestrictions must explicitly and exactly cover the post-mutation statements');
1116
+ const state = await this.#db
1117
+ .prepare(`SELECT installation_id, authorization_revision, search_generation,
1118
+ last_advance_id
1119
+ FROM taproot_installation_authorization WHERE singleton = 1`)
1120
+ .all();
1121
+ const installation = state.results[0];
1122
+ if (!installation ||
1123
+ installation.installation_id !== policy.installationId ||
1124
+ Number(installation.authorization_revision) !==
1125
+ policy.expectedAuthorizationRevision ||
1126
+ caller.installationId !== policy.installationId ||
1127
+ caller.authorizationRevision !== policy.expectedAuthorizationRevision)
1128
+ throw new RevisionConflictError('Installation authorization revision is stale or mismatched');
1129
+ if (policy.workspaceId !== null &&
1130
+ !caller.workspaceIds.includes(policy.workspaceId))
1131
+ throw new InvalidEntityError('Canonical policy workspace must belong to the caller context');
1132
+ if (!isVisibleTo(policy.visibility, caller))
1133
+ throw new InvalidEntityError('Canonical policy must remain visible to its creating or updating caller');
1134
+ if (previousRevision === undefined &&
1135
+ policy.ownerPrincipalId !== caller.principalId &&
1136
+ !caller.capabilities.includes(KNOWLEDGE_POLICY_CAPABILITY))
1137
+ throw new InvalidEntityError(`Creating policy for another owner requires ${KNOWLEDGE_POLICY_CAPABILITY}`);
1138
+ const statementRows = expectedStatementIds.map((statementId) => {
1139
+ const restrictions = policy.statementRestrictions[statementId];
1140
+ return {
1141
+ statementId,
1142
+ restrictionsJson: JSON.stringify(restrictions),
1143
+ effectiveVisibilityJson: serializeVisibilityScope(intersectVisibilityScopes(policy.visibility, ...restrictions)),
1144
+ };
1145
+ });
1146
+ const effectiveVisibilityJson = serializeVisibilityScope(intersectVisibilityScopes(policy.visibility, ...statementRows.map(({ effectiveVisibilityJson: serialized }) => JSON.parse(serialized))));
1147
+ let action = 'create';
1148
+ if (previousRevision !== undefined) {
1149
+ const current = await this.#db
1150
+ .prepare(`SELECT installation_id, source_revision, visibility_json,
1151
+ effective_visibility_json, workspace_id, owner_principal_id
1152
+ FROM taproot_entity_authorization WHERE entity_id = ?`)
1153
+ .bind(entity.id)
1154
+ .all();
1155
+ const previousPolicy = current.results[0];
1156
+ if (!previousPolicy ||
1157
+ previousPolicy.installation_id !== policy.installationId ||
1158
+ Number(previousPolicy.source_revision) !== previousRevision)
1159
+ throw new RevisionConflictError('Canonical entity policy is missing, quarantined, or stale');
1160
+ assertScopeDoesNotWiden(policy.visibility, JSON.parse(previousPolicy.visibility_json), 'entity visibility');
1161
+ if (!isVisibleTo(JSON.parse(previousPolicy.effective_visibility_json), caller))
1162
+ throw new InvalidEntityError('Caller is not authorized for the current canonical policy');
1163
+ const previousStatements = await this.#db
1164
+ .prepare(`SELECT statement_id, restrictions_json, effective_visibility_json
1165
+ FROM taproot_statement_authorization
1166
+ WHERE entity_id = ? AND source_revision = ?`)
1167
+ .bind(entity.id, previousRevision)
1168
+ .all();
1169
+ const nextById = new Map(statementRows.map((row) => [row.statementId, row]));
1170
+ const previousRestrictions = Object.fromEntries(previousStatements.results
1171
+ .map((row) => {
1172
+ const parsed = JSON.parse(row.restrictions_json);
1173
+ if (!Array.isArray(parsed))
1174
+ throw new InvalidEntityError(`Stored statement ${row.statement_id} restrictions are invalid`);
1175
+ return [
1176
+ row.statement_id,
1177
+ parsed.map((scope) => normalizeVisibilityScope(scope)),
1178
+ ];
1179
+ })
1180
+ .sort(([left], [right]) => compareCodeUnits(String(left), String(right))));
1181
+ for (const previous of previousStatements.results) {
1182
+ const next = nextById.get(previous.statement_id);
1183
+ if (!next)
1184
+ continue;
1185
+ assertScopeDoesNotWiden(JSON.parse(next.effectiveVisibilityJson), JSON.parse(previous.effective_visibility_json), `statement ${previous.statement_id} visibility`);
1186
+ }
1187
+ const changed = previousPolicy.workspace_id !== policy.workspaceId ||
1188
+ previousPolicy.owner_principal_id !== policy.ownerPrincipalId ||
1189
+ previousPolicy.visibility_json !==
1190
+ serializeVisibilityScope(policy.visibility) ||
1191
+ JSON.stringify(previousRestrictions) !==
1192
+ JSON.stringify(policy.statementRestrictions);
1193
+ action = changed ? 'policy-change' : 'unchanged';
1194
+ if (changed && !caller.capabilities.includes(KNOWLEDGE_POLICY_CAPABILITY))
1195
+ throw new InvalidEntityError(`Canonical policy changes require ${KNOWLEDGE_POLICY_CAPABILITY}`);
1196
+ }
1197
+ const authorizationRevision = policy.expectedAuthorizationRevision + 1;
1198
+ const searchGeneration = Number(installation.search_generation) + 1;
1199
+ if (!Number.isSafeInteger(authorizationRevision) ||
1200
+ !Number.isSafeInteger(searchGeneration))
1201
+ throw new RevisionConflictError('Installation authorization counters are exhausted');
1202
+ return {
1203
+ policy,
1204
+ previousAdvanceId: installation.last_advance_id,
1205
+ authorizationRevision,
1206
+ searchGeneration,
1207
+ effectiveVisibilityJson,
1208
+ action,
1209
+ statementRows,
1210
+ };
1211
+ }
1212
+ async #preauthorizeCanonicalWrite(entityId, rawPolicy, rawContext, expectedRevision) {
1213
+ if (!rawPolicy || !rawContext)
1214
+ throw new AuthorizationDeniedError('Authorization denied');
1215
+ let policy;
1216
+ let caller;
1217
+ try {
1218
+ policy = normalizeCanonicalAuthorizationPolicy(rawPolicy);
1219
+ caller = normalizeAuthorizationContext(rawContext);
1220
+ }
1221
+ catch {
1222
+ throw new AuthorizationDeniedError('Authorization denied');
1223
+ }
1224
+ if (!caller.capabilities.includes(KNOWLEDGE_WRITE_CAPABILITY))
1225
+ throw new AuthorizationDeniedError('Authorization denied');
1226
+ const result = await this.#db
1227
+ .prepare(`SELECT s.installation_id, s.authorization_revision,
1228
+ e.entity_id, e.revision,
1229
+ p.installation_id AS policy_installation_id,
1230
+ p.source_revision AS policy_source_revision,
1231
+ p.effective_visibility_json
1232
+ FROM taproot_installation_authorization s
1233
+ LEFT JOIN taproot_entities e ON e.entity_id = ?
1234
+ LEFT JOIN taproot_entity_authorization p
1235
+ ON p.entity_id = e.entity_id AND p.source_revision = e.revision
1236
+ WHERE s.singleton = 1`)
1237
+ .bind(entityId)
1238
+ .all();
1239
+ const row = result.results[0];
1240
+ if (!row ||
1241
+ row.installation_id !== policy.installationId ||
1242
+ row.installation_id !== caller.installationId ||
1243
+ Number(row.authorization_revision) !==
1244
+ policy.expectedAuthorizationRevision ||
1245
+ Number(row.authorization_revision) !== caller.authorizationRevision)
1246
+ throw new AuthorizationDeniedError('Authorization denied');
1247
+ if (row.entity_id === null) {
1248
+ if (expectedRevision !== undefined)
1249
+ throw new AuthorizationDeniedError('Authorization denied');
1250
+ if ((policy.workspaceId !== null &&
1251
+ !caller.workspaceIds.includes(policy.workspaceId)) ||
1252
+ !isVisibleTo(policy.visibility, caller) ||
1253
+ (policy.ownerPrincipalId !== caller.principalId &&
1254
+ !caller.capabilities.includes(KNOWLEDGE_POLICY_CAPABILITY)))
1255
+ throw new AuthorizationDeniedError('Authorization denied');
1256
+ return null;
1257
+ }
1258
+ if (row.policy_installation_id !== row.installation_id ||
1259
+ row.policy_source_revision === null ||
1260
+ row.effective_visibility_json === null ||
1261
+ !isVisibleTo(JSON.parse(row.effective_visibility_json), caller))
1262
+ throw new AuthorizationDeniedError('Authorization denied');
1263
+ if (expectedRevision !== undefined &&
1264
+ Number(row.policy_source_revision) !== expectedRevision)
1265
+ throw new RevisionConflictError('Canonical revision is stale');
1266
+ return Number(row.policy_source_revision);
1267
+ }
1268
+ async #authorizedLifecycle(entityId, rawContext) {
1269
+ const caller = normalizeAuthorizationContext(rawContext);
1270
+ if (!caller.capabilities.includes(KNOWLEDGE_WRITE_CAPABILITY))
1271
+ throw new AuthorizationDeniedError('Authorization denied');
1272
+ const result = await this.#db
1273
+ .prepare(`SELECT e.entity_type, e.deleted_at, e.redirect_to,
1274
+ p.effective_visibility_json
1275
+ FROM taproot_installation_authorization s
1276
+ JOIN taproot_entities e ON e.entity_id = ?
1277
+ JOIN taproot_entity_authorization p
1278
+ ON p.entity_id = e.entity_id AND p.source_revision = e.revision
1279
+ WHERE s.singleton = 1
1280
+ AND s.installation_id = ? AND s.authorization_revision = ?
1281
+ AND p.installation_id = s.installation_id`)
1282
+ .bind(entityId, caller.installationId, caller.authorizationRevision)
1283
+ .all();
1284
+ const row = result.results[0];
1285
+ if (!row ||
1286
+ !isVisibleTo(JSON.parse(row.effective_visibility_json), caller))
1287
+ throw new AuthorizationDeniedError('Authorization denied');
1288
+ return {
1289
+ type: row.entity_type,
1290
+ deletedAt: row.deleted_at,
1291
+ redirectTo: row.redirect_to,
1292
+ };
1293
+ }
1294
+ async #validateRedirectTarget(sourceId, targetId, sourceType, authorizationContext) {
1295
+ const seen = new Set([sourceId]);
1296
+ let cursor = targetId;
1297
+ for (let depth = 0; cursor && depth < 100; depth += 1) {
1298
+ if (seen.has(cursor))
1299
+ throw new InvalidEntityError('Redirect would create a cycle');
1300
+ seen.add(cursor);
1301
+ const target = authorizationContext
1302
+ ? await this.#authorizedLifecycle(cursor, authorizationContext)
1303
+ : await this.getEntity(cursor).then(({ entity, deletedAt, redirectTo }) => ({
1304
+ type: entity.type,
1305
+ deletedAt,
1306
+ redirectTo,
1307
+ }));
1308
+ if (target.type !== sourceType)
1309
+ throw new InvalidEntityError('Redirect source and target must have the same entity type');
1310
+ if (target.deletedAt)
1311
+ throw new InvalidEntityError('An entity cannot redirect to a deleted target');
1312
+ cursor = target.redirectTo;
1313
+ }
1314
+ if (cursor)
1315
+ throw new InvalidEntityError('Redirect chain exceeds 100 entities');
1316
+ }
1317
+ #authorizationWriteStatements(entity, lifecycle, context, prepared, previousRevision) {
1318
+ const { policy, authorizationRevision, searchGeneration, effectiveVisibilityJson, statementRows, previousAdvanceId, } = prepared;
1319
+ const visibilityJson = serializeVisibilityScope(policy.visibility);
1320
+ const rowsJson = JSON.stringify(statementRows);
1321
+ const statements = [
1322
+ this.#db
1323
+ .prepare(`UPDATE taproot_installation_authorization
1324
+ SET authorization_revision = ?, search_generation = ?, updated_at = ?
1325
+ , last_advance_id = ?
1326
+ WHERE singleton = 1 AND installation_id = ?
1327
+ AND authorization_revision = ? AND search_generation = ?
1328
+ AND last_advance_id = ?`)
1329
+ .bind(authorizationRevision, searchGeneration, context.createdAt, context.eventId, policy.installationId, policy.expectedAuthorizationRevision, searchGeneration - 1, previousAdvanceId),
1330
+ this.#assertion(`EXISTS (
1331
+ SELECT 1 FROM taproot_installation_authorization
1332
+ WHERE singleton = 1 AND installation_id = ?
1333
+ AND authorization_revision = ? AND search_generation = ?
1334
+ AND last_advance_id = ?
1335
+ )`, policy.installationId, authorizationRevision, searchGeneration, context.eventId),
1336
+ ];
1337
+ if (previousRevision === undefined) {
1338
+ statements.push(this.#db
1339
+ .prepare(`INSERT INTO taproot_entity_authorization(
1340
+ entity_id, installation_id, workspace_id, owner_principal_id,
1341
+ visibility_json, effective_visibility_json, source_revision, authorization_revision,
1342
+ deleted_at, event_id, updated_at
1343
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1344
+ .bind(entity.id, policy.installationId, policy.workspaceId, policy.ownerPrincipalId, visibilityJson, effectiveVisibilityJson, entity.lastrevid, authorizationRevision, lifecycle.deletedAt, context.eventId, context.createdAt));
1345
+ }
1346
+ else {
1347
+ statements.push(this.#db
1348
+ .prepare(`UPDATE taproot_entity_authorization
1349
+ SET workspace_id = ?, owner_principal_id = ?, visibility_json = ?,
1350
+ effective_visibility_json = ?, source_revision = ?, authorization_revision = ?, deleted_at = ?,
1351
+ event_id = ?, updated_at = ?
1352
+ WHERE entity_id = ? AND installation_id = ? AND source_revision = ?`)
1353
+ .bind(policy.workspaceId, policy.ownerPrincipalId, visibilityJson, effectiveVisibilityJson, entity.lastrevid, authorizationRevision, lifecycle.deletedAt, context.eventId, context.createdAt, entity.id, policy.installationId, previousRevision), this.#assertion(`EXISTS (
1354
+ SELECT 1 FROM taproot_entity_authorization
1355
+ WHERE entity_id = ? AND installation_id = ?
1356
+ AND source_revision = ? AND authorization_revision = ?
1357
+ )`, entity.id, policy.installationId, entity.lastrevid, authorizationRevision));
1358
+ }
1359
+ statements.push(this.#db
1360
+ .prepare(`INSERT INTO taproot_entity_authorization_revisions(
1361
+ entity_id, source_revision, installation_id, workspace_id,
1362
+ owner_principal_id, visibility_json, effective_visibility_json, authorization_revision,
1363
+ deleted_at, event_id, created_at
1364
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1365
+ .bind(entity.id, entity.lastrevid, policy.installationId, policy.workspaceId, policy.ownerPrincipalId, visibilityJson, effectiveVisibilityJson, authorizationRevision, lifecycle.deletedAt, context.eventId, context.createdAt), this.#db
1366
+ .prepare(`DELETE FROM taproot_statement_authorization WHERE entity_id = ?`)
1367
+ .bind(entity.id), this.#db
1368
+ .prepare(`INSERT INTO taproot_statement_authorization(
1369
+ entity_id, statement_id, source_revision, restrictions_json,
1370
+ effective_visibility_json, authorization_revision
1371
+ )
1372
+ SELECT ?, json_extract(value, '$.statementId'), ?,
1373
+ json_extract(value, '$.restrictionsJson'),
1374
+ json_extract(value, '$.effectiveVisibilityJson'), ?
1375
+ FROM json_each(?)`)
1376
+ .bind(entity.id, entity.lastrevid, authorizationRevision, rowsJson), this.#db
1377
+ .prepare(`INSERT INTO taproot_statement_authorization_revisions(
1378
+ entity_id, source_revision, statement_id, restrictions_json,
1379
+ effective_visibility_json, authorization_revision
1380
+ )
1381
+ SELECT ?, ?, json_extract(value, '$.statementId'),
1382
+ json_extract(value, '$.restrictionsJson'),
1383
+ json_extract(value, '$.effectiveVisibilityJson'), ?
1384
+ FROM json_each(?)`)
1385
+ .bind(entity.id, entity.lastrevid, authorizationRevision, rowsJson), this.#db
1386
+ .prepare(`INSERT INTO taproot_authorization_projection_outbox(
1387
+ event_id, entity_id, source_revision, authorization_revision,
1388
+ search_generation, operation, created_at
1389
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`)
1390
+ .bind(context.eventId, entity.id, entity.lastrevid, authorizationRevision, searchGeneration, lifecycle.deletedAt ? 'delete' : 'upsert', context.createdAt));
1391
+ return statements;
1392
+ }
918
1393
  #preparePatch(patch) {
919
1394
  try {
920
1395
  return prepareQuadPatch(this.#db, patch);
@@ -1045,13 +1520,24 @@ export class TaprootRepository {
1045
1520
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1046
1521
  .bind(entity.id, entity.lastrevid, json, metadata.actor ?? context.attribution?.id ?? null, context.attribution ? JSON.stringify(context.attribution) : null, metadata.editSummary ?? null, JSON.stringify(context.tags), context.eventId, context.contentHash, context.parentHash, context.lifecycle.deletedAt, context.lifecycle.redirectTo, context.createdAt);
1047
1522
  }
1048
- #auditInsert(entity, metadata, context) {
1523
+ #auditInsert(entity, metadata, context, authorization) {
1049
1524
  return this.#db
1050
1525
  .prepare(`INSERT INTO taproot_audit_events(
1051
1526
  event_id, entity_id, revision, event_type, attribution_json, edit_summary,
1052
1527
  tags_json, request_id, content_hash, parent_hash, details_json, created_at
1053
1528
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1054
- .bind(context.eventId, entity.id, entity.lastrevid, context.eventType, context.attribution ? JSON.stringify(context.attribution) : null, metadata.editSummary ?? null, JSON.stringify(context.tags), metadata.requestId ?? null, context.contentHash, context.parentHash, JSON.stringify(context.lifecycle), context.createdAt);
1529
+ .bind(context.eventId, entity.id, entity.lastrevid, context.eventType, context.attribution ? JSON.stringify(context.attribution) : null, metadata.editSummary ?? null, JSON.stringify(context.tags), metadata.requestId ?? null, context.contentHash, context.parentHash, JSON.stringify({
1530
+ ...context.lifecycle,
1531
+ ...(authorization
1532
+ ? {
1533
+ authorization: {
1534
+ action: authorization.action,
1535
+ authorizationRevision: authorization.authorizationRevision,
1536
+ searchGeneration: authorization.searchGeneration,
1537
+ },
1538
+ }
1539
+ : {}),
1540
+ }), context.createdAt);
1055
1541
  }
1056
1542
  async #writeContext(entity, json, metadata, parentHash, eventType, lifecycle) {
1057
1543
  const attribution = normalizeAttribution(metadata);
@@ -1120,10 +1606,6 @@ export class TaprootRepository {
1120
1606
  }
1121
1607
  #namespaceStatements() {
1122
1608
  return [
1123
- this.#db
1124
- .prepare(`INSERT INTO taproot_metadata(metadata_key, metadata_value)
1125
- VALUES ('base_iri', ?) ON CONFLICT(metadata_key) DO NOTHING`)
1126
- .bind(withoutTrailingSlashes(this.#options.baseIri)),
1127
1609
  this.#assertion(`EXISTS (SELECT 1 FROM taproot_metadata
1128
1610
  WHERE metadata_key = 'base_iri' AND metadata_value = ?)`, withoutTrailingSlashes(this.#options.baseIri)),
1129
1611
  ];
@@ -1239,6 +1721,26 @@ function locateStatement(entity, id) {
1239
1721
  }
1240
1722
  throw new InvalidStatementError(`Statement ${id} does not exist`);
1241
1723
  }
1724
+ function resupplyStatementTexts(entity, supplied) {
1725
+ const expected = new Set();
1726
+ for (const statements of Object.values(entity.claims)) {
1727
+ for (const statement of statements) {
1728
+ expected.add(statement.id);
1729
+ if (!Object.hasOwn(supplied, statement.id))
1730
+ throw new InvalidStatementError(`Statement ${statement.id} text must be explicitly resupplied for this revision`);
1731
+ const text = supplied[statement.id];
1732
+ assertAuthoredStatementText(text, statement.id);
1733
+ statement.text = text;
1734
+ }
1735
+ }
1736
+ const unexpected = Object.keys(supplied).filter((id) => !expected.has(id));
1737
+ if (unexpected.length)
1738
+ throw new InvalidStatementError(`Statement text was supplied for unknown statement ${unexpected[0]}`);
1739
+ return entity;
1740
+ }
1741
+ function authoredStatementTexts(entity) {
1742
+ return Object.fromEntries(Object.values(entity.claims).flatMap((statements) => statements.map((statement) => [statement.id, statement.text])));
1743
+ }
1242
1744
  function collectSnak(snak, used) {
1243
1745
  const current = used.get(snak.property);
1244
1746
  if (current && current !== snak.datatype)
@@ -1246,6 +1748,15 @@ function collectSnak(snak, used) {
1246
1748
  used.set(snak.property, snak.datatype);
1247
1749
  }
1248
1750
  function applyEntityCommand(entity, command) {
1751
+ if (command.type === 'add-statement' || command.type === 'replace-statement')
1752
+ assertAuthoredStatementText(command.statement.text, command.statement.id);
1753
+ else if (command.type === 'set-statement-rank' ||
1754
+ command.type === 'add-qualifier' ||
1755
+ command.type === 'remove-qualifier' ||
1756
+ command.type === 'add-reference' ||
1757
+ command.type === 'replace-reference' ||
1758
+ command.type === 'remove-reference')
1759
+ assertAuthoredStatementText(command.text, command.statementId);
1249
1760
  switch (command.type) {
1250
1761
  case 'set-label':
1251
1762
  entity.labels[command.language] = {
@@ -1314,10 +1825,13 @@ function applyEntityCommand(entity, command) {
1314
1825
  case 'set-statement-rank':
1315
1826
  locateStatement(entity, command.statementId).statement.rank =
1316
1827
  command.rank;
1828
+ locateStatement(entity, command.statementId).statement.text =
1829
+ command.text;
1317
1830
  break;
1318
1831
  case 'add-qualifier': {
1319
1832
  validateSnak(command.snak);
1320
1833
  const statement = locateStatement(entity, command.statementId).statement;
1834
+ statement.text = command.text;
1321
1835
  const property = command.snak.property;
1322
1836
  if (!statement.qualifiers[property]) {
1323
1837
  statement.qualifiers[property] = [];
@@ -1328,6 +1842,7 @@ function applyEntityCommand(entity, command) {
1328
1842
  }
1329
1843
  case 'remove-qualifier': {
1330
1844
  const statement = locateStatement(entity, command.statementId).statement;
1845
+ statement.text = command.text;
1331
1846
  const snaks = statement.qualifiers[command.property];
1332
1847
  if (!snaks?.[command.ordinal])
1333
1848
  throw new InvalidStatementError('Qualifier does not exist');
@@ -1338,12 +1853,16 @@ function applyEntityCommand(entity, command) {
1338
1853
  }
1339
1854
  break;
1340
1855
  }
1341
- case 'add-reference':
1342
- locateStatement(entity, command.statementId).statement.references.push(structuredClone(command.reference));
1856
+ case 'add-reference': {
1857
+ const statement = locateStatement(entity, command.statementId).statement;
1858
+ statement.text = command.text;
1859
+ statement.references.push(structuredClone(command.reference));
1343
1860
  break;
1861
+ }
1344
1862
  case 'replace-reference': {
1345
- const references = locateStatement(entity, command.statementId).statement
1346
- .references;
1863
+ const statement = locateStatement(entity, command.statementId).statement;
1864
+ statement.text = command.text;
1865
+ const references = statement.references;
1347
1866
  const index = references.findIndex(({ hash }) => hash === command.hash);
1348
1867
  if (index < 0)
1349
1868
  throw new InvalidStatementError(`Reference ${command.hash} does not exist`);
@@ -1351,8 +1870,9 @@ function applyEntityCommand(entity, command) {
1351
1870
  break;
1352
1871
  }
1353
1872
  case 'remove-reference': {
1354
- const references = locateStatement(entity, command.statementId).statement
1355
- .references;
1873
+ const statement = locateStatement(entity, command.statementId).statement;
1874
+ statement.text = command.text;
1875
+ const references = statement.references;
1356
1876
  const index = references.findIndex(({ hash }) => hash === command.hash);
1357
1877
  if (index < 0)
1358
1878
  throw new InvalidStatementError(`Reference ${command.hash} does not exist`);
@@ -1377,10 +1897,10 @@ function isUniqueEntityError(cause) {
1377
1897
  return /UNIQUE constraint failed: taproot_entities\.entity_id/iu.test(cause.message);
1378
1898
  }
1379
1899
  function isRevisionUniqueError(cause) {
1380
- return /UNIQUE constraint failed: taproot_entity_revisions\.entity_id, taproot_entity_revisions\.revision/iu.test(cause.message);
1900
+ return (/UNIQUE constraint failed: taproot_entity_revisions\.entity_id, taproot_entity_revisions\.revision/iu.test(cause.message) || /taproot revisions cannot be replaced/iu.test(cause.message));
1381
1901
  }
1382
1902
  function isEventIdUniqueError(cause) {
1383
- return /UNIQUE constraint failed: taproot_audit_events\.event_id/iu.test(cause.message);
1903
+ return (/UNIQUE constraint failed: taproot_audit_events\.event_id/iu.test(cause.message) || /taproot audit events cannot be replaced/iu.test(cause.message));
1384
1904
  }
1385
1905
  function normalizeAttribution(metadata) {
1386
1906
  if (metadata.actor !== undefined && typeof metadata.actor !== 'string')
@@ -1493,6 +2013,19 @@ function sameQuads(left, right) {
1493
2013
  const b = [...new Set(right.map(quadKeyValue))].sort();
1494
2014
  return JSON.stringify(a) === JSON.stringify(b);
1495
2015
  }
2016
+ function statementIds(entity) {
2017
+ return Object.values(entity.claims)
2018
+ .flatMap((statements) => statements.map(({ id }) => id))
2019
+ .sort(compareCodeUnits);
2020
+ }
2021
+ function assertScopeDoesNotWiden(next, previous, field) {
2022
+ const intersection = serializeVisibilityScope(intersectVisibilityScopes(next, previous));
2023
+ if (intersection !== serializeVisibilityScope(next))
2024
+ throw new InvalidEntityError(`${field} cannot widen canonical policy`);
2025
+ }
2026
+ function compareCodeUnits(left, right) {
2027
+ return left < right ? -1 : left > right ? 1 : 0;
2028
+ }
1496
2029
  function toError(error) {
1497
2030
  return error instanceof Error ? error : new Error(String(error));
1498
2031
  }