@gnolith/taproot 0.2.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 (51) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/COMPATIBILITY.md +5 -3
  3. package/README.md +106 -33
  4. package/SECURITY.md +6 -3
  5. package/dist/authorization-maintenance.d.ts +55 -0
  6. package/dist/authorization-maintenance.d.ts.map +1 -0
  7. package/dist/authorization-maintenance.js +686 -0
  8. package/dist/authorization-maintenance.js.map +1 -0
  9. package/dist/authorization.d.ts +98 -0
  10. package/dist/authorization.d.ts.map +1 -0
  11. package/dist/authorization.js +1137 -0
  12. package/dist/authorization.js.map +1 -0
  13. package/dist/canonical.d.ts +2 -1
  14. package/dist/canonical.d.ts.map +1 -1
  15. package/dist/canonical.js +9 -1
  16. package/dist/canonical.js.map +1 -1
  17. package/dist/errors.d.ts +4 -0
  18. package/dist/errors.d.ts.map +1 -1
  19. package/dist/errors.js +4 -0
  20. package/dist/errors.js.map +1 -1
  21. package/dist/index.d.ts +125 -49
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +351 -44
  24. package/dist/index.js.map +1 -1
  25. package/dist/migrations.d.ts +9 -3
  26. package/dist/migrations.d.ts.map +1 -1
  27. package/dist/migrations.js +77 -5
  28. package/dist/migrations.js.map +1 -1
  29. package/dist/repository.d.ts +20 -9
  30. package/dist/repository.d.ts.map +1 -1
  31. package/dist/repository.js +593 -48
  32. package/dist/repository.js.map +1 -1
  33. package/dist/schema.d.ts +14 -5
  34. package/dist/schema.d.ts.map +1 -1
  35. package/dist/schema.js +461 -18
  36. package/dist/schema.js.map +1 -1
  37. package/dist/types.d.ts +59 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/docs/api.md +56 -18
  40. package/docs/architecture.md +19 -7
  41. package/docs/authorization.md +82 -0
  42. package/docs/completion-audit.md +15 -15
  43. package/docs/operations.md +28 -5
  44. package/docs/product-scope.md +5 -3
  45. package/docs/testing.md +18 -1
  46. package/docs/threat-model.md +30 -3
  47. package/examples/d1-diamond-interop/README.md +2 -1
  48. package/examples/d1-diamond-interop/demo.ts +150 -21
  49. package/migrations/0003_canonical_statement_text.sql +31 -0
  50. package/migrations/0004_canonical_authorization_policy.sql +213 -0
  51. package/package.json +4 -1
@@ -1,11 +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
7
  import { canonicalizeTaprootBaseIri } from './migrations.js';
8
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. */
9
11
  export class TaprootRepository {
10
12
  #db;
11
13
  #options;
@@ -29,6 +31,79 @@ export class TaprootRepository {
29
31
  ...(options.factory ? { factory: options.factory } : {}),
30
32
  };
31
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
+ }
32
107
  async getEntity(id) {
33
108
  const row = await this.#loadRow(id);
34
109
  if (!row)
@@ -265,11 +340,15 @@ export class TaprootRepository {
265
340
  async replaceEntity(id, replacement, edit) {
266
341
  if (replacement.id !== id)
267
342
  throw new InvalidEntityError('Replacement entity id cannot change');
268
- return this.#mutate(id, edit, () => cloneEntity(replacement), undefined, 'update');
343
+ return this.#mutate(id, edit, () => resupplyStatementTexts(cloneEntity(replacement), edit.statementTexts), undefined, 'update');
269
344
  }
270
345
  async revertEntity(id, targetRevision, edit) {
346
+ if (edit.authorization || edit.authorizationContext)
347
+ await this.#preauthorizeCanonicalWrite(id, edit.authorization, edit.authorizationContext, edit.expectedRevision);
271
348
  const target = await this.getEntityRevision(id, targetRevision);
272
- 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');
273
352
  }
274
353
  async softDeleteEntity(id, edit) {
275
354
  return this.#mutate(id, edit, (entity) => entity, {
@@ -286,22 +365,16 @@ export class TaprootRepository {
286
365
  async redirectEntity(id, target, edit) {
287
366
  if (id === target)
288
367
  throw new InvalidEntityError('An entity cannot redirect to itself');
289
- const source = await this.getEntity(id);
290
- const targetEntity = await this.getEntity(target);
291
- if (source.entity.type !== targetEntity.entity.type)
292
- throw new InvalidEntityError('Redirect source and target must have the same entity type');
293
- if (targetEntity.deletedAt)
294
- throw new InvalidEntityError('An entity cannot redirect to a deleted target');
295
- const seen = new Set([id]);
296
- let cursor = target;
297
- for (let depth = 0; cursor && depth < 100; depth += 1) {
298
- if (seen.has(cursor))
299
- throw new InvalidEntityError('Redirect would create a cycle');
300
- seen.add(cursor);
301
- cursor = (await this.getEntity(cursor)).redirectTo;
302
- }
303
- if (cursor)
304
- 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);
305
378
  return this.#mutate(id, edit, (entity) => entity, {
306
379
  deletedAt: null,
307
380
  redirectTo: target,
@@ -366,6 +439,7 @@ export class TaprootRepository {
366
439
  });
367
440
  }
368
441
  async addStatement(id, statement, edit) {
442
+ assertAuthoredStatementText(statement.text, statement.id);
369
443
  return this.#mutate(id, edit, (entity) => {
370
444
  const property = statement.mainsnak.property;
371
445
  entity.claims[property] ??= [];
@@ -374,6 +448,7 @@ export class TaprootRepository {
374
448
  });
375
449
  }
376
450
  async replaceStatement(id, statementId, statement, edit) {
451
+ assertAuthoredStatementText(statement.text, statement.id);
377
452
  return this.#mutate(id, edit, (entity) => {
378
453
  const located = locateStatement(entity, statementId);
379
454
  located.statements.splice(located.index, 1);
@@ -394,16 +469,21 @@ export class TaprootRepository {
394
469
  return entity;
395
470
  });
396
471
  }
397
- async setStatementRank(id, statementId, rank, edit) {
472
+ async setStatementRank(id, statementId, rank, text, edit) {
473
+ assertAuthoredStatementText(text, statementId);
398
474
  return this.#mutate(id, edit, (entity) => {
399
- locateStatement(entity, statementId).statement.rank = rank;
475
+ const statement = locateStatement(entity, statementId).statement;
476
+ statement.rank = rank;
477
+ statement.text = text;
400
478
  return entity;
401
479
  });
402
480
  }
403
- async addQualifier(id, statementId, snak, edit) {
481
+ async addQualifier(id, statementId, snak, text, edit) {
482
+ assertAuthoredStatementText(text, statementId);
404
483
  validateSnak(snak);
405
484
  return this.#mutate(id, edit, (entity) => {
406
485
  const statement = locateStatement(entity, statementId).statement;
486
+ statement.text = text;
407
487
  const property = snak.property;
408
488
  if (!statement.qualifiers[property]) {
409
489
  statement.qualifiers[property] = [];
@@ -413,9 +493,11 @@ export class TaprootRepository {
413
493
  return entity;
414
494
  });
415
495
  }
416
- async removeQualifier(id, statementId, property, ordinal, edit) {
496
+ async removeQualifier(id, statementId, property, ordinal, text, edit) {
497
+ assertAuthoredStatementText(text, statementId);
417
498
  return this.#mutate(id, edit, (entity) => {
418
499
  const statement = locateStatement(entity, statementId).statement;
500
+ statement.text = text;
419
501
  const snaks = statement.qualifiers[property];
420
502
  if (!snaks?.[ordinal])
421
503
  throw new InvalidStatementError('Qualifier does not exist');
@@ -427,16 +509,21 @@ export class TaprootRepository {
427
509
  return entity;
428
510
  });
429
511
  }
430
- async addReference(id, statementId, reference, edit) {
512
+ async addReference(id, statementId, reference, text, edit) {
513
+ assertAuthoredStatementText(text, statementId);
431
514
  return this.#mutate(id, edit, (entity) => {
432
- 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));
433
518
  return entity;
434
519
  });
435
520
  }
436
- async removeReference(id, statementId, hash, edit) {
521
+ async removeReference(id, statementId, hash, text, edit) {
522
+ assertAuthoredStatementText(text, statementId);
437
523
  return this.#mutate(id, edit, (entity) => {
438
- const references = locateStatement(entity, statementId).statement
439
- .references;
524
+ const statement = locateStatement(entity, statementId).statement;
525
+ statement.text = text;
526
+ const references = statement.references;
440
527
  const index = references.findIndex((reference) => reference.hash === hash);
441
528
  if (index < 0)
442
529
  throw new InvalidStatementError(`Reference ${hash} does not exist`);
@@ -444,10 +531,12 @@ export class TaprootRepository {
444
531
  return entity;
445
532
  });
446
533
  }
447
- async replaceReference(id, statementId, hash, reference, edit) {
534
+ async replaceReference(id, statementId, hash, reference, text, edit) {
535
+ assertAuthoredStatementText(text, statementId);
448
536
  return this.#mutate(id, edit, (entity) => {
449
- const references = locateStatement(entity, statementId).statement
450
- .references;
537
+ const statement = locateStatement(entity, statementId).statement;
538
+ statement.text = text;
539
+ const references = statement.references;
451
540
  const index = references.findIndex((item) => item.hash === hash);
452
541
  if (index < 0)
453
542
  throw new InvalidStatementError(`Reference ${hash} does not exist`);
@@ -462,6 +551,7 @@ export class TaprootRepository {
462
551
  }
463
552
  const succeeded = new Map();
464
553
  const failed = [];
554
+ let authorizationContext = options.metadata?.authorizationContext;
465
555
  let pending = values
466
556
  .map((entity, index) => ({ entity, index }))
467
557
  .sort((a, b) => Number(a.entity.type !== 'property') -
@@ -471,7 +561,20 @@ export class TaprootRepository {
471
561
  let progress = false;
472
562
  for (const entry of pending) {
473
563
  try {
474
- 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
+ };
475
578
  progress = true;
476
579
  }
477
580
  catch (error) {
@@ -518,11 +621,31 @@ export class TaprootRepository {
518
621
  }
519
622
  async #importOne(entity, options) {
520
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
+ }
521
640
  try {
522
641
  const current = await this.getEntity(entity.id);
523
642
  return await this.replaceEntity(entity.id, entity, {
524
643
  expectedRevision: current.entity.lastrevid,
644
+ statementTexts: authoredStatementTexts(entity),
525
645
  ...options.metadata,
646
+ ...(options.authorizations?.[entity.id]
647
+ ? { authorization: options.authorizations[entity.id] }
648
+ : {}),
526
649
  });
527
650
  }
528
651
  catch (error) {
@@ -530,7 +653,12 @@ export class TaprootRepository {
530
653
  throw error;
531
654
  }
532
655
  }
533
- 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
+ });
534
662
  }
535
663
  async applyCommands(id, commands, edit) {
536
664
  if (!commands.length)
@@ -721,12 +849,48 @@ export class TaprootRepository {
721
849
  deletedAt: stored.deletedAt,
722
850
  redirectTo: stored.redirectTo,
723
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;
724
866
  const statements = [
725
867
  this.#db
726
868
  .prepare(`DELETE FROM taproot_terms WHERE entity_id = ?`)
727
869
  .bind(id),
728
870
  this.#termsInsert(stored.entity),
729
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),
730
894
  ...patch.statements,
731
895
  ...this.#ownershipPatch(id, actual, expected).statements,
732
896
  ];
@@ -738,6 +902,8 @@ export class TaprootRepository {
738
902
  this.#emitObservation('repair', started, 'error', id, stored.entity.lastrevid, cause);
739
903
  if (isEventIdUniqueError(cause))
740
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 });
741
907
  throw cause;
742
908
  }
743
909
  const after = await this.inspectEntityIntegrity(id);
@@ -752,6 +918,8 @@ export class TaprootRepository {
752
918
  async #create(entity, metadata, allocated, eventType) {
753
919
  const started = performance.now();
754
920
  validateEntity(entity);
921
+ if (metadata.authorization || metadata.authorizationContext)
922
+ await this.#preauthorizeCanonicalWrite(entity.id, metadata.authorization, metadata.authorizationContext);
755
923
  if (await this.#loadRow(entity.id))
756
924
  throw new EntityAlreadyExistsError(`Entity ${entity.id} already exists`);
757
925
  await this.#validatePropertyDatatypes(entity);
@@ -759,6 +927,9 @@ export class TaprootRepository {
759
927
  const json = exportEntityJson(entity, this.#options.maxEntityBytes);
760
928
  const lifecycle = { deletedAt: null, redirectTo: null };
761
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;
762
933
  const newQuads = this.#lifecycleQuads(entity, lifecycle);
763
934
  const marker = revisionQuad(entity, this.#options);
764
935
  const patch = this.#preparePatch({ forbid: [marker], insert: newQuads });
@@ -777,7 +948,9 @@ export class TaprootRepository {
777
948
  }
778
949
  statements.push(this.#db
779
950
  .prepare(`INSERT INTO taproot_entities(entity_id, entity_type, datatype, revision, entity_json, modified_at) VALUES (?, ?, ?, ?, ?, ?)`)
780
- .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));
781
954
  const patchOffset = statements.length;
782
955
  statements.push(...patch.statements);
783
956
  const ownership = this.#ownershipPatch(entity.id, [], newQuads);
@@ -796,6 +969,12 @@ export class TaprootRepository {
796
969
  },
797
970
  eventId: context.eventId,
798
971
  contentHash: context.contentHash,
972
+ ...(authorization
973
+ ? {
974
+ authorizationRevision: authorization.authorizationRevision,
975
+ searchGeneration: authorization.searchGeneration,
976
+ }
977
+ : {}),
799
978
  };
800
979
  this.#emitObservation(eventType, started, 'success', entity.id, entity.lastrevid);
801
980
  return result;
@@ -812,13 +991,17 @@ export class TaprootRepository {
812
991
  }
813
992
  if (isEventIdUniqueError(mapped))
814
993
  throw new RevisionConflictError('Generated audit event id already exists', { cause: mapped });
815
- if (allocated && isAssertionError(mapped))
816
- 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 });
817
998
  throw mapped;
818
999
  }
819
1000
  }
820
1001
  async #mutate(id, edit, transform, lifecycleOverride, eventType = 'update') {
821
1002
  const started = performance.now();
1003
+ if (edit.authorization || edit.authorizationContext)
1004
+ await this.#preauthorizeCanonicalWrite(id, edit.authorization, edit.authorizationContext, edit.expectedRevision);
822
1005
  const row = await this.#loadRow(id);
823
1006
  if (!row)
824
1007
  throw new EntityNotFoundError(`Entity ${id} was not found`);
@@ -850,6 +1033,9 @@ export class TaprootRepository {
850
1033
  (await revisionContentHash(row.entity_json, oldLifecycle));
851
1034
  const newLifecycle = lifecycleOverride ?? oldLifecycle;
852
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;
853
1039
  const oldQuads = this.#lifecycleQuads(stored.entity, oldLifecycle);
854
1040
  const newQuads = this.#lifecycleQuads(next, newLifecycle);
855
1041
  const patch = this.#preparePatch({
@@ -863,12 +1049,14 @@ export class TaprootRepository {
863
1049
  .bind(next.type === 'property' ? next.datatype : null, next.lastrevid, json, next.modified, newLifecycle.deletedAt, newLifecycle.redirectTo, id, previous),
864
1050
  this.#assertion(`EXISTS (SELECT 1 FROM taproot_entities WHERE entity_id = ? AND revision = ?)`, id, next.lastrevid),
865
1051
  this.#revisionInsert(next, json, edit, context),
866
- this.#auditInsert(next, edit, context),
1052
+ this.#auditInsert(next, edit, context, authorization),
867
1053
  this.#db
868
1054
  .prepare('DELETE FROM taproot_terms WHERE entity_id = ?')
869
1055
  .bind(id),
870
1056
  this.#termsInsert(next),
871
1057
  ];
1058
+ if (authorization)
1059
+ statements.push(...this.#authorizationWriteStatements(next, newLifecycle, context, authorization, previous));
872
1060
  const patchOffset = statements.length;
873
1061
  statements.push(...patch.statements);
874
1062
  const ownership = this.#ownershipPatch(id, oldQuads, newQuads);
@@ -887,6 +1075,12 @@ export class TaprootRepository {
887
1075
  },
888
1076
  eventId: context.eventId,
889
1077
  contentHash: context.contentHash,
1078
+ ...(authorization
1079
+ ? {
1080
+ authorizationRevision: authorization.authorizationRevision,
1081
+ searchGeneration: authorization.searchGeneration,
1082
+ }
1083
+ : {}),
890
1084
  };
891
1085
  this.#emitObservation(eventType, started, 'success', id, next.lastrevid);
892
1086
  return result;
@@ -907,6 +1101,295 @@ export class TaprootRepository {
907
1101
  throw mapped;
908
1102
  }
909
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
+ }
910
1393
  #preparePatch(patch) {
911
1394
  try {
912
1395
  return prepareQuadPatch(this.#db, patch);
@@ -1037,13 +1520,24 @@ export class TaprootRepository {
1037
1520
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1038
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);
1039
1522
  }
1040
- #auditInsert(entity, metadata, context) {
1523
+ #auditInsert(entity, metadata, context, authorization) {
1041
1524
  return this.#db
1042
1525
  .prepare(`INSERT INTO taproot_audit_events(
1043
1526
  event_id, entity_id, revision, event_type, attribution_json, edit_summary,
1044
1527
  tags_json, request_id, content_hash, parent_hash, details_json, created_at
1045
1528
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1046
- .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);
1047
1541
  }
1048
1542
  async #writeContext(entity, json, metadata, parentHash, eventType, lifecycle) {
1049
1543
  const attribution = normalizeAttribution(metadata);
@@ -1227,6 +1721,26 @@ function locateStatement(entity, id) {
1227
1721
  }
1228
1722
  throw new InvalidStatementError(`Statement ${id} does not exist`);
1229
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
+ }
1230
1744
  function collectSnak(snak, used) {
1231
1745
  const current = used.get(snak.property);
1232
1746
  if (current && current !== snak.datatype)
@@ -1234,6 +1748,15 @@ function collectSnak(snak, used) {
1234
1748
  used.set(snak.property, snak.datatype);
1235
1749
  }
1236
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);
1237
1760
  switch (command.type) {
1238
1761
  case 'set-label':
1239
1762
  entity.labels[command.language] = {
@@ -1302,10 +1825,13 @@ function applyEntityCommand(entity, command) {
1302
1825
  case 'set-statement-rank':
1303
1826
  locateStatement(entity, command.statementId).statement.rank =
1304
1827
  command.rank;
1828
+ locateStatement(entity, command.statementId).statement.text =
1829
+ command.text;
1305
1830
  break;
1306
1831
  case 'add-qualifier': {
1307
1832
  validateSnak(command.snak);
1308
1833
  const statement = locateStatement(entity, command.statementId).statement;
1834
+ statement.text = command.text;
1309
1835
  const property = command.snak.property;
1310
1836
  if (!statement.qualifiers[property]) {
1311
1837
  statement.qualifiers[property] = [];
@@ -1316,6 +1842,7 @@ function applyEntityCommand(entity, command) {
1316
1842
  }
1317
1843
  case 'remove-qualifier': {
1318
1844
  const statement = locateStatement(entity, command.statementId).statement;
1845
+ statement.text = command.text;
1319
1846
  const snaks = statement.qualifiers[command.property];
1320
1847
  if (!snaks?.[command.ordinal])
1321
1848
  throw new InvalidStatementError('Qualifier does not exist');
@@ -1326,12 +1853,16 @@ function applyEntityCommand(entity, command) {
1326
1853
  }
1327
1854
  break;
1328
1855
  }
1329
- case 'add-reference':
1330
- 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));
1331
1860
  break;
1861
+ }
1332
1862
  case 'replace-reference': {
1333
- const references = locateStatement(entity, command.statementId).statement
1334
- .references;
1863
+ const statement = locateStatement(entity, command.statementId).statement;
1864
+ statement.text = command.text;
1865
+ const references = statement.references;
1335
1866
  const index = references.findIndex(({ hash }) => hash === command.hash);
1336
1867
  if (index < 0)
1337
1868
  throw new InvalidStatementError(`Reference ${command.hash} does not exist`);
@@ -1339,8 +1870,9 @@ function applyEntityCommand(entity, command) {
1339
1870
  break;
1340
1871
  }
1341
1872
  case 'remove-reference': {
1342
- const references = locateStatement(entity, command.statementId).statement
1343
- .references;
1873
+ const statement = locateStatement(entity, command.statementId).statement;
1874
+ statement.text = command.text;
1875
+ const references = statement.references;
1344
1876
  const index = references.findIndex(({ hash }) => hash === command.hash);
1345
1877
  if (index < 0)
1346
1878
  throw new InvalidStatementError(`Reference ${command.hash} does not exist`);
@@ -1365,10 +1897,10 @@ function isUniqueEntityError(cause) {
1365
1897
  return /UNIQUE constraint failed: taproot_entities\.entity_id/iu.test(cause.message);
1366
1898
  }
1367
1899
  function isRevisionUniqueError(cause) {
1368
- 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));
1369
1901
  }
1370
1902
  function isEventIdUniqueError(cause) {
1371
- 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));
1372
1904
  }
1373
1905
  function normalizeAttribution(metadata) {
1374
1906
  if (metadata.actor !== undefined && typeof metadata.actor !== 'string')
@@ -1481,6 +2013,19 @@ function sameQuads(left, right) {
1481
2013
  const b = [...new Set(right.map(quadKeyValue))].sort();
1482
2014
  return JSON.stringify(a) === JSON.stringify(b);
1483
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
+ }
1484
2029
  function toError(error) {
1485
2030
  return error instanceof Error ? error : new Error(String(error));
1486
2031
  }