@haverstack/core 0.37.0 → 0.39.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 (80) hide show
  1. package/README.md +19 -15
  2. package/dist/access.d.ts +4 -6
  3. package/dist/access.d.ts.map +1 -1
  4. package/dist/access.js +7 -8
  5. package/dist/access.js.map +1 -1
  6. package/dist/adapter-entry.d.ts +7 -6
  7. package/dist/adapter-entry.d.ts.map +1 -1
  8. package/dist/adapter-entry.js +6 -5
  9. package/dist/adapter-entry.js.map +1 -1
  10. package/dist/attachment-download.d.ts +3 -1
  11. package/dist/attachment-download.d.ts.map +1 -1
  12. package/dist/attachment-download.js +1 -1
  13. package/dist/attachment-download.js.map +1 -1
  14. package/dist/changes.d.ts +22 -16
  15. package/dist/changes.d.ts.map +1 -1
  16. package/dist/changes.js +43 -32
  17. package/dist/changes.js.map +1 -1
  18. package/dist/combine.d.ts +1 -1
  19. package/dist/combine.js +6 -6
  20. package/dist/combine.js.map +1 -1
  21. package/dist/errors.d.ts +24 -12
  22. package/dist/errors.d.ts.map +1 -1
  23. package/dist/errors.js +32 -17
  24. package/dist/errors.js.map +1 -1
  25. package/dist/grants.d.ts +17 -25
  26. package/dist/grants.d.ts.map +1 -1
  27. package/dist/grants.js +27 -14
  28. package/dist/grants.js.map +1 -1
  29. package/dist/identity-bindings.js +2 -2
  30. package/dist/index.d.ts +12 -8
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +12 -6
  33. package/dist/index.js.map +1 -1
  34. package/dist/query-validation.d.ts +28 -11
  35. package/dist/query-validation.d.ts.map +1 -1
  36. package/dist/query-validation.js +95 -36
  37. package/dist/query-validation.js.map +1 -1
  38. package/dist/record-changes.d.ts +11 -10
  39. package/dist/record-changes.d.ts.map +1 -1
  40. package/dist/record-changes.js +33 -25
  41. package/dist/record-changes.js.map +1 -1
  42. package/dist/record-id.d.ts +3 -3
  43. package/dist/record-id.js +8 -8
  44. package/dist/record-id.js.map +1 -1
  45. package/dist/scoped-stack.d.ts +71 -34
  46. package/dist/scoped-stack.d.ts.map +1 -1
  47. package/dist/scoped-stack.js +107 -88
  48. package/dist/scoped-stack.js.map +1 -1
  49. package/dist/stack-reads.d.ts +2 -2
  50. package/dist/stack-reads.js +5 -5
  51. package/dist/stack-reads.js.map +1 -1
  52. package/dist/stack.d.ts +132 -161
  53. package/dist/stack.d.ts.map +1 -1
  54. package/dist/stack.js +287 -288
  55. package/dist/stack.js.map +1 -1
  56. package/dist/testing.d.ts +17 -17
  57. package/dist/testing.d.ts.map +1 -1
  58. package/dist/testing.js +46 -42
  59. package/dist/testing.js.map +1 -1
  60. package/dist/types.d.ts +169 -143
  61. package/dist/types.d.ts.map +1 -1
  62. package/dist/types.js +2 -2
  63. package/dist/types.js.map +1 -1
  64. package/dist/wire-body.d.ts +71 -0
  65. package/dist/wire-body.d.ts.map +1 -0
  66. package/dist/wire-body.js +138 -0
  67. package/dist/wire-body.js.map +1 -0
  68. package/dist/wire-entry.d.ts +9 -8
  69. package/dist/wire-entry.d.ts.map +1 -1
  70. package/dist/wire-entry.js +13 -14
  71. package/dist/wire-entry.js.map +1 -1
  72. package/dist/wire-record.d.ts +4 -4
  73. package/dist/wire-record.d.ts.map +1 -1
  74. package/dist/wire-record.js +35 -22
  75. package/dist/wire-record.js.map +1 -1
  76. package/dist/wire-request.d.ts +37 -4
  77. package/dist/wire-request.d.ts.map +1 -1
  78. package/dist/wire-request.js +288 -79
  79. package/dist/wire-request.js.map +1 -1
  80. package/package.json +1 -1
package/dist/stack.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * - Content validation on write
10
10
  * - Migration registry and explicit, owner-driven migrateAll()
11
11
  * - Version snapshotting on update
12
- * - Soft and hard delete
12
+ * - Soft delete and purge
13
13
  *
14
14
  * Apps should never talk to a StackAdapter directly.
15
15
  */
@@ -21,15 +21,15 @@ import { hasGroupAdmin, isGroupAdminAssociation, validatePermissions } from './a
21
21
  import { compareRecordedAttachments } from './attachment-download.js';
22
22
  import { ChangeEmitter, RelayDelivery, PendingChange, assertSinceUsable } from './changes.js';
23
23
  import { SYSTEM_TYPES } from './types.js';
24
- import { StackClosedError, StackConflictError, StackMigrationError, StackNotFoundError, StackPermissionError, StackQueryError, StackSchemaDriftError, StackValidationError, StackVersionConflictError, } from './errors.js';
25
- import { assertQueryCapabilities, assertSortCapability, assertValidRelatedTo, assertValidJournalQuery, assertValidSort, assertAuthorityAssociations, assertDataAssociations, filtersContent, validateAssociation, validateAssociations, } from './query-validation.js';
24
+ import { UseAfterCloseError, InvalidAdapterError, StackConflictError, StackMigrationError, StackNotFoundError, StackBadRequestError, StackSchemaDriftError, StackValidationError, StackVersionConflictError, } from './errors.js';
25
+ import { assertQueryCapabilities, assertSortCapability, assertValidAssociationFilters, assertValidJournalQuery, assertValidSort, assertAuthorityAssociations, assertDataAssociations, filtersContent, validateAssociation, validateAssociations, } from './query-validation.js';
26
26
  import { GRANT_ACTION_SET, READ_COMPANIONS, grantConveys, grantReach, matchesGrantTarget, validateGrantTarget, validateGrantee, grantCoversGrantee, UNGRANTABLE_SYSTEM_TYPES, loadGrantRecords, } from './grants.js';
27
27
  import { bindingFieldsOf, uniqueBindingFieldsOf } from './identity-bindings.js';
28
28
  import { assertAttachmentSize, assertContentSize } from './limits.js';
29
29
  import { validateParentId, validateRecordId, validateClockField, validateIdTimestampSkew, DEFAULT_ID_TIMESTAMP_SKEW_MS, } from './record-id.js';
30
30
  import { queryAllPages, findFirstMatch, lookupEntityByDid, MAX_QUERY_LIMIT, } from './stack-reads.js';
31
31
  import { associationDelta, associationEqual, associationIdentical, assertNonEmptyChangeSet, changeSetOps, bumpsVersion, takesIfVersion, effectiveChanges, stampGroupAdmin, isGroupRecord, } from './record-changes.js';
32
- import { ScopedStack } from './scoped-stack.js';
32
+ import { ScopedStack, scopeToken } from './scoped-stack.js';
33
33
  // -------------------------------------------------------
34
34
  // Supporting types
35
35
  // -------------------------------------------------------
@@ -46,6 +46,25 @@ const DEFAULT_GC_GRACE_MS = 24 * 60 * 60 * 1000;
46
46
  * § Reparenting.
47
47
  */
48
48
  const MAX_PARENT_DEPTH = 64;
49
+ /**
50
+ * An Actor in the one form `Stack` stores: a `principalId` equal to the
51
+ * subject is dropped, so "acted as itself" has a single spelling, and an
52
+ * empty id is refused rather than stored as a name for nobody.
53
+ * See docs/spec/data-model.md § Actor.
54
+ */
55
+ export function normalizeActor(actor) {
56
+ if (!actor)
57
+ return undefined;
58
+ for (const field of ['subjectId', 'principalId']) {
59
+ if (actor[field] === '') {
60
+ throw new StackBadRequestError(`Invalid ${field}: the empty string is not an id.`);
61
+ }
62
+ }
63
+ const { subjectId, principalId } = actor;
64
+ return principalId === undefined || principalId === subjectId
65
+ ? { subjectId }
66
+ : { subjectId, principalId };
67
+ }
49
68
  /** Sentinel: filter.baseId resolved to zero matching types. */
50
69
  const EMPTY_FAMILY = Symbol('empty-family');
51
70
  /**
@@ -109,27 +128,7 @@ export class Stack {
109
128
  static createActor(record) {
110
129
  if (!record.updatedBy)
111
130
  return undefined;
112
- return {
113
- entityId: record.updatedBy,
114
- ...(record.updatedVia !== undefined && { principalId: record.updatedVia }),
115
- ...(record.appId !== undefined && { appId: record.appId }),
116
- };
117
- }
118
- /**
119
- * The requester behind a write that stamps nothing on the record itself —
120
- * a hard delete, which leaves no record to stamp, and associate()/
121
- * dissociate(), which don't bump and so don't touch updatedBy/updatedVia.
122
- * Hard delete is owner-acting-alone only, so it never has a principal to
123
- * name beside the subject; associate()/dissociate() can run delegated,
124
- * so `updatedVia` rides along here when present.
125
- */
126
- static actorFrom(opts) {
127
- if (!opts.updatedBy)
128
- return undefined;
129
- return {
130
- entityId: opts.updatedBy,
131
- ...(opts.updatedVia !== undefined && { principalId: opts.updatedVia }),
132
- };
131
+ return { ...record.updatedBy, ...(record.appId !== undefined && { appId: record.appId }) };
133
132
  }
134
133
  async getTypeCached(id) {
135
134
  const cached = this.typeCache.get(id);
@@ -141,12 +140,12 @@ export class Stack {
141
140
  return type;
142
141
  }
143
142
  /**
144
- * Create a Stack instance. Reads ownerEntityId and timezone from the adapter.
143
+ * Open a Stack over an adapter. Reads ownerEntityId and timezone from the adapter.
145
144
  */
146
- static async create(adapter, opts = {}) {
145
+ static async open(adapter, opts = {}) {
147
146
  if (!adapter.ownerEntityId) {
148
- throw new Error('Stack misconfiguration: adapter has no ownerEntityId. ' +
149
- 'Initialise the adapter with an entityId before calling Stack.create().');
147
+ throw new InvalidAdapterError('Invalid adapter: adapter has no ownerEntityId. ' +
148
+ 'Initialize the adapter with an ownerEntityId before calling Stack.open().');
150
149
  }
151
150
  const stack = new Stack(adapter, opts.idTimestampSkewMs === undefined ? DEFAULT_ID_TIMESTAMP_SKEW_MS : opts.idTimestampSkewMs);
152
151
  await stack.seedSystemTypes();
@@ -180,7 +179,7 @@ export class Stack {
180
179
  return this.adapter.ownerEntityId;
181
180
  }
182
181
  async getEntityByDid(did) {
183
- return lookupEntityByDid((q) => this.query(q), did, filtersContent(this.features), true);
182
+ return lookupEntityByDid((q) => this.query(q), did, filtersContent(this.capabilities), true);
184
183
  }
185
184
  async getOwnerEntity() {
186
185
  return this.getEntityByDid(this.ownerEntityId);
@@ -188,42 +187,43 @@ export class Stack {
188
187
  get timezone() {
189
188
  return this.adapter.timezone;
190
189
  }
191
- get features() {
190
+ get capabilities() {
192
191
  return this.adapter.capabilities;
193
192
  }
194
193
  /**
195
194
  * Get a permission-scoped view of this Stack, as if requests came from
196
- * the given principal (null = anonymous). Plain Stack methods are
197
- * unscoped; use asEntity() when one Stack serves multiple, possibly
198
- * untrusted, entities.
199
- *
200
- * `onBehalfOf` names the subject a delegated app acts for: authority is
201
- * then the intersection of both parties' grants, while authorship and
202
- * `-own` resolve against the subject. See
195
+ * the given entity acting as itself (null = anonymous). Plain Stack
196
+ * methods are unscoped; use asEntity() when one Stack serves multiple,
197
+ * possibly untrusted, entities. See
203
198
  * docs/spec/access-control.md § Enforcement: Stack.asEntity().
204
199
  */
205
- asEntity(entityId, opts = {}) {
206
- this.assertOpen();
207
- if (opts.onBehalfOf && !entityId) {
208
- throw new StackPermissionError('An anonymous principal cannot act on behalf of an entity');
209
- }
210
- return new ScopedStack(this, entityId, opts.onBehalfOf ?? entityId, this.idTimestampSkewMsValue, this.adapter, this.changes);
200
+ asEntity(entityId) {
201
+ return entityId === null ? this.scope(null, null) : this.asActor({ subjectId: entityId });
211
202
  }
212
203
  /**
213
- * Scope to an authenticated session — what StackTokenStore.lookupToken()
214
- * returns. Equivalent to asEntity(principalId, { onBehalfOf: subjectId }),
215
- * and what a server should reach for at its request boundary.
216
- *
217
- * Both identities are DIDs, so passing them positionally leaves nothing
218
- * to catch a swap, and a swapped pair is undetectable in the undelegated
219
- * case where they are equal — it would surface only once delegation is in
220
- * use, as authority no longer fenced by the app's grants and every write
221
- * attributed to the app rather than the person. Taking the pair whole
222
- * removes the order to get wrong. See
223
- * docs/spec/access-control.md § Delegation: principal and subject.
204
+ * Scope to an Actor — a delegated app acting for its user, or a
205
+ * TokenSession exactly as StackTokenStore.lookupToken() returns it, which
206
+ * is what a server should reach for at its request boundary. Authority is
207
+ * the intersection of both parties' grants, while authorship and `-own`
208
+ * resolve against the subject. Taking the pair whole leaves no order to
209
+ * swap. See docs/spec/access-control.md § Delegation: principal and subject.
224
210
  */
225
- forSession(session) {
226
- return this.asEntity(session.principalId, { onBehalfOf: session.subjectId });
211
+ asActor(actor) {
212
+ const { subjectId, principalId = subjectId } = normalizeActor(actor);
213
+ return this.scope(principalId, subjectId);
214
+ }
215
+ scope(principalId, subjectId) {
216
+ this.assertOpen();
217
+ return new ScopedStack(scopeToken, {
218
+ stack: this,
219
+ principalId,
220
+ subjectId,
221
+ idTimestampSkewMs: this.idTimestampSkewMsValue,
222
+ adapter: this.adapter,
223
+ changes: this.changes,
224
+ assertOpen: () => this.assertOpen(),
225
+ relaysChanges: this.relaysChanges,
226
+ });
227
227
  }
228
228
  // -------------------------------------------------------
229
229
  // Types
@@ -235,11 +235,11 @@ export class Stack {
235
235
  * anything beyond additive evolution throws StackSchemaDriftError. See
236
236
  * docs/spec/data-model.md § Schema drift detection.
237
237
  */
238
- async defineType(id, name, schema, opts = {}) {
238
+ async defineType({ id, name, schema, migratesFrom }) {
239
239
  this.assertOpen();
240
240
  const parsed = parseTypeId(id);
241
241
  if (!parsed) {
242
- throw new StackQueryError(`Invalid TypeId format: "${id}". Expected "namespace/name@version", e.g. "com.example.myapp/note@1".`);
242
+ throw new StackBadRequestError(`Invalid TypeId format: "${id}". Expected "namespace/name@version", e.g. "com.example.myapp/note@1".`);
243
243
  }
244
244
  // Register this version even on the idempotent-no-op path —
245
245
  // presentAtLatest()'s stale-writer detection depends on it.
@@ -284,7 +284,7 @@ export class Stack {
284
284
  schema,
285
285
  schemaHash,
286
286
  createdAt: existing?.createdAt ?? new Date(),
287
- ...(opts.migratesFrom && { migratesFrom: opts.migratesFrom }),
287
+ ...(migratesFrom && { migratesFrom }),
288
288
  };
289
289
  await this.adapter.saveType(type);
290
290
  this.typeCache.set(id, type);
@@ -374,12 +374,12 @@ export class Stack {
374
374
  * too, validates each result before writing, and aborts on the first
375
375
  * validation failure. See docs/spec/data-model.md § Type migrations.
376
376
  */
377
- async migrateAll(baseTypeId) {
377
+ async migrateAll(baseId) {
378
378
  this.assertOpen();
379
379
  const types = await this.adapter.listTypes();
380
- const familyTypeIds = types.filter((t) => t.baseId === baseTypeId).map((t) => t.id);
380
+ const familyTypeIds = types.filter((t) => t.baseId === baseId).map((t) => t.id);
381
381
  if (familyTypeIds.length === 0) {
382
- throw new StackMigrationError(`migrateAll: no registered types found for baseTypeId "${baseTypeId}"`);
382
+ throw new StackMigrationError(`migrateAll: no registered types found for baseId "${baseId}"`);
383
383
  }
384
384
  let migrated = 0;
385
385
  for (const typeId of familyTypeIds) {
@@ -431,7 +431,7 @@ export class Stack {
431
431
  this.assertOpen();
432
432
  const type = await this.getTypeCached(typeId);
433
433
  if (!type) {
434
- throw new StackQueryError(`Unknown type: "${typeId}". Call defineType() first.`);
434
+ throw new StackBadRequestError(`Unknown type: "${typeId}". Call defineType() first.`);
435
435
  }
436
436
  // Copied, never aliased: an import loop that reuses one Date across rows
437
437
  // (`d.setTime(...)` per record) would otherwise retro-edit every record
@@ -468,7 +468,7 @@ export class Stack {
468
468
  if (errors.length > 0) {
469
469
  throw new StackValidationError(errors);
470
470
  }
471
- assertContentSize(content, this.features.limits.contentBytes, 'Content');
471
+ assertContentSize(content, this.capabilities.limits.contentBytes, 'Content');
472
472
  if (typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
473
473
  await this.checkAttachmentMimeTypeOnCreate(content);
474
474
  }
@@ -489,8 +489,9 @@ export class Stack {
489
489
  if (opts.parentId !== undefined)
490
490
  await this.assertNoParentCycle(opts.id, opts.parentId);
491
491
  }
492
+ const createdBy = normalizeActor(opts.createdBy);
492
493
  const associations = baseIdOf(typeId) === SYSTEM_TYPES.GROUP
493
- ? stampGroupAdmin(opts.associations, opts.entityId ?? this.ownerEntityId)
494
+ ? stampGroupAdmin(opts.associations, createdBy?.subjectId ?? this.ownerEntityId)
494
495
  : opts.associations;
495
496
  // createdAt drives the id, so the two agree by construction rather than
496
497
  // by coincidence. An explicit one mints via generateIdForTimestamp(),
@@ -504,10 +505,8 @@ export class Stack {
504
505
  // An id field a caller supplies is a value or it is absent — never the
505
506
  // empty string, which names nobody. Refused rather than dropped, so the
506
507
  // caller is never silently ignored.
507
- for (const field of ['entityId', 'appId', 'principalId']) {
508
- if (opts[field] === '') {
509
- throw new StackQueryError(`Invalid ${field}: the empty string is not an id.`);
510
- }
508
+ if (opts.appId === '') {
509
+ throw new StackBadRequestError('Invalid appId: the empty string is not an id.');
511
510
  }
512
511
  // Every create naming a parent owes the reference check, whether or not
513
512
  // it supplied an id: a destination a caller names has to exist.
@@ -523,14 +522,11 @@ export class Stack {
523
522
  // Presence, not truthiness: '' is refused above, so absence is the
524
523
  // only thing a falsy value could mean here.
525
524
  ...(opts.parentId !== undefined && { parentId: opts.parentId }),
526
- ...(opts.entityId !== undefined && { entityId: opts.entityId }),
527
525
  ...(opts.appId !== undefined && { appId: opts.appId }),
528
- ...(opts.principalId !== undefined && { principalId: opts.principalId }),
529
- // A create's actor is its author, so these are derived rather than
530
- // taken: stamping them here keeps "absent means an unscoped write"
526
+ // A create's actor is its author, so `updatedBy` is derived rather
527
+ // than taken: stamping it here keeps "absent means an unscoped write"
531
528
  // true of version 1 as it is of every later version.
532
- ...(opts.entityId && { updatedBy: opts.entityId }),
533
- ...(opts.principalId && { updatedVia: opts.principalId }),
529
+ ...(createdBy && { createdBy, updatedBy: createdBy }),
534
530
  ...(opts.permissions?.length && { permissions: opts.permissions }),
535
531
  ...(associations?.length && { associations }),
536
532
  ...(opts.unlisted && { unlistedAt: createdAt }),
@@ -614,11 +610,11 @@ export class Stack {
614
610
  // A change set naming only aspects the journal already keeps in full
615
611
  // doesn't bump — the same rule associate()/dissociate() follow
616
612
  // unconditionally. Its actor travels as an explicit opt rather than a
617
- // record stamp, since a non-bumping write never touches
618
- // updatedBy/updatedVia. See docs/spec/versioning.md § Version history.
613
+ // record stamp, since a non-bumping write never touches `updatedBy`.
614
+ // See docs/spec/versioning.md § Version history.
619
615
  const bumps = bumpsVersion(ops);
620
616
  // Computed against the same before/after changeSetOps compared, so
621
- // whether associate/dissociate/permissions appear in `ops` and what
617
+ // whether associate/dissociate/reshare appear in `ops` and what
622
618
  // the journal lists can never disagree. Both halves of the partition
623
619
  // produce the same tagged edits and travel as one list: the journal's
624
620
  // argument is prior state, and an ACL element's is no different from a
@@ -633,17 +629,12 @@ export class Stack {
633
629
  ];
634
630
  const previousParentId = existing.parentId ?? null;
635
631
  const change = new PendingChange(ops, {
636
- actor: Stack.actorFrom(opts),
632
+ actor: normalizeActor(opts.actor),
637
633
  ...(ops.includes('reparent') && { previousParentId }),
638
634
  ...(assocDelta.length && { associations: assocDelta }),
639
635
  });
640
636
  const updated = await this.adapter.mutateRecord(id, effectiveChanges(changes, ops), {
641
- ...(bumps
642
- ? this.writeOptions(existing, opts)
643
- : {
644
- updatedBy: opts.updatedBy,
645
- updatedVia: opts.updatedVia,
646
- }),
637
+ ...(bumps ? this.writeOptions(existing, opts) : { actor: normalizeActor(opts.actor) }),
647
638
  bumpsVersion: bumps,
648
639
  journal: change.journal,
649
640
  });
@@ -700,10 +691,10 @@ export class Stack {
700
691
  if (contentPatch) {
701
692
  // The patch is what travels, so the patch is what's measured — a
702
693
  // small patch against a large record is not an oversized request.
703
- assertContentSize(contentPatch, this.features.limits.contentBytes, 'Patch');
694
+ assertContentSize(contentPatch, this.capabilities.limits.contentBytes, 'Patch');
704
695
  const type = await this.getTypeCached(existing.typeId);
705
696
  if (!type) {
706
- throw new StackQueryError(`Unknown type: "${existing.typeId}"`);
697
+ throw new StackBadRequestError(`Unknown type: "${existing.typeId}"`);
707
698
  }
708
699
  merged = applyMergePatch(existing.content, contentPatch);
709
700
  const contentErrors = [
@@ -781,7 +772,7 @@ export class Stack {
781
772
  const replaced = (existing.associations ?? []).find((a) => associationEqual(a, association));
782
773
  await this.checkAttachmentAssociationPointers([association]);
783
774
  const change = new PendingChange('associate', {
784
- actor: Stack.actorFrom(opts),
775
+ actor: normalizeActor(opts.actor),
785
776
  // A re-point carries what it overwrote: nothing else retains the
786
777
  // attachmentRecordId that association held.
787
778
  associations: [
@@ -821,7 +812,7 @@ export class Stack {
821
812
  this.assertGroupAdminRemains(existing, (existing.associations ?? []).filter((a) => !associationEqual(a, association)));
822
813
  }
823
814
  const change = new PendingChange('dissociate', {
824
- actor: Stack.actorFrom(opts),
815
+ actor: normalizeActor(opts.actor),
825
816
  // In full, annotation included — what makes a removal as undoable
826
817
  // from the log as a re-point is. The frame this becomes still names
827
818
  // identity only. See docs/spec/journal.md § The entry.
@@ -849,8 +840,8 @@ export class Stack {
849
840
  if (current.some((p) => associationEqual(p, permission)))
850
841
  return existing;
851
842
  this.assertPermissionSet([...current, permission]);
852
- const change = new PendingChange('permissions', {
853
- actor: Stack.actorFrom(opts),
843
+ const change = new PendingChange('reshare', {
844
+ actor: normalizeActor(opts.actor),
854
845
  associations: [{ op: 'add', association: permission }],
855
846
  });
856
847
  const updated = await this.adapter.associate(id, permission, { journal: change.journal });
@@ -874,8 +865,8 @@ export class Stack {
874
865
  if (!matched)
875
866
  return existing;
876
867
  this.assertPermissionSet(current.filter((p) => !associationEqual(p, permission)));
877
- const change = new PendingChange('permissions', {
878
- actor: Stack.actorFrom(opts),
868
+ const change = new PendingChange('reshare', {
869
+ actor: normalizeActor(opts.actor),
879
870
  associations: [{ op: 'remove', previous: matched }],
880
871
  });
881
872
  const updated = await this.adapter.dissociate(id, permission, { journal: change.journal });
@@ -948,7 +939,7 @@ export class Stack {
948
939
  }
949
940
  }
950
941
  /**
951
- * Soft-delete a record (default) or hard-delete it permanently, removing
942
+ * Soft-delete a record (default) or purge it permanently, removing
952
943
  * the record and its version history. Soft delete snapshots and bumps
953
944
  * version; a no-op if already deleted. `_config` is never deletable
954
945
  * (docs/spec.md § The `_config` record). See docs/spec/versioning.md
@@ -971,8 +962,8 @@ export class Stack {
971
962
  }
972
963
  /**
973
964
  * delete(), reporting the record it acted on alongside the files it
974
- * stranded: as it stood immediately before destruction for a hard
975
- * delete, or as the resulting tombstone for a soft delete. Both are
965
+ * stranded: as it stood immediately before destruction for a
966
+ * purge, or as the resulting tombstone for a soft delete. Both are
976
967
  * captured inside the same write that destroys or tombstones the record,
977
968
  * never a read beforehand — a read-then-delete would leave a window
978
969
  * where a concurrent write can land, get destroyed or overwritten by
@@ -983,15 +974,15 @@ export class Stack {
983
974
  if (id === SYSTEM_TYPES.CONFIG) {
984
975
  throw new StackConflictError("Cannot delete the _config record: it holds the stack's identity and is required for every permission check.");
985
976
  }
986
- if (opts.hard) {
977
+ if (opts.purge) {
987
978
  // The adapter hands back what it destroyed, captured inside the same
988
979
  // write: a read here instead would race the delete, and afterwards
989
980
  // there is nothing left to read. Null means there was no record, so
990
981
  // nothing was purged and nothing is announced.
991
- const change = new PendingChange('hard-delete', { actor: Stack.actorFrom(opts) });
982
+ const change = new PendingChange('purge', { actor: normalizeActor(opts.actor) });
992
983
  const purged = await this.adapter.deleteRecord(id, {
993
- hard: true,
994
- expectedVersion: opts.ifVersion,
984
+ purge: true,
985
+ ifVersion: opts.ifVersion,
995
986
  journal: change.journal,
996
987
  });
997
988
  if (!purged)
@@ -1008,7 +999,7 @@ export class Stack {
1008
999
  // attachments intact — so nothing is stranded and nothing is reported.
1009
1000
  if (existing.deletedAt)
1010
1001
  return { record: existing, referencedFileIds: [] };
1011
- const change = new PendingChange('delete', { actor: Stack.actorFrom(opts) });
1002
+ const change = new PendingChange('delete', { actor: normalizeActor(opts.actor) });
1012
1003
  const deleted = await this.adapter.deleteRecord(id, {
1013
1004
  ...this.writeOptions(existing, opts),
1014
1005
  journal: change.journal,
@@ -1033,7 +1024,7 @@ export class Stack {
1033
1024
  }
1034
1025
  /**
1035
1026
  * Reverse a soft delete. Idempotent — undeleting a record that isn't
1036
- * deleted returns it unchanged. Hard-deleted records are gone, so this
1027
+ * deleted returns it unchanged. Purged records are gone, so this
1037
1028
  * throws StackNotFoundError for them just like any other missing record.
1038
1029
  * Snapshots and bumps version, same as delete().
1039
1030
  */
@@ -1046,7 +1037,7 @@ export class Stack {
1046
1037
  this.checkIfVersion(existing, opts.ifVersion);
1047
1038
  if (!existing.deletedAt)
1048
1039
  return existing;
1049
- const change = new PendingChange('undelete', { actor: Stack.actorFrom(opts) });
1040
+ const change = new PendingChange('undelete', { actor: normalizeActor(opts.actor) });
1050
1041
  const undeleted = await this.adapter.undeleteRecord(id, {
1051
1042
  ...this.writeOptions(existing, opts),
1052
1043
  journal: change.journal,
@@ -1066,7 +1057,7 @@ export class Stack {
1066
1057
  assertQueryCapabilities(filter, this.adapter.capabilities);
1067
1058
  assertValidSort(query.sort);
1068
1059
  assertSortCapability(query.sort, this.adapter.capabilities);
1069
- assertValidRelatedTo(filter?.relatedTo);
1060
+ assertValidAssociationFilters(filter);
1070
1061
  const limit = rawLimit !== undefined ? Math.min(rawLimit, MAX_QUERY_LIMIT) : undefined;
1071
1062
  const resolvedFilter = await this.resolveBaseIdFilter(filter);
1072
1063
  if (resolvedFilter === EMPTY_FAMILY) {
@@ -1145,7 +1136,7 @@ export class Stack {
1145
1136
  }
1146
1137
  const type = await this.getTypeCached(target.typeId);
1147
1138
  if (!type) {
1148
- throw new StackQueryError(`Unknown type: "${target.typeId}"`);
1139
+ throw new StackBadRequestError(`Unknown type: "${target.typeId}"`);
1149
1140
  }
1150
1141
  const errors = [
1151
1142
  ...validateContent(target.content, type.schema),
@@ -1168,7 +1159,10 @@ export class Stack {
1168
1159
  // A restore adds no containment edge and takes none away, so there is
1169
1160
  // no cycle for it to close and nothing for the destination checks to
1170
1161
  // gate. See docs/spec/versioning.md § Restore semantics.
1171
- const change = new PendingChange('restore', { actor: Stack.actorFrom(opts) });
1162
+ const change = new PendingChange('restore', {
1163
+ actor: normalizeActor(opts.actor),
1164
+ previousTypeId: existing.typeId,
1165
+ });
1172
1166
  const restored = await this.adapter.restoreVersion(id, version, {
1173
1167
  ...this.writeOptions(existing, opts),
1174
1168
  journal: change.journal,
@@ -1216,7 +1210,7 @@ export class Stack {
1216
1210
  const id = existing.id;
1217
1211
  const type = await this.getTypeCached(toTypeId);
1218
1212
  if (!type) {
1219
- throw new StackQueryError(`Unknown type: "${toTypeId}". Call defineType() first.`);
1213
+ throw new StackBadRequestError(`Unknown type: "${toTypeId}". Call defineType() first.`);
1220
1214
  }
1221
1215
  const errors = [
1222
1216
  ...validateReservedKeys(content),
@@ -1227,7 +1221,7 @@ export class Stack {
1227
1221
  if (errors.length > 0) {
1228
1222
  throw new StackValidationError(errors);
1229
1223
  }
1230
- assertContentSize(content, this.features.limits.contentBytes, 'Content');
1224
+ assertContentSize(content, this.capabilities.limits.contentBytes, 'Content');
1231
1225
  const fromFamily = baseIdOf(existing.typeId);
1232
1226
  const toFamily = baseIdOf(toTypeId);
1233
1227
  const existingContent = existing.content;
@@ -1257,7 +1251,10 @@ export class Stack {
1257
1251
  if (id === SYSTEM_TYPES.CONFIG) {
1258
1252
  this.checkConfigEntityIdUnchanged(existing.content.entityId, content.entityId);
1259
1253
  }
1260
- const change = new PendingChange('migrate', { actor: Stack.actorFrom(opts) });
1254
+ const change = new PendingChange('migrate', {
1255
+ actor: normalizeActor(opts.actor),
1256
+ previousTypeId: existing.typeId,
1257
+ });
1261
1258
  const migrated = await this.adapter.commitMigration(id, toTypeId, content, {
1262
1259
  ...this.writeOptions(existing, opts),
1263
1260
  journal: change.journal,
@@ -1317,8 +1314,8 @@ export class Stack {
1317
1314
  }
1318
1315
  }
1319
1316
  /**
1320
- * A unique binding field is what a lookup resolves *by* — a record's
1321
- * `principalId` by `_app.did`, its `entityId` by `_entity.did`. Two cards
1317
+ * A unique binding field is what a lookup resolves *by* — an Actor's
1318
+ * `principalId` by `_app.did`, its `subjectId` by `_entity.did`. Two cards
1322
1319
  * claiming one value would leave that lookup without a single answer, and
1323
1320
  * ambiguity is all an impersonating card needs. Enforced here rather than
1324
1321
  * by schema, since uniqueness is a property of the set, not of the value.
@@ -1336,7 +1333,7 @@ export class Stack {
1336
1333
  baseId: family,
1337
1334
  includeDeleted: true,
1338
1335
  includeUnlisted: true,
1339
- ...(filtersContent(this.features) && { content: { [field]: value } }),
1336
+ ...(filtersContent(this.capabilities) && { content: { [field]: value } }),
1340
1337
  },
1341
1338
  }, (r) => r.id !== excludeId && r.content[field] === value);
1342
1339
  if (clash) {
@@ -1476,19 +1473,20 @@ export class Stack {
1476
1473
  }
1477
1474
  /**
1478
1475
  * Store bytes and create an _attachment@1 metadata record (owner-
1479
- * attributed, no entityId), returning that record — `content.fileId`
1476
+ * attributed, no createdBy), returning that record — `content.fileId`
1480
1477
  * addresses the bytes, `id` addresses the metadata. Delegates to the
1481
1478
  * adapter's atomic putAttachmentWithMetadata() when implemented, trusting
1482
1479
  * the returned record as backend-authoritative; otherwise falls back to
1483
1480
  * bytes-then-create(). See docs/spec/wire-format.md § Attachments.
1484
1481
  */
1485
- async putAttachment(data, mimeType, filename, appId) {
1482
+ async putAttachment(data, opts) {
1483
+ const { mimeType, filename, appId } = opts;
1486
1484
  this.assertOpen();
1487
- assertAttachmentSize(data.byteLength, this.features.limits.attachmentBytes);
1485
+ assertAttachmentSize(data.byteLength, this.capabilities.limits.attachmentBytes);
1488
1486
  if (this.adapter.putAttachmentWithMetadata) {
1489
1487
  // The metadata record is written inside the adapter, so create()
1490
1488
  // never sees it and this is the only place it can be announced.
1491
- const record = await this.adapter.putAttachmentWithMetadata(data, mimeType, filename, appId);
1489
+ const record = await this.adapter.putAttachmentWithMetadata(data, opts);
1492
1490
  // The one emission with no journal half of its own: the far side
1493
1491
  // wrote the record, so it appended the entry in that same write —
1494
1492
  // the same division saveVersion() follows over this adapter. See
@@ -1496,7 +1494,7 @@ export class Stack {
1496
1494
  this.announce(new PendingChange('create'), record);
1497
1495
  return record;
1498
1496
  }
1499
- const fileId = await this.adapter.putAttachment(data);
1497
+ const fileId = await this.adapter.putBlob(data);
1500
1498
  return this.create(`${SYSTEM_TYPES.ATTACHMENT}@1`, {
1501
1499
  fileId,
1502
1500
  mimeType,
@@ -1506,7 +1504,7 @@ export class Stack {
1506
1504
  }
1507
1505
  async getAttachment(fileId) {
1508
1506
  this.assertOpen();
1509
- return this.adapter.getAttachment(fileId);
1507
+ return this.adapter.getBlob(fileId);
1510
1508
  }
1511
1509
  /**
1512
1510
  * Every `_attachment` record describing `fileId`, earliest-recorded
@@ -1527,7 +1525,7 @@ export class Stack {
1527
1525
  baseId: SYSTEM_TYPES.ATTACHMENT,
1528
1526
  includeDeleted: true,
1529
1527
  includeUnlisted: true,
1530
- ...(filtersContent(this.features) && { content: { fileId } }),
1528
+ ...(filtersContent(this.capabilities) && { content: { fileId } }),
1531
1529
  },
1532
1530
  });
1533
1531
  return results
@@ -1559,7 +1557,7 @@ export class Stack {
1559
1557
  deletedRecords = await this.adapter.deleteUnreferencedAttachmentRecords(fileId, await this.attachmentTypeIds());
1560
1558
  const at = new Date();
1561
1559
  for (const record of deletedRecords) {
1562
- this.announce(new PendingChange('hard-delete', { actor: Stack.actorFrom(opts) }), record, at);
1560
+ this.announce(new PendingChange('purge', { actor: normalizeActor(opts.actor) }), record, at);
1563
1561
  }
1564
1562
  }
1565
1563
  else {
@@ -1567,13 +1565,13 @@ export class Stack {
1567
1565
  }
1568
1566
  if (!deletedRecords.length) {
1569
1567
  try {
1570
- await this.adapter.getAttachment(fileId);
1568
+ await this.adapter.getBlob(fileId);
1571
1569
  }
1572
1570
  catch {
1573
1571
  throw new StackNotFoundError(`Attachment not found: "${fileId}"`);
1574
1572
  }
1575
1573
  }
1576
- await this.adapter.deleteAttachment(fileId);
1574
+ await this.adapter.deleteBlob(fileId);
1577
1575
  }
1578
1576
  /**
1579
1577
  * Non-atomic fallback for adapters that don't implement
@@ -1585,7 +1583,7 @@ export class Stack {
1585
1583
  // must find its attachments intact on undelete or relisting. See
1586
1584
  // docs/spec/attachments.md § Deleting attachments.
1587
1585
  const refResult = await this.query({
1588
- filter: { attachmentFileId: fileId, includeDeleted: true, includeUnlisted: true },
1586
+ filter: { referencesFileId: fileId, includeDeleted: true, includeUnlisted: true },
1589
1587
  limit: 1,
1590
1588
  });
1591
1589
  if (refResult.records.length > 0) {
@@ -1595,7 +1593,7 @@ export class Stack {
1595
1593
  // too — none of it may be left pointing at deleted bytes.
1596
1594
  const metaRecords = await this.getAttachmentRecords(fileId);
1597
1595
  for (const record of metaRecords) {
1598
- await this.delete(record.id, { hard: true, ...opts });
1596
+ await this.delete(record.id, { purge: true, ...opts });
1599
1597
  }
1600
1598
  return metaRecords;
1601
1599
  }
@@ -1626,19 +1624,19 @@ export class Stack {
1626
1624
  }
1627
1625
  }
1628
1626
  // Bare-bytes orphans: blobs with zero metadata records, only
1629
- // discoverable if the blob adapter implements listFiles().
1627
+ // discoverable if the blob adapter implements listBlobs().
1630
1628
  const blobByFile = new Map();
1631
- if (this.adapter.listFiles) {
1632
- for (const file of await this.adapter.listFiles()) {
1629
+ if (this.adapter.listBlobs) {
1630
+ for (const file of await this.adapter.listBlobs()) {
1633
1631
  blobByFile.set(file.fileId, { modifiedAt: file.modifiedAt.getTime(), size: file.size });
1634
1632
  }
1635
1633
  }
1636
1634
  const candidateFileIds = new Set([...metaByFile.keys(), ...blobByFile.keys()]);
1637
- const deleted = [];
1635
+ const deletedFileIds = [];
1638
1636
  let reclaimedBytes = 0;
1639
1637
  for (const fileId of candidateFileIds) {
1640
1638
  const refResult = await this.query({
1641
- filter: { attachmentFileId: fileId, includeDeleted: true, includeUnlisted: true },
1639
+ filter: { referencesFileId: fileId, includeDeleted: true, includeUnlisted: true },
1642
1640
  limit: 1,
1643
1641
  });
1644
1642
  if (refResult.records.length > 0)
@@ -1650,12 +1648,12 @@ export class Stack {
1650
1648
  continue;
1651
1649
  const size = meta?.size ?? blob?.size ?? 0;
1652
1650
  if (dryRun) {
1653
- deleted.push(fileId);
1651
+ deletedFileIds.push(fileId);
1654
1652
  reclaimedBytes += size;
1655
1653
  continue;
1656
1654
  }
1657
1655
  try {
1658
- await this.deleteAttachment(fileId, { updatedBy: opts.updatedBy });
1656
+ await this.deleteAttachment(fileId, { actor: opts.actor });
1659
1657
  }
1660
1658
  catch (err) {
1661
1659
  // Raced with a new reference, or another sweep/call already removed
@@ -1664,10 +1662,10 @@ export class Stack {
1664
1662
  continue;
1665
1663
  throw err;
1666
1664
  }
1667
- deleted.push(fileId);
1665
+ deletedFileIds.push(fileId);
1668
1666
  reclaimedBytes += size;
1669
1667
  }
1670
- return { deleted, reclaimedBytes };
1668
+ return { deletedFileIds, reclaimedBytes };
1671
1669
  }
1672
1670
  // -------------------------------------------------------
1673
1671
  // Lifecycle
@@ -1708,7 +1706,7 @@ export class Stack {
1708
1706
  *
1709
1707
  * The subscription's own filter goes to the relay rather than being
1710
1708
  * applied on the way back: the emitter at the far end holds the record,
1711
- * so it can answer `entityId` and `parentId`, which the envelope
1709
+ * so it can answer `createdBy` and `parentId`, which the envelope
1712
1710
  * deliberately does not carry. That is also why a relay is opened per
1713
1711
  * subscription rather than shared.
1714
1712
  */
@@ -1729,10 +1727,7 @@ export class Stack {
1729
1727
  void stop();
1730
1728
  };
1731
1729
  }
1732
- /**
1733
- * Whether changes reach this stack from elsewhere. Public only so
1734
- * ScopedStack can refuse a scope it cannot honor; not an app-facing API.
1735
- */
1730
+ /** Whether changes reach this stack from elsewhere. */
1736
1731
  get relaysChanges() {
1737
1732
  return typeof this.adapter.subscribeChanges === 'function';
1738
1733
  }
@@ -1767,23 +1762,20 @@ export class Stack {
1767
1762
  await this.adapter.close?.();
1768
1763
  }
1769
1764
  }
1770
- /**
1771
- * Throws once close() has run. Public only so ScopedStack can gate the
1772
- * one path it takes to the adapter directly; not an app-facing API.
1773
- */
1765
+ /** Throws once close() has run. */
1774
1766
  assertOpen() {
1775
1767
  if (this.closed)
1776
- throw new StackClosedError();
1768
+ throw new UseAfterCloseError();
1777
1769
  }
1778
1770
  // -------------------------------------------------------
1779
1771
  // Grants
1780
1772
  // -------------------------------------------------------
1781
1773
  /**
1782
- * Create _grant records authorizing entities to act on records of
1783
- * specific types. The target is the grantee the record carries:
1784
- * `{ kind: 'entity' }` for one DID, `{ kind: 'group' }` for a `_group`
1785
- * Record's roster at a role, `{ kind: 'authenticated' }` for any
1786
- * authenticated entity.
1774
+ * Create a _grant record authorizing `grantee` to take `actions` on
1775
+ * records of `typeId`: `{ kind: 'entity' }` for one DID, `{ kind: 'group' }`
1776
+ * for a `_group` Record's roster at a role, `{ kind: 'authenticated' }` for
1777
+ * any authenticated entity. The type-level mirror of grantAccess() —
1778
+ * subject first, grantee inside the element.
1787
1779
  *
1788
1780
  * Granting an **app** a `-own` action does not contain it the way the
1789
1781
  * suffix suggests: when that app acts for someone, `-own` is read as the
@@ -1793,22 +1785,19 @@ export class Stack {
1793
1785
  * needs, not the suffix that looks narrowest. See
1794
1786
  * docs/spec/access-control.md § Delegation: principal and subject.
1795
1787
  *
1796
- * The grantee lives in content.grantee, not record.entityId. See
1788
+ * `typeOrBaseId` is a versioned TypeId (`"baseId@version"`) or a bare
1789
+ * baseId; either names the whole family. See
1797
1790
  * docs/spec/access-control.md § Type-level grants.
1798
1791
  */
1799
- async grant(target, grants) {
1792
+ async grantType(typeOrBaseId, grant) {
1800
1793
  this.assertOpen();
1801
- validateGrantTarget(target);
1802
- this.checkGrantsValid(grants);
1803
- const records = [];
1804
- for (const g of grants) {
1805
- records.push(await this.create(`${SYSTEM_TYPES.GRANT}@1`, {
1806
- typeId: g.typeId,
1807
- actions: g.actions,
1808
- grantee: target,
1809
- }));
1810
- }
1811
- return records;
1794
+ validateGrantTarget(grant.grantee);
1795
+ this.checkGrantValid(typeOrBaseId, grant.actions);
1796
+ return this.create(`${SYSTEM_TYPES.GRANT}@1`, {
1797
+ typeId: typeOrBaseId,
1798
+ actions: grant.actions,
1799
+ grantee: grant.grantee,
1800
+ });
1812
1801
  }
1813
1802
  /**
1814
1803
  * List _grant records. Omit `query` for all grants;
@@ -1819,12 +1808,12 @@ export class Stack {
1819
1808
  * (ones naming them, ones naming a group they belong to at a role they
1820
1809
  * hold, plus every default grant) — the same resolution hasGrant() uses.
1821
1810
  *
1822
- * Every arm but `entity` answers identity — what `grant()` would have
1811
+ * Every arm but `entity` answers identity — what `grantType()` would have
1823
1812
  * written with the same argument. The `entity` arm answers coverage, so
1824
- * its result is not a preview of what `revoke()` would withdraw.
1813
+ * its result is not a preview of what `revokeType()` would withdraw.
1825
1814
  * See docs/spec/access-control.md § Type-level grants.
1826
1815
  */
1827
- async listGrants(query) {
1816
+ async listTypeGrants(query) {
1828
1817
  this.assertOpen();
1829
1818
  if (query !== undefined)
1830
1819
  validateGrantTarget(query, true);
@@ -1853,90 +1842,78 @@ export class Stack {
1853
1842
  return result;
1854
1843
  }
1855
1844
  /**
1856
- * The inverse of grant(): soft-deletes _grant records matching `target`
1857
- * and each `{ typeId, actions }` pair, at the same granularity grant()
1858
- * writes — the grantee is matched whole, role included. A soft delete
1859
- * like any other — the owner can undelete a revocation.
1845
+ * The inverse of grantType(): soft-deletes the _grant records on
1846
+ * `typeOrBaseId`'s family matching `grant`, at the same granularity
1847
+ * grantType() writes — the grantee is matched whole, role included, and
1848
+ * the actions exactly. A soft delete like any other — the owner can
1849
+ * undelete a revocation.
1860
1850
  *
1861
- * Returns the grants it withdrew, as they stood, so a revocation that
1862
- * matched nothing says so rather than passing in silence. An empty result
1863
- * is not an error — a grant already withdrawn or never written is the
1864
- * ordinary case, and re-running a revocation has to stay safe.
1865
- * See docs/spec/access-control.md § Type-level grants.
1851
+ * Returns the grants it withdrew, as they stood: an array, where
1852
+ * revokeAccess() returns one Record, because one target can match several
1853
+ * _grant records. An empty result is not an error — re-running a
1854
+ * revocation has to stay safe. See
1855
+ * docs/spec/access-control.md § Listing and revoking.
1866
1856
  */
1867
- async revoke(target, grants) {
1857
+ async revokeType(typeOrBaseId, grant) {
1868
1858
  this.assertOpen();
1869
- validateGrantTarget(target);
1870
- const revoked = [];
1859
+ validateGrantTarget(grant.grantee);
1860
+ const familyId = baseIdOf(typeOrBaseId);
1861
+ const actionSet = new Set(grant.actions);
1871
1862
  const all = await loadGrantRecords((q) => this.query(q));
1872
- for (const g of grants) {
1873
- const familyId = baseIdOf(g.typeId);
1874
- const actionSet = new Set(g.actions);
1875
- const matches = all.filter((r) => {
1876
- const c = r.content;
1877
- // Establishes the family and that `actions` is a list, so the exact
1878
- // match below reads a real one. Matched against the stored list
1879
- // rather than the reach, so a grant carrying an action this
1880
- // vocabulary drops is not withdrawn by a target that omits it.
1881
- const reach = grantReach(c);
1882
- if (!reach || reach.familyId !== familyId)
1883
- return false;
1884
- if (!matchesGrantTarget(c, target))
1885
- return false;
1886
- return c.actions.length === actionSet.size && c.actions.every((a) => actionSet.has(a));
1887
- });
1888
- for (const match of matches) {
1889
- await this.delete(match.id);
1890
- revoked.push(match);
1891
- }
1892
- }
1893
- return revoked;
1863
+ const matches = all.filter((r) => {
1864
+ const c = r.content;
1865
+ // Establishes the family and that `actions` is a list, so the exact
1866
+ // match below reads a real one. Matched against the stored list
1867
+ // rather than the reach, so a grant carrying an action this
1868
+ // vocabulary drops is not withdrawn by a target that omits it.
1869
+ const reach = grantReach(c);
1870
+ if (!reach || reach.familyId !== familyId)
1871
+ return false;
1872
+ if (!matchesGrantTarget(c, grant.grantee))
1873
+ return false;
1874
+ return c.actions.length === actionSet.size && c.actions.every((a) => actionSet.has(a));
1875
+ });
1876
+ for (const match of matches)
1877
+ await this.delete(match.id);
1878
+ return matches;
1894
1879
  }
1895
1880
  // -------------------------------------------------------
1896
1881
  // Private helpers
1897
1882
  // -------------------------------------------------------
1898
1883
  /**
1899
- * Validates the whole grant batch before any record is created, so a bad
1900
- * entry fails clean rather than leaving a partial set written. Actions
1901
- * must be known GrantAction values, typeIds must be well-formed, and the
1902
- * _grant/_config families are refused — see docs/spec/access-control.md
1903
- * § Type-level grants.
1884
+ * Actions must be known GrantAction values, typeIds must be well-formed,
1885
+ * and the _grant/_config families are refused — see
1886
+ * docs/spec/access-control.md § Type-level grants.
1904
1887
  */
1905
- checkGrantsValid(grants) {
1888
+ checkGrantValid(typeId, actions) {
1906
1889
  const errors = [];
1907
- grants.forEach((g, i) => {
1908
- g.actions.forEach((action, j) => {
1909
- if (!GRANT_ACTION_SET.has(action)) {
1910
- errors.push({
1911
- path: `grants[${i}].actions[${j}]`,
1912
- message: `Unknown grant action "${action}"`,
1913
- });
1914
- }
1890
+ actions.forEach((action, j) => {
1891
+ if (!GRANT_ACTION_SET.has(action)) {
1892
+ errors.push({ path: `actions[${j}]`, message: `Unknown grant action "${action}"` });
1893
+ }
1894
+ });
1895
+ if (!isWellFormedTypeId(typeId)) {
1896
+ errors.push({
1897
+ path: 'typeId',
1898
+ message: `"${typeId}" is not a well-formed baseId or versioned TypeId (expected "baseId" or "baseId@version")`,
1915
1899
  });
1916
- if (!isWellFormedTypeId(g.typeId)) {
1917
- errors.push({
1918
- path: `grants[${i}].typeId`,
1919
- message: `"${g.typeId}" is not a well-formed baseId or versioned TypeId (expected "baseId" or "baseId@version")`,
1920
- });
1900
+ }
1901
+ else if (UNGRANTABLE_SYSTEM_TYPES.has(baseIdOf(typeId))) {
1902
+ const refused = [...UNGRANTABLE_SYSTEM_TYPES].join(', ');
1903
+ errors.push({
1904
+ path: 'typeId',
1905
+ message: `Cannot grant on "${baseIdOf(typeId)}": grants on ${refused} are refused to prevent privilege escalation`,
1906
+ });
1907
+ }
1908
+ actions.forEach((action, j) => {
1909
+ if (grantConveys(actions, action))
1921
1910
  return;
1922
- }
1923
- if (UNGRANTABLE_SYSTEM_TYPES.has(baseIdOf(g.typeId))) {
1924
- const refused = [...UNGRANTABLE_SYSTEM_TYPES].join(', ');
1925
- errors.push({
1926
- path: `grants[${i}].typeId`,
1927
- message: `Cannot grant on "${baseIdOf(g.typeId)}": grants on ${refused} are refused to prevent privilege escalation`,
1928
- });
1929
- }
1930
- g.actions.forEach((action, j) => {
1931
- if (grantConveys(g.actions, action))
1932
- return;
1933
- const companions = READ_COMPANIONS.get(action);
1934
- if (!companions)
1935
- return;
1936
- errors.push({
1937
- path: `grants[${i}].actions[${j}]`,
1938
- message: `"${action}" requires ${companions.map((c) => `"${c}"`).join(' or ')} in the same grant: a mutate verb reaches the record and its history, so it conveys nothing without read`,
1939
- });
1911
+ const companions = READ_COMPANIONS.get(action);
1912
+ if (!companions)
1913
+ return;
1914
+ errors.push({
1915
+ path: `actions[${j}]`,
1916
+ message: `"${action}" requires ${companions.map((c) => `"${c}"`).join(' or ')} in the same grant: a mutate verb reaches the record and its history, so it conveys nothing without read`,
1940
1917
  });
1941
1918
  });
1942
1919
  if (errors.length > 0) {
@@ -1944,58 +1921,82 @@ export class Stack {
1944
1921
  }
1945
1922
  }
1946
1923
  async seedSystemTypes() {
1947
- await this.defineType(`${SYSTEM_TYPES.CONFIG}@1`, 'Config', {
1948
- entityId: { kind: 'string', required: true },
1949
- // Optional passthrough app metadata — see ConfigContent.timezone.
1950
- timezone: { kind: 'string' },
1924
+ await this.defineType({
1925
+ id: `${SYSTEM_TYPES.CONFIG}@1`,
1926
+ name: 'Config',
1927
+ schema: {
1928
+ entityId: { kind: 'string', required: true },
1929
+ // Optional passthrough app metadata — see ConfigContent.timezone.
1930
+ timezone: { kind: 'string' },
1931
+ },
1951
1932
  });
1952
- await this.defineType(`${SYSTEM_TYPES.ENTITY}@1`, 'Entity', {
1953
- did: { kind: 'string', required: true },
1954
- name: { kind: 'string', required: true },
1955
- handle: { kind: 'string' },
1933
+ await this.defineType({
1934
+ id: `${SYSTEM_TYPES.ENTITY}@1`,
1935
+ name: 'Entity',
1936
+ schema: {
1937
+ did: { kind: 'string', required: true },
1938
+ name: { kind: 'string', required: true },
1939
+ handle: { kind: 'string' },
1940
+ },
1956
1941
  });
1957
- await this.defineType(`${SYSTEM_TYPES.APP}@1`, 'App', {
1958
- appId: { kind: 'string', required: true },
1959
- name: { kind: 'string', required: true },
1960
- version: { kind: 'string' },
1961
- did: { kind: 'string' },
1942
+ await this.defineType({
1943
+ id: `${SYSTEM_TYPES.APP}@1`,
1944
+ name: 'App',
1945
+ schema: {
1946
+ appId: { kind: 'string', required: true },
1947
+ name: { kind: 'string', required: true },
1948
+ version: { kind: 'string' },
1949
+ did: { kind: 'string' },
1950
+ },
1962
1951
  });
1963
- await this.defineType(`${SYSTEM_TYPES.GROUP}@1`, 'Group', {
1964
- name: { kind: 'string', required: true },
1965
- handle: { kind: 'string' },
1966
- stackUrl: { kind: 'string' },
1952
+ await this.defineType({
1953
+ id: `${SYSTEM_TYPES.GROUP}@1`,
1954
+ name: 'Group',
1955
+ schema: {
1956
+ name: { kind: 'string', required: true },
1957
+ handle: { kind: 'string' },
1958
+ stackUrl: { kind: 'string' },
1959
+ },
1967
1960
  });
1968
- await this.defineType(`${SYSTEM_TYPES.GRANT}@1`, 'Grant', {
1969
- typeId: { kind: 'string', required: true },
1970
- actions: { kind: 'array', items: { kind: 'string' }, required: true },
1971
- // Required, and closed: a Grant's reach is spelled by its `grantee`,
1972
- // so a record arriving without one is refused here rather than read
1973
- // as a grant to every authenticated entity. The arms differ in which
1974
- // fields they carry, which a schema cannot express — evaluation reads
1975
- // the `kind` and confers nothing on one it does not recognize.
1976
- // See docs/spec/access-control.md § Type-level grants.
1977
- grantee: {
1978
- kind: 'object',
1979
- required: true,
1980
- properties: {
1981
- kind: { kind: 'string', required: true },
1982
- entityId: { kind: 'string' },
1983
- groupId: { kind: 'string' },
1984
- role: { kind: 'string' },
1961
+ await this.defineType({
1962
+ id: `${SYSTEM_TYPES.GRANT}@1`,
1963
+ name: 'Grant',
1964
+ schema: {
1965
+ typeId: { kind: 'string', required: true },
1966
+ actions: { kind: 'array', items: { kind: 'string' }, required: true },
1967
+ // Required, and closed: a Grant's reach is spelled by its `grantee`,
1968
+ // so a record arriving without one is refused here rather than read
1969
+ // as a grant to every authenticated entity. The arms differ in which
1970
+ // fields they carry, which a schema cannot express — evaluation reads
1971
+ // the `kind` and confers nothing on one it does not recognize.
1972
+ // See docs/spec/access-control.md § Type-level grants.
1973
+ grantee: {
1974
+ kind: 'object',
1975
+ required: true,
1976
+ properties: {
1977
+ kind: { kind: 'string', required: true },
1978
+ entityId: { kind: 'string' },
1979
+ groupId: { kind: 'string' },
1980
+ role: { kind: 'string' },
1981
+ },
1985
1982
  },
1986
1983
  },
1987
1984
  });
1988
- await this.defineType(`${SYSTEM_TYPES.ATTACHMENT}@1`, 'Attachment', {
1989
- // Deliberately `string`, not `file-ref`: attachmentFileId matching
1990
- // (deleteAttachment()/collectAttachmentGarbage()'s reference scan) is
1991
- // schema-driven, so a `file-ref` fileId here would make every
1992
- // metadata record its own file's reference — nothing would ever be
1993
- // deletable or collectible. See docs/spec/attachments.md § Deleting
1994
- // attachments / Garbage collection.
1995
- fileId: { kind: 'string', required: true },
1996
- mimeType: { kind: 'string', required: true },
1997
- size: { kind: 'number', required: true },
1998
- filename: { kind: 'string' },
1985
+ await this.defineType({
1986
+ id: `${SYSTEM_TYPES.ATTACHMENT}@1`,
1987
+ name: 'Attachment',
1988
+ schema: {
1989
+ // Deliberately `string`, not `file-ref`: referencesFileId matching
1990
+ // (deleteAttachment()/collectAttachmentGarbage()'s reference scan) is
1991
+ // schema-driven, so a `file-ref` fileId here would make every
1992
+ // metadata record its own file's reference — nothing would ever be
1993
+ // deletable or collectible. See docs/spec/attachments.md § Deleting
1994
+ // attachments / Garbage collection.
1995
+ fileId: { kind: 'string', required: true },
1996
+ mimeType: { kind: 'string', required: true },
1997
+ size: { kind: 'number', required: true },
1998
+ filename: { kind: 'string' },
1999
+ },
1999
2000
  });
2000
2001
  }
2001
2002
  /**
@@ -2013,16 +2014,15 @@ export class Stack {
2013
2014
  * The options every version-bumping adapter write carries: the `ifVersion`
2014
2015
  * precondition, the prior-state snapshot that has to land in the same
2015
2016
  * atomic write, and who to attribute the change to. Taken together so a
2016
- * new mutating verb cannot quietly omit one — `updatedVia` most of all,
2017
- * whose absence reads as an undelegated write.
2017
+ * new mutating verb cannot quietly omit one — the actor's `principalId`
2018
+ * most of all, whose absence reads as an undelegated write.
2018
2019
  * See docs/spec/versioning.md § Version history.
2019
2020
  */
2020
2021
  writeOptions(existing, opts) {
2021
2022
  return {
2022
- expectedVersion: opts.ifVersion,
2023
+ ifVersion: opts.ifVersion,
2023
2024
  snapshot: this.buildVersionSnapshot(existing),
2024
- updatedBy: opts.updatedBy,
2025
- updatedVia: opts.updatedVia,
2025
+ actor: normalizeActor(opts.actor),
2026
2026
  };
2027
2027
  }
2028
2028
  /**
@@ -2040,9 +2040,8 @@ export class Stack {
2040
2040
  typeId: record.typeId,
2041
2041
  content: record.content,
2042
2042
  updatedAt: record.updatedAt,
2043
- ...(record.entityId && { entityId: record.entityId }),
2043
+ ...(record.createdBy && { createdBy: record.createdBy }),
2044
2044
  ...(record.updatedBy && { updatedBy: record.updatedBy }),
2045
- ...(record.updatedVia && { updatedVia: record.updatedVia }),
2046
2045
  };
2047
2046
  }
2048
2047
  }