@haverstack/core 0.27.0 → 0.29.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 (46) hide show
  1. package/README.md +9 -2
  2. package/dist/access.d.ts +20 -0
  3. package/dist/access.d.ts.map +1 -1
  4. package/dist/access.js +26 -0
  5. package/dist/access.js.map +1 -1
  6. package/dist/changes.d.ts +12 -2
  7. package/dist/changes.d.ts.map +1 -1
  8. package/dist/changes.js +36 -7
  9. package/dist/changes.js.map +1 -1
  10. package/dist/combine.d.ts.map +1 -1
  11. package/dist/combine.js +1 -3
  12. package/dist/combine.js.map +1 -1
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/merge.d.ts +1 -1
  17. package/dist/merge.js +2 -2
  18. package/dist/merge.js.map +1 -1
  19. package/dist/schema.d.ts.map +1 -1
  20. package/dist/schema.js +56 -5
  21. package/dist/schema.js.map +1 -1
  22. package/dist/stack.d.ts +144 -59
  23. package/dist/stack.d.ts.map +1 -1
  24. package/dist/stack.js +497 -210
  25. package/dist/stack.js.map +1 -1
  26. package/dist/testing.d.ts +3 -10
  27. package/dist/testing.d.ts.map +1 -1
  28. package/dist/testing.js +37 -26
  29. package/dist/testing.js.map +1 -1
  30. package/dist/types.d.ts +121 -23
  31. package/dist/types.d.ts.map +1 -1
  32. package/dist/types.js +8 -0
  33. package/dist/types.js.map +1 -1
  34. package/dist/validate.d.ts +31 -0
  35. package/dist/validate.d.ts.map +1 -1
  36. package/dist/validate.js +150 -3
  37. package/dist/validate.js.map +1 -1
  38. package/dist/wire-entry.d.ts +1 -1
  39. package/dist/wire-entry.d.ts.map +1 -1
  40. package/dist/wire-entry.js +1 -1
  41. package/dist/wire-entry.js.map +1 -1
  42. package/dist/wire-record.d.ts +16 -1
  43. package/dist/wire-record.d.ts.map +1 -1
  44. package/dist/wire-record.js +56 -0
  45. package/dist/wire-record.js.map +1 -1
  46. package/package.json +2 -2
package/dist/stack.js CHANGED
@@ -15,12 +15,12 @@
15
15
  */
16
16
  import { generateId, generateIdForTimestamp, isValidIdFormat, idTimestamp, MAX_ID_TIMESTAMP, } from './id.js';
17
17
  import { hashSchema, isCompatible, parseTypeId, baseIdOf, diffSchemas, isWellFormedTypeId, } from './schema.js';
18
- import { CONTENT_SEGMENT_METACHARACTERS, SEGMENT_METACHARACTER_RE, validateContent, validateContentKeys, validatePatchValues, validateReservedKeys, validateSchemaFieldNames, } from './validate.js';
18
+ import { CONTENT_SEGMENT_METACHARACTERS, SEGMENT_METACHARACTER_RE, validateContent, validateContentKeys, validatePatchValues, validateReservedKeys, validateSchemaFieldNames, validateSchemaReservedNames, validateSchemaShape, } from './validate.js';
19
19
  import { applyMergePatch } from './merge.js';
20
- import { checkAccess, groupRoleFromAssociations, isOwnerActingAlone, validatePermissions, } from './access.js';
20
+ import { checkAccess, groupRoleFromAssociations, hasGroupAdmin, isGroupAdminAssociation, isOwnerActingAlone, validatePermissions, } from './access.js';
21
21
  import { compareRecordedAttachments } from './attachment-download.js';
22
22
  import { ChangeEmitter, RelayDelivery, Subscription, buildEmission, matchesFilter, passesUnlistedBoundary, } from './changes.js';
23
- import { SYSTEM_TYPES, GRANT_ACTIONS } from './types.js';
23
+ import { SYSTEM_TYPES, GRANT_ACTIONS, RECORD_CHANGE_KEYS } from './types.js';
24
24
  // -------------------------------------------------------
25
25
  // Supporting types
26
26
  // -------------------------------------------------------
@@ -532,7 +532,7 @@ function assertAttachmentSize(byteLength, attachmentBytes) {
532
532
  }
533
533
  /**
534
534
  * The content half of the same pre-check, on Stack.create() and
535
- * Stack.update(). Local adapters declare `limits.contentBytes: null` and skip
535
+ * Stack.mutate(). Local adapters declare `limits.contentBytes: null` and skip
536
536
  * the serialization entirely; only a server declares a ceiling, and its
537
537
  * own request-size limit stays authoritative — this just spares an app the
538
538
  * round trip and gives it a typed failure instead of a 413 it has to
@@ -657,6 +657,20 @@ const DEFAULT_GC_GRACE_MS = 24 * 60 * 60 * 1000;
657
657
  * pagination cursor a StackQueryError rather than a content-validation
658
658
  * failure. See StackQueryError's doc comment.
659
659
  */
660
+ /**
661
+ * The same format rule applied to a `parentId` a caller names. Separate
662
+ * from validateRecordId() only for its message: the id under discussion is
663
+ * the destination, not the record being written, and a shared message would
664
+ * report the wrong one. See docs/spec/data-model.md § Reparenting.
665
+ */
666
+ function validateParentId(parentId) {
667
+ if (parentId.startsWith(RESERVED_ID_PREFIX)) {
668
+ throw new StackQueryError(`Invalid parentId "${parentId}": uses the reserved "${RESERVED_ID_PREFIX}" prefix.`);
669
+ }
670
+ if (!isValidIdFormat(parentId)) {
671
+ throw new StackQueryError(`Invalid parentId "${parentId}": expected 12 lowercase Crockford base-32 characters.`);
672
+ }
673
+ }
660
674
  function validateRecordId(id) {
661
675
  if (id.startsWith(RESERVED_ID_PREFIX)) {
662
676
  throw new StackQueryError(`ID "${id}" uses the reserved "${RESERVED_ID_PREFIX}" prefix.`);
@@ -840,7 +854,7 @@ export class Stack {
840
854
  /**
841
855
  * Announce a mutation that has already been persisted. Called after the
842
856
  * adapter write resolves and before the mutating method settles, so
843
- * `await stack.update(...)` guarantees subscribers have been notified —
857
+ * `await stack.mutate(...)` guarantees subscribers have been notified —
844
858
  * and nothing about work they deferred. A handler cannot fail the write:
845
859
  * there is nothing left to fail. See docs/spec/events.md § Handlers.
846
860
  */
@@ -970,7 +984,17 @@ export class Stack {
970
984
  const priorMax = this.maxDefinedVersion.get(parsed.baseId) ?? 0;
971
985
  if (parsed.version > priorMax)
972
986
  this.maxDefinedVersion.set(parsed.baseId, parsed.version);
973
- const nameErrors = validateSchemaFieldNames(schema);
987
+ // Shape first: the name checks and hashSchema() below both read the
988
+ // schema as a well-formed one, and a schema off the wire is parsed
989
+ // JSON that no compiler has seen.
990
+ const shapeErrors = validateSchemaShape(schema);
991
+ if (shapeErrors.length > 0) {
992
+ throw new StackValidationError(shapeErrors);
993
+ }
994
+ const nameErrors = [
995
+ ...validateSchemaReservedNames(schema),
996
+ ...validateSchemaFieldNames(schema),
997
+ ];
974
998
  if (nameErrors.length > 0) {
975
999
  throw new StackValidationError(nameErrors);
976
1000
  }
@@ -1193,6 +1217,13 @@ export class Stack {
1193
1217
  if (opts.createdAt !== undefined) {
1194
1218
  validateIdTimestampSkew(opts.id, this.idTimestampSkewMsValue, opts.createdAt.getTime(), 'createdAt');
1195
1219
  }
1220
+ // A generated id names nothing, so a create under a parent cannot
1221
+ // close a loop. A caller-supplied one can: existing records may
1222
+ // already point at it, and the chain above the parent can lead back
1223
+ // to it — so the walk is asked only for that combination.
1224
+ // See docs/spec/data-model.md § Reparenting.
1225
+ if (opts.parentId !== undefined)
1226
+ await this.assertNoParentCycle(opts.id, opts.parentId);
1196
1227
  }
1197
1228
  const associations = baseIdOf(typeId) === SYSTEM_TYPES.GROUP
1198
1229
  ? stampGroupAdmin(opts.associations, opts.entityId ?? this.ownerEntityId)
@@ -1210,6 +1241,20 @@ export class Stack {
1210
1241
  (opts.createdAt !== undefined
1211
1242
  ? generateIdForTimestamp(createdAt.getTime())
1212
1243
  : generateId(createdAt.getTime()));
1244
+ // An id field a caller supplies is a value or it is absent — never the
1245
+ // empty string, which names nobody. Refused rather than dropped: an
1246
+ // ignored field is the silent-normalization this mapper family was just
1247
+ // fixed for, one layer up.
1248
+ for (const field of ['entityId', 'appId', 'principalId']) {
1249
+ if (opts[field] === '') {
1250
+ throw new StackQueryError(`Invalid ${field}: the empty string is not an id.`);
1251
+ }
1252
+ }
1253
+ // Every create naming a parent owes the reference check, whether or not
1254
+ // it supplied an id: a destination a caller names has to be one that
1255
+ // exists. This is the read an ordinary create used to skip.
1256
+ if (opts.parentId !== undefined)
1257
+ await this.assertParentExists(id, opts.parentId);
1213
1258
  const record = {
1214
1259
  id,
1215
1260
  typeId,
@@ -1217,10 +1262,14 @@ export class Stack {
1217
1262
  updatedAt,
1218
1263
  content,
1219
1264
  version: 1,
1220
- ...(opts.parentId && { parentId: opts.parentId }),
1221
- ...(opts.entityId && { entityId: opts.entityId }),
1222
- ...(opts.appId && { appId: opts.appId }),
1223
- ...(opts.principalId && { principalId: opts.principalId }),
1265
+ // Read for presence, not truthiness — every one of these is checked
1266
+ // above, so '' never reaches here and absence is the only thing a
1267
+ // falsy value could mean. Presence says that outright instead of
1268
+ // relying on it.
1269
+ ...(opts.parentId !== undefined && { parentId: opts.parentId }),
1270
+ ...(opts.entityId !== undefined && { entityId: opts.entityId }),
1271
+ ...(opts.appId !== undefined && { appId: opts.appId }),
1272
+ ...(opts.principalId !== undefined && { principalId: opts.principalId }),
1224
1273
  // A create's actor is its author, so these are derived rather than
1225
1274
  // taken: stamping them here keeps "absent means an unscoped write"
1226
1275
  // true of version 1 as it is of every later version.
@@ -1275,64 +1324,140 @@ export class Stack {
1275
1324
  return opts.presentAt === 'latest' ? this.presentAtLatest(record) : record;
1276
1325
  }
1277
1326
  /**
1278
- * Update a record's content via JSON Merge Patch: omitted fields are
1279
- * kept, null removes a field. Validates the merged result against the
1280
- * record's *current* stored type and never changes typeId (see
1281
- * docs/spec/data-model.md § Type migrations); snapshots the prior state
1282
- * to version history. Associations and permissions have their own methods.
1327
+ * Apply a change set — content patch, `parentId`, `permissions`,
1328
+ * `associations`, `unlisted`, in any combination — as one atomic write
1329
+ * producing one version and one snapshot. Keys are read for presence, so
1330
+ * `unlisted: false` and `parentId: null` are changes.
1331
+ *
1332
+ * Every key present is checked against the record as it stands, and a
1333
+ * change set already satisfied in all of them writes nothing and returns
1334
+ * the record unchanged. The ops the change event carries are derived
1335
+ * from that same comparison, so naming an aspect without moving it is
1336
+ * never reported as moving it.
1337
+ *
1338
+ * Content is validated against the record's *current* stored type and
1339
+ * `typeId` never changes (see docs/spec/data-model.md § Type
1340
+ * migrations). See docs/spec/data-model.md § Mutations.
1283
1341
  */
1284
- async update(id, content, opts = {}) {
1342
+ async mutate(id, changes, opts = {}) {
1285
1343
  this.assertOpen();
1344
+ assertNonEmptyChangeSet(changes);
1286
1345
  const existing = await this.adapter.getRecord(id);
1287
1346
  if (!existing) {
1288
1347
  throw new StackNotFoundError(`Record not found: "${id}"`);
1289
1348
  }
1290
1349
  this.checkIfVersion(existing, opts.ifVersion);
1291
- const type = await this.getTypeCached(existing.typeId);
1292
- if (!type) {
1293
- throw new StackQueryError(`Unknown type: "${existing.typeId}"`);
1294
- }
1295
- // Checked on the raw patch, before the merge: a reserved key in a
1296
- // patch is lost by applyMergePatch rather than stored, and an
1297
- // `undefined` value is indistinguishable from an absent field once
1298
- // merged, so a check on the merged result would see neither. See
1299
- // validateReservedKeys() and validatePatchValues().
1300
- const patchErrors = [
1301
- ...validateReservedKeys(content),
1302
- ...validatePatchValues(content),
1303
- ...validateContentKeys(content),
1304
- ];
1305
- if (patchErrors.length > 0) {
1306
- throw new StackValidationError(patchErrors);
1307
- }
1308
- // The patch is what travels, so the patch is what's measured — a small
1309
- // patch against a large record is not an oversized request.
1310
- assertContentSize(content, this.features.limits.contentBytes, 'Patch');
1311
- // Merge (RFC 7396 / JSON Merge Patch): null values delete a field.
1312
- const merged = applyMergePatch(existing.content, content);
1313
- const errors = validateContent(merged, type.schema);
1314
- if (errors.length > 0) {
1315
- throw new StackValidationError(errors);
1316
- }
1317
- if (existing.typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
1318
- this.checkAttachmentImmutableFields(content, existing.content, merged);
1319
- }
1320
- await this.checkBindingsOnUpdate(existing.typeId, id, content, existing.content, merged);
1321
- if (id === SYSTEM_TYPES.CONFIG) {
1322
- this.checkConfigEntityIdUnchanged(existing.content.entityId, merged.entityId);
1323
- }
1324
- const updated = await this.adapter.patchContent(id, content, {
1350
+ const merged = await this.validateChangeSet(id, existing, changes);
1351
+ const ops = changeSetOps(existing, changes, merged);
1352
+ // Nothing moved, so there is nothing to version. Decided here rather
1353
+ // than in the adapter: "did this change anything" is a question about
1354
+ // the record's meaning, and an adapter is never handed a write that
1355
+ // writes nothing.
1356
+ if (ops.length === 0)
1357
+ return existing;
1358
+ const previousParentId = existing.parentId ?? null;
1359
+ const updated = await this.adapter.mutateRecord(id, effectiveChanges(changes, ops), {
1325
1360
  expectedVersion: opts.ifVersion,
1326
1361
  snapshot: this.buildVersionSnapshot(existing),
1327
1362
  updatedBy: opts.updatedBy,
1328
1363
  updatedVia: opts.updatedVia,
1329
1364
  });
1330
- this.emitChange('update', updated);
1365
+ this.emitChange(ops, updated, ops.includes('reparent') ? { previousParentId } : {});
1331
1366
  return updated;
1332
1367
  }
1368
+ /**
1369
+ * mutate() with `contentPatch` alone. The common case by a wide margin,
1370
+ * and named for what it does rather than for a symmetry with create()
1371
+ * that a patch does not have.
1372
+ */
1373
+ async patchContent(id, patch, opts = {}) {
1374
+ return this.mutate(id, { contentPatch: patch }, opts);
1375
+ }
1376
+ /**
1377
+ * Every check a change set owes before anything is written, in one pass:
1378
+ * one refused key refuses the call, so none of them may run after a
1379
+ * partial write. Returns the merged content when the set carries a
1380
+ * content patch, since the caller needs it to decide whether content
1381
+ * actually moved.
1382
+ *
1383
+ * Parent existence and acyclicity are asked only when the destination
1384
+ * differs from where the record already sits — a move to where it
1385
+ * already is names no new edge, and would otherwise cost a read apiece.
1386
+ */
1387
+ async validateChangeSet(id, existing, changes) {
1388
+ const { contentPatch, permissions, associations, parentId } = changes;
1389
+ const errors = [
1390
+ ...(contentPatch
1391
+ ? [
1392
+ ...validateReservedKeys(contentPatch),
1393
+ ...validatePatchValues(contentPatch),
1394
+ ...validateContentKeys(contentPatch),
1395
+ ]
1396
+ : []),
1397
+ ...(permissions ? validatePermissions(permissions) : []),
1398
+ ...(associations ? validateAssociations(associations) : []),
1399
+ ];
1400
+ if (errors.length > 0)
1401
+ throw new StackValidationError(errors);
1402
+ let merged;
1403
+ if (contentPatch) {
1404
+ // The patch is what travels, so the patch is what's measured — a
1405
+ // small patch against a large record is not an oversized request.
1406
+ assertContentSize(contentPatch, this.features.limits.contentBytes, 'Patch');
1407
+ const type = await this.getTypeCached(existing.typeId);
1408
+ if (!type) {
1409
+ throw new StackQueryError(`Unknown type: "${existing.typeId}"`);
1410
+ }
1411
+ merged = applyMergePatch(existing.content, contentPatch);
1412
+ const contentErrors = validateContent(merged, type.schema);
1413
+ if (contentErrors.length > 0)
1414
+ throw new StackValidationError(contentErrors);
1415
+ if (existing.typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
1416
+ this.checkAttachmentImmutableFields(contentPatch, existing.content, merged);
1417
+ }
1418
+ await this.checkBindingsOnUpdate(existing.typeId, id, contentPatch, existing.content, merged);
1419
+ if (id === SYSTEM_TYPES.CONFIG) {
1420
+ this.checkConfigEntityIdUnchanged(existing.content.entityId, merged.entityId);
1421
+ }
1422
+ }
1423
+ if (associations !== undefined) {
1424
+ this.assertGroupAdminRemains(existing, associations);
1425
+ }
1426
+ if (parentId !== undefined && parentId !== null && parentId !== (existing.parentId ?? null)) {
1427
+ await this.assertParentExists(id, parentId);
1428
+ await this.assertNoParentCycle(id, parentId);
1429
+ }
1430
+ return merged;
1431
+ }
1432
+ /**
1433
+ * Refuse a write that would leave a `_group` Record with no `admin` on
1434
+ * its roster. Asked of the roster the write would *produce* — the one
1435
+ * place a change-set check reads the post-state rather than the
1436
+ * pre-state, because the rule is about what is left behind rather than
1437
+ * what the caller named.
1438
+ *
1439
+ * That framing is the whole of the self-removal question: an `admin`
1440
+ * removing themselves passes while another remains and is refused when
1441
+ * they are the last, and neither case needs to know whose entry went.
1442
+ *
1443
+ * Lives here rather than in `ScopedStack` deliberately. Who may write a
1444
+ * `_group` is a permission question and belongs to the gate up there;
1445
+ * this is an integrity constraint on the Record, so it binds every
1446
+ * requester — the stack owner included, who would otherwise be the hole
1447
+ * that stops anything downstream relying on the invariant.
1448
+ * See docs/spec/identity.md § Group.
1449
+ */
1450
+ assertGroupAdminRemains(record, next) {
1451
+ if (!isGroupRecord(record))
1452
+ return;
1453
+ if (hasGroupAdmin(next))
1454
+ return;
1455
+ throw new StackConflictError(`Cannot leave group "${record.id}" without an admin: a _group record's roster keeps at ` +
1456
+ 'least one `admin` relationship association. Name the incoming admin in the same write.');
1457
+ }
1333
1458
  /**
1334
1459
  * Add an association to a record. Snapshots the record's prior state and
1335
- * bumps version, same as update() — associations are covered by the same
1460
+ * bumps version, same as patchContent() — associations are covered by the same
1336
1461
  * versioning rule as content.
1337
1462
  * If the association already exists (same kind, label, and payload), this
1338
1463
  * is a no-op. Returns the record as it now stands — unchanged on a no-op.
@@ -1377,6 +1502,13 @@ export class Stack {
1377
1502
  if (!(existing.associations ?? []).some((a) => associationEqual(a, association))) {
1378
1503
  return existing;
1379
1504
  }
1505
+ // Removing anything else cannot take the roster to zero, so only an
1506
+ // admin entry is worth deriving the post-state for. Derived rather than
1507
+ // counted so this path and the change set's reach the invariant through
1508
+ // the same helper, asking the same question of the same shape of list.
1509
+ if (isGroupAdminAssociation(association)) {
1510
+ this.assertGroupAdminRemains(existing, (existing.associations ?? []).filter((a) => !associationEqual(a, association)));
1511
+ }
1380
1512
  const updated = await this.adapter.dissociate(id, association, {
1381
1513
  expectedVersion: opts.ifVersion,
1382
1514
  snapshot: this.buildVersionSnapshot(existing),
@@ -1387,63 +1519,59 @@ export class Stack {
1387
1519
  return updated;
1388
1520
  }
1389
1521
  /**
1390
- * Replace all permissions on a record. Snapshots and bumps version, same
1391
- * as associate(). Pass an empty array to make the record private (the
1392
- * default). No-op if the new set is deep-equal to the current one. Returns
1393
- * the record as it now stands — unchanged on a no-op.
1394
- */
1395
- async setPermissions(id, permissions, opts = {}) {
1396
- this.assertOpen();
1397
- const errors = validatePermissions(permissions);
1398
- if (errors.length > 0) {
1399
- throw new StackValidationError(errors);
1400
- }
1401
- const existing = await this.adapter.getRecord(id);
1402
- if (!existing) {
1403
- throw new StackNotFoundError(`Record not found: "${id}"`);
1404
- }
1405
- this.checkIfVersion(existing, opts.ifVersion);
1406
- if (permissionsEqual(existing.permissions ?? [], permissions))
1407
- return existing;
1408
- const updated = await this.adapter.setPermissions(id, permissions, {
1409
- expectedVersion: opts.ifVersion,
1410
- snapshot: this.buildVersionSnapshot(existing),
1411
- updatedBy: opts.updatedBy,
1412
- updatedVia: opts.updatedVia,
1413
- });
1414
- this.emitChange('permissions', updated);
1415
- return updated;
1416
- }
1417
- /**
1418
- * Withhold a record from enumeration, or restore it. Orthogonal to
1419
- * setPermissions(): it says nothing about who may read the record, only
1420
- * whether `query()` and the change feed enumerate it by default. No-op if
1421
- * already in the requested state. Returns the record as it now stands —
1422
- * unchanged on a no-op. See docs/spec/access-control.md §
1423
- * Unlisted records.
1522
+ * Refuse an edge that would make a record its own ancestor — asked at
1523
+ * every site that adds one: a change set naming `parentId`, a restore
1524
+ * putting a container back, and a create naming both its own id and a parent. Nothing
1525
+ * downstream (a generator deriving a page path, a folder view) is
1526
+ * written to survive a cycle.
1424
1527
  *
1425
- * The op passed to emitChange() carries the transition direction —
1426
- * `unlist` (kind `deleted`, so subscribers already holding the record are
1427
- * told to drop it) or `list` (kind `changed`, an upsert like `undelete`,
1428
- * for the record's publish moment).
1429
- */
1430
- async setUnlisted(id, unlisted, opts = {}) {
1431
- this.assertOpen();
1432
- const existing = await this.adapter.getRecord(id);
1433
- if (!existing) {
1434
- throw new StackNotFoundError(`Record not found: "${id}"`);
1528
+ * Walks with the unscoped adapter deliberately: a walk that skipped the
1529
+ * links a requester cannot read would let a cycle be assembled through
1530
+ * them and break the invariant for every reader. The walk is bounded
1531
+ * because a chain long enough to exhaust it is already pathological.
1532
+ *
1533
+ * Read-then-write, so two moves racing on opposite ends of one chain can
1534
+ * both pass — closing that means the invariant lives in the adapter,
1535
+ * where the write is atomic, which puts a graph constraint in the
1536
+ * storage contract every adapter then implements. Same deferral as
1537
+ * checkBindingUnique() above. Consumers that walk `parentId` should
1538
+ * carry a visited set rather than trust this alone; MAX_PARENT_DEPTH is
1539
+ * the same posture applied here.
1540
+ * See docs/spec/data-model.md § Reparenting.
1541
+ */
1542
+ /**
1543
+ * The checks a *caller-named* destination owes, before the cycle walk:
1544
+ * `parentId` is a real, well-formed record id. Format first, so a
1545
+ * malformed one is a 400 naming the problem rather than a read that
1546
+ * cannot match. Existence closes the gap between the owner path and a
1547
+ * non-owner's, where canReadReferent() already refuses a parent that
1548
+ * isn't there.
1549
+ *
1550
+ * restoreVersion() deliberately does not call this — see its own comment.
1551
+ * See docs/spec/data-model.md § Reparenting.
1552
+ */
1553
+ async assertParentExists(id, parentId) {
1554
+ validateParentId(parentId);
1555
+ if (!(await this.adapter.getRecord(parentId))) {
1556
+ throw new StackConflictError(`Cannot parent record "${id}" to "${parentId}": no such record. A container has to ` +
1557
+ 'exist when it is named.');
1558
+ }
1559
+ }
1560
+ async assertNoParentCycle(id, parentId) {
1561
+ let cursor = parentId;
1562
+ for (let depth = 0; cursor !== undefined; depth++) {
1563
+ if (cursor === id) {
1564
+ throw new StackConflictError(`Cannot parent record "${id}" to "${parentId}": it is a descendant of "${id}", ` +
1565
+ 'and the move would make the record its own ancestor.');
1566
+ }
1567
+ // The cap bounds work, and guarantees this terminates on a chain that
1568
+ // is already cyclic. It does not refuse the move: a chain this long is
1569
+ // past what the check can speak to, not evidence of a loop, and
1570
+ // refusing it would claim an invariant core does not maintain.
1571
+ if (depth >= MAX_PARENT_DEPTH)
1572
+ return;
1573
+ cursor = (await this.adapter.getRecord(cursor))?.parentId;
1435
1574
  }
1436
- this.checkIfVersion(existing, opts.ifVersion);
1437
- if (Boolean(existing.unlistedAt) === unlisted)
1438
- return existing;
1439
- const updated = await this.adapter.setUnlisted(id, unlisted, {
1440
- expectedVersion: opts.ifVersion,
1441
- snapshot: this.buildVersionSnapshot(existing),
1442
- updatedBy: opts.updatedBy,
1443
- updatedVia: opts.updatedVia,
1444
- });
1445
- this.emitChange(unlisted ? 'unlist' : 'list', updated);
1446
- return updated;
1447
1575
  }
1448
1576
  /**
1449
1577
  * Soft-delete a record (default) or hard-delete it permanently, removing
@@ -1571,9 +1699,10 @@ export class Stack {
1571
1699
  /**
1572
1700
  * Restore a record to a previous version by creating a new version —
1573
1701
  * never rewrites history. The snapshot is validated against its own
1574
- * stored typeId (not the record's current type), restores associations,
1575
- * and never restores permissions. See docs/spec/versioning.md § Restore
1576
- * semantics.
1702
+ * stored typeId (not the record's current type), restores associations
1703
+ * and `parentId` (absent on the snapshot is the root, so a restore
1704
+ * always settles containment), and never restores permissions. See
1705
+ * docs/spec/versioning.md § Restore semantics.
1577
1706
  */
1578
1707
  async restoreVersion(id, version, opts = {}) {
1579
1708
  this.assertOpen();
@@ -1598,31 +1727,50 @@ export class Stack {
1598
1727
  this.checkConfigEntityIdUnchanged(existing.content.entityId, target.content.entityId);
1599
1728
  }
1600
1729
  // Restoring is a write like any other, so it owes the same immutability
1601
- // check update() pays — a snapshot taken before a card claimed its DID
1730
+ // check a content patch pays — a snapshot taken before a card claimed its DID
1602
1731
  // would otherwise move the binding by rolling content back. Uniqueness
1603
1732
  // needs no separate check here: a restore can only put back a value this
1604
1733
  // same card already held, which immutability already refuses to change.
1605
1734
  for (const field of bindingFieldsOf(baseIdOf(target.typeId))) {
1606
1735
  this.checkBindingImmutable(baseIdOf(target.typeId), field, existing.content[field], target.content[field]);
1607
1736
  }
1737
+ // A restore that puts a container back is an edge-adding site like
1738
+ // a change set's `parentId`, and the chain above that container may have moved since
1739
+ // the snapshot was taken. The cycle walk therefore applies — but
1740
+ // assertParentExists() deliberately does not: a restore is not a caller
1741
+ // naming a destination, it is history being put back, and the container
1742
+ // may have been hard-deleted since. Refusing here would make an
1743
+ // unrelated deletion cost the record its content rollback, which is the
1744
+ // recoverability this whole verb exists for. A dangling parentId is
1745
+ // legal at rest anyway — deleting a container never touches its
1746
+ // children. See docs/spec/versioning.md § Restore semantics.
1747
+ const previousParentId = existing.parentId ?? null;
1748
+ const targetParentId = target.parentId ?? null;
1749
+ const moves = targetParentId !== previousParentId;
1750
+ if (moves && targetParentId !== null) {
1751
+ await this.assertNoParentCycle(id, targetParentId);
1752
+ }
1753
+ // A `_group`'s roster is authority, not data: it does not roll back.
1754
+ // See docs/spec/versioning.md § Restore semantics.
1608
1755
  const restored = await this.adapter.restoreVersion(id, version, {
1609
1756
  expectedVersion: opts.ifVersion,
1610
1757
  snapshot: this.buildVersionSnapshot(existing),
1611
1758
  updatedBy: opts.updatedBy,
1612
1759
  updatedVia: opts.updatedVia,
1760
+ ...(isGroupRecord(existing) && { restoreAssociations: false }),
1613
1761
  });
1614
- this.emitChange('restore', restored);
1762
+ this.emitChange('restore', restored, moves ? { previousParentId } : {});
1615
1763
  return restored;
1616
1764
  }
1617
1765
  /**
1618
1766
  * Commit a per-record migration: replace `content` and `typeId` together
1619
1767
  * in one step, validated against `toTypeId`'s schema exactly as
1620
- * create()/update() validate against a type's schema. The single-record
1768
+ * create()/mutate() validate against a type's schema. The single-record
1621
1769
  * counterpart to migrateAll() — content here is supplied by the caller
1622
1770
  * (computed client-side by the type's owning app, per
1623
1771
  * docs/spec/wire-format.md § Migration commit) rather than a registered
1624
1772
  * Migration function. Snapshots the prior state to version history, same
1625
- * as update()/restoreVersion(), and takes the same optional `ifVersion`
1773
+ * as mutate()/restoreVersion(), and takes the same optional `ifVersion`
1626
1774
  * precondition every version-bumping mutation takes — checked atomically
1627
1775
  * at the adapter, not here (see docs/spec/versioning.md § Optimistic
1628
1776
  * concurrency).
@@ -1632,7 +1780,7 @@ export class Stack {
1632
1780
  * record as it stands, so it owes both sets of integrity checks — the
1633
1781
  * binding rules, the attachment rules, and `_config`'s. Missing either
1634
1782
  * half would make migrate a second, unguarded write path to the same
1635
- * state create()/update() refuse to reach.
1783
+ * state create()/mutate() refuse to reach.
1636
1784
  */
1637
1785
  async commitMigration(id, toTypeId, content, opts = {}) {
1638
1786
  this.assertOpen();
@@ -1718,7 +1866,7 @@ export class Stack {
1718
1866
  /**
1719
1867
  * Immutability for every binding field a patch touches, then uniqueness
1720
1868
  * for the subset that carries it. Fields absent from the patch carry no
1721
- * new claim — update() is a merge, so an untouched binding is the one the
1869
+ * new claim — a content patch is a merge, so an untouched binding is the one the
1722
1870
  * card already holds.
1723
1871
  */
1724
1872
  async checkBindingsOnUpdate(typeId, id, patch, existing, merged) {
@@ -1909,7 +2057,7 @@ export class Stack {
1909
2057
  }
1910
2058
  }
1911
2059
  /**
1912
- * `_config.entityId` defines stack ownership; neither update() nor
2060
+ * `_config.entityId` defines stack ownership; neither patchContent() nor
1913
2061
  * restoreVersion() may change it. A conflict with stack integrity, not a
1914
2062
  * schema violation — hence StackConflictError. See docs/spec.md § The
1915
2063
  * `_config` record.
@@ -2424,8 +2572,9 @@ export class Stack {
2424
2572
  /**
2425
2573
  * Snapshot of a record's prior state, passed with the mutating adapter
2426
2574
  * call so snapshot and mutation land in one atomic write. `associations`
2427
- * is always present ([] when empty) so restore can distinguish "cleared"
2428
- * from a snapshot that omits the key entirely ("leave as-is"). See
2575
+ * and `parentId` are always present (`[]` and `null` where the record has
2576
+ * neither) so restore can distinguish "cleared" and "at the root" from a
2577
+ * snapshot that omits the key entirely ("leave as-is"). See
2429
2578
  * docs/spec/versioning.md § Version history.
2430
2579
  */
2431
2580
  buildVersionSnapshot(record) {
@@ -2437,6 +2586,7 @@ export class Stack {
2437
2586
  ...(record.entityId && { entityId: record.entityId }),
2438
2587
  ...(record.updatedBy && { updatedBy: record.updatedBy }),
2439
2588
  ...(record.updatedVia && { updatedVia: record.updatedVia }),
2589
+ ...(record.parentId !== undefined && { parentId: record.parentId }),
2440
2590
  associations: record.associations ?? [],
2441
2591
  ...(record.permissions && { permissions: record.permissions }),
2442
2592
  };
@@ -2485,6 +2635,89 @@ function permissionEqual(a, b) {
2485
2635
  }
2486
2636
  return false;
2487
2637
  }
2638
+ /**
2639
+ * A change set has to name at least one aspect. Refused rather than read
2640
+ * as a no-op: it addresses nothing, so there is nothing it could have
2641
+ * failed to satisfy, and every way of producing one is a caller bug —
2642
+ * typically a conditional that built an empty object.
2643
+ * See docs/spec/data-model.md § Mutations.
2644
+ */
2645
+ function assertNonEmptyChangeSet(changes) {
2646
+ // Presence, not truthiness: `unlisted: false` and `parentId: null` are
2647
+ // aspects this call names, and reading them as absent would drop a
2648
+ // change the caller asked for.
2649
+ if (RECORD_CHANGE_KEYS.some((key) => changes[key] !== undefined))
2650
+ return;
2651
+ throw new StackQueryError('A change set names at least one of: ' + RECORD_CHANGE_KEYS.join(', ') + '.');
2652
+ }
2653
+ /**
2654
+ * Which aspects a change set actually moves, against the record as it
2655
+ * stands — the no-op decision and the change event's `ops` are the same
2656
+ * comparison, so they can never disagree. A key naming the value a record
2657
+ * already holds contributes nothing.
2658
+ *
2659
+ * `merged` is the content the patch produces, computed once by the
2660
+ * caller that had to validate it anyway.
2661
+ * See docs/spec/events.md § The event shape.
2662
+ */
2663
+ function changeSetOps(existing, changes, merged) {
2664
+ const ops = [];
2665
+ if (merged !== undefined && !contentEqual(existing.content, merged))
2666
+ ops.push('patch');
2667
+ if (changes.parentId !== undefined && changes.parentId !== (existing.parentId ?? null)) {
2668
+ ops.push('reparent');
2669
+ }
2670
+ if (changes.permissions && !permissionsEqual(existing.permissions ?? [], changes.permissions)) {
2671
+ ops.push('permissions');
2672
+ }
2673
+ if (changes.associations) {
2674
+ const before = existing.associations ?? [];
2675
+ const after = changes.associations;
2676
+ if (after.some((a) => !before.some((b) => associationEqual(a, b))))
2677
+ ops.push('associate');
2678
+ if (before.some((b) => !after.some((a) => associationEqual(a, b))))
2679
+ ops.push('dissociate');
2680
+ }
2681
+ if (changes.unlisted !== undefined && Boolean(existing.unlistedAt) !== changes.unlisted) {
2682
+ ops.push(changes.unlisted ? 'unlist' : 'list');
2683
+ }
2684
+ return ops;
2685
+ }
2686
+ /**
2687
+ * The change set narrowed to the aspects that actually moved. An adapter
2688
+ * is handed this rather than what the caller wrote, so restating an aspect
2689
+ * cannot rewrite it: `unlisted: true` on an already-unlisted record would
2690
+ * otherwise drag `unlistedAt` forward, moving the record's publish moment
2691
+ * with no op reporting it. It also keeps the adapter contract honest —
2692
+ * every key an adapter receives is one it must write.
2693
+ */
2694
+ function effectiveChanges(changes, ops) {
2695
+ const effective = {};
2696
+ if (ops.includes('patch'))
2697
+ effective.contentPatch = changes.contentPatch;
2698
+ if (ops.includes('reparent'))
2699
+ effective.parentId = changes.parentId;
2700
+ if (ops.includes('permissions'))
2701
+ effective.permissions = changes.permissions;
2702
+ if (ops.includes('associate') || ops.includes('dissociate')) {
2703
+ effective.associations = changes.associations;
2704
+ }
2705
+ if (ops.includes('unlist') || ops.includes('list'))
2706
+ effective.unlisted = changes.unlisted;
2707
+ return effective;
2708
+ }
2709
+ /**
2710
+ * Whether a merge patch produced the content the record already held.
2711
+ * Compared by serialization: content is JSON by construction — it round
2712
+ * trips through storage that way — and a patch preserves key order for
2713
+ * every field it does not name, so the encoding of an unchanged record is
2714
+ * stable. A reordering patch that changes nothing else is the one case
2715
+ * this reports as a change, which costs an empty version rather than a
2716
+ * wrong answer.
2717
+ */
2718
+ function contentEqual(a, b) {
2719
+ return JSON.stringify(a) === JSON.stringify(b);
2720
+ }
2488
2721
  function permissionsEqual(a, b) {
2489
2722
  return a.length === b.length && a.every((p, i) => permissionEqual(p, b[i]));
2490
2723
  }
@@ -2494,6 +2727,13 @@ function permissionsEqual(a, b) {
2494
2727
  /** Default page size used to fill a permission-filtered query result. */
2495
2728
  const DEFAULT_QUERY_LIMIT = 50;
2496
2729
  const MAX_QUERY_LIMIT = 1000;
2730
+ /**
2731
+ * How far a change set's `parentId` will walk a proposed ancestor chain before refusing
2732
+ * the move. Bounds the reads one call can cost; a hierarchy deeper than
2733
+ * this is beyond what `parentId` is for. See docs/spec/data-model.md
2734
+ * § Reparenting.
2735
+ */
2736
+ const MAX_PARENT_DEPTH = 64;
2497
2737
  /**
2498
2738
  * Ensures `creator` carries an `admin` relationship association, adding one
2499
2739
  * if it's not already present. Used to bootstrap a `_group` record's first
@@ -2512,6 +2752,13 @@ function stampGroupAdmin(associations, creator) {
2512
2752
  { kind: 'relationship', label: 'admin', target: { scope: 'entity', entityId: creator } },
2513
2753
  ];
2514
2754
  }
2755
+ /**
2756
+ * Whether a Record is a `_group`, in any of its type versions — the family
2757
+ * whose roster rules apply.
2758
+ */
2759
+ function isGroupRecord(record) {
2760
+ return baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP;
2761
+ }
2515
2762
  /**
2516
2763
  * Drops a snapshot's `permissions` — owner-only audit data, never served
2517
2764
  * to a non-owner history reader. `entityId` (change attribution) stays.
@@ -2554,7 +2801,7 @@ const presentDeleted = (record) => record.deletedAt ? tombstoneOf(record) : reco
2554
2801
  *
2555
2802
  * Two identities, one rule: **the principal governs authority, the subject
2556
2803
  * governs attribution.** Grant lookup and the privilege-bearing gates that
2557
- * no grant reaches (setPermissions, group management, hard delete, widening
2804
+ * no grant reaches (resharing, group management, hard delete, widening
2558
2805
  * access at create time) key on `principalEntityId`; authorship, `-own`
2559
2806
  * matching, record-level permission resolution, and "files I uploaded"
2560
2807
  * lookups key on `subjectEntityId`.
@@ -2897,7 +3144,7 @@ export class ScopedStack {
2897
3144
  * `_group` records are managed, not merely written: only the owner or an
2898
3145
  * `admin` roster holder may mutate them — ordinary write permissions and
2899
3146
  * grants don't apply. Asked of both identities under delegation, like
2900
- * setPermissions(). See docs/spec/identity.md § Group.
3147
+ * a reshare. See docs/spec/identity.md § Group.
2901
3148
  */
2902
3149
  isGroupManager(record) {
2903
3150
  if (!this.managesGroup(this.principalEntityId, record))
@@ -3059,7 +3306,7 @@ export class ScopedStack {
3059
3306
  * Gates file-ref content fields on file access, mirroring the
3060
3307
  * attachment-association gate — a file-ref field conveys attachment
3061
3308
  * access exactly like an `attachment` association. Only fields present
3062
- * in `content` are checked (update() is a merge patch; untouched fields
3309
+ * in `content` are checked (a content patch is a merge patch; untouched fields
3063
3310
  * carry no new reference).
3064
3311
  */
3065
3312
  async requireFileRefAccess(typeId, content) {
@@ -3178,7 +3425,7 @@ export class ScopedStack {
3178
3425
  }
3179
3426
  /**
3180
3427
  * Whether this request may decide who else reaches a record — the rule
3181
- * setPermissions() enforces, asked at create time too so the reach it
3428
+ * a reshare enforces, asked at create time too so the reach it
3182
3429
  * withholds can't be taken one step earlier while authoring. A delegated
3183
3430
  * app is denied it: widening access is the one thing containment most
3184
3431
  * needs to hold. Refused rather than silently ignored, so an app never
@@ -3192,7 +3439,7 @@ export class ScopedStack {
3192
3439
  }
3193
3440
  /**
3194
3441
  * Whether one identity, on its own, may decide who else reaches `record`
3195
- * — the owner-or-creator rule setPermissions() enforces, asked of one
3442
+ * — the owner-or-creator rule a reshare enforces, asked of one
3196
3443
  * side at a time. See
3197
3444
  * docs/spec/access-control.md § Delegation: principal and subject.
3198
3445
  */
@@ -3268,28 +3515,105 @@ export class ScopedStack {
3268
3515
  } while (records.length < limit && page.cursor && totalFetched < maxFetched);
3269
3516
  return { records, cursor: page.cursor };
3270
3517
  }
3271
- async update(id, content, opts = {}) {
3272
- const record = await this.requireUpdatable(id);
3273
- // Ahead of the binding fences below, which compare the patch's value
3274
- // for a field against the stored one: `undefined` names a field
3275
- // without claiming a value, so it is refused as a malformed patch
3276
- // rather than read as a change. See Stack.update() for the same check
3277
- // on every other path into a patch.
3278
- const patchErrors = validatePatchValues(content);
3279
- if (patchErrors.length > 0)
3280
- throw new StackValidationError(patchErrors);
3281
- // Value-wise, not presence-wise: a client that reads a card, edits its
3282
- // `name` and sends the whole content object back is not setting the
3283
- // binding it round-trips, and `name` is writable by record-level
3284
- // permission. Same predicate restoreVersion() applies to a snapshot.
3285
- this.requireOwnerForAppIdentity(record.typeId, (field) => field in content && content[field] !== record.content[field]);
3286
- // Likewise value-wise: re-sending the DID a card already holds claims
3287
- // nothing, and immutability refuses changing it regardless.
3288
- if ('did' in content && content.did !== record.content.did) {
3289
- this.requireOwnerForOwnerDid(record.typeId, content.did);
3290
- }
3291
- await this.requireFileRefAccess(record.typeId, content);
3292
- return this.stack.update(id, content, { ...opts, ...this.actor });
3518
+ /**
3519
+ * Apply a change set on behalf of the subject. Authority is resolved
3520
+ * **per key** and every gate reads the record as it stands, never as the
3521
+ * change set would leave it: a widened `permissions` never satisfies the
3522
+ * read check on a `parentId` named in the same call, and a `_group`
3523
+ * roster never satisfies the admin check that same call has to pass.
3524
+ * One refused key refuses the whole call, so nothing is partially
3525
+ * applied and no key is silently dropped.
3526
+ * See docs/spec/access-control.md § Composing a change set.
3527
+ */
3528
+ async mutate(id, changes, opts = {}) {
3529
+ // Ahead of every gate: a malformed change set is a validation error
3530
+ // for every requester, rather than one for the owner and a permission
3531
+ // refusal for everyone else.
3532
+ assertNonEmptyChangeSet(changes);
3533
+ if (changes.contentPatch) {
3534
+ const patchErrors = validatePatchValues(changes.contentPatch);
3535
+ if (patchErrors.length > 0)
3536
+ throw new StackValidationError(patchErrors);
3537
+ }
3538
+ const reshares = changes.permissions !== undefined || changes.unlisted !== undefined;
3539
+ const writes = changes.contentPatch !== undefined ||
3540
+ changes.associations !== undefined ||
3541
+ changes.parentId !== undefined;
3542
+ // requireUpdatable() reads the record and applies the write gate; the
3543
+ // reshare keys need the record before their own gate, and a change set
3544
+ // carrying only those must not be held to the write gate it doesn't
3545
+ // need. One read either way.
3546
+ const record = writes ? await this.requireUpdatable(id) : await this.requireReshareable(id);
3547
+ if (writes && reshares)
3548
+ await this.requireReshareOf(record);
3549
+ if (changes.contentPatch) {
3550
+ const patch = changes.contentPatch;
3551
+ // Value-wise, not presence-wise: a client that reads a card, edits
3552
+ // its `name` and sends the whole content object back is not setting
3553
+ // the binding it round-trips, and `name` is writable by record-level
3554
+ // permission. Same predicate restoreVersion() applies to a snapshot.
3555
+ this.requireOwnerForAppIdentity(record.typeId, (field) => field in patch && patch[field] !== record.content[field]);
3556
+ // Likewise value-wise: re-sending the DID a card already holds
3557
+ // claims nothing, and immutability refuses changing it regardless.
3558
+ if ('did' in patch && patch.did !== record.content.did) {
3559
+ this.requireOwnerForOwnerDid(record.typeId, patch.did);
3560
+ }
3561
+ await this.requireFileRefAccess(record.typeId, patch);
3562
+ }
3563
+ // Every association the change set would add is a reference this
3564
+ // requester has to be allowed to create — asked against the stored
3565
+ // set, so an association already on the record is not re-gated.
3566
+ for (const assoc of changes.associations ?? []) {
3567
+ if ((record.associations ?? []).some((a) => associationEqual(a, assoc)))
3568
+ continue;
3569
+ await this.requireAssociationAccess(record.typeId, assoc);
3570
+ }
3571
+ if (changes.parentId != null && !(await this.canReadReferent(changes.parentId))) {
3572
+ throw new StackPermissionError();
3573
+ }
3574
+ return this.stack.mutate(id, changes, { ...opts, ...this.actor });
3575
+ }
3576
+ async patchContent(id, patch, opts = {}) {
3577
+ return this.mutate(id, { contentPatch: patch }, opts);
3578
+ }
3579
+ /**
3580
+ * The record, having established that this request may decide who else
3581
+ * reaches it — the owner-or-creator rule, asked of both sides of a
3582
+ * delegation, or a Group's admin rule for a `_group`. The gate the
3583
+ * `permissions` and `unlisted` keys carry, which the write bit
3584
+ * deliberately does not confer.
3585
+ */
3586
+ async requireReshareable(id) {
3587
+ const record = await this.stack.get(id);
3588
+ if (!record)
3589
+ throw new StackNotFoundError(`Record not found: "${id}"`);
3590
+ await this.requireOwnerForGrantRecord(record);
3591
+ await this.requireReshareOf(record);
3592
+ // Reached through its own gate rather than requireUpdatable(), so the
3593
+ // soft-delete refusal has to be asked here too — after the authority
3594
+ // decision above, for the reason refuseIfDeleted() gives.
3595
+ return this.refuseIfDeleted(record, true);
3596
+ }
3597
+ /** The reshare decision alone, for a record already read and write-gated. */
3598
+ async requireReshareOf(record) {
3599
+ if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
3600
+ // Group management, not authorship: a creator later demoted from the
3601
+ // admin roster shouldn't retain a side door to reassign who can read
3602
+ // or write the group record.
3603
+ if (!this.isGroupManager(record))
3604
+ throw await this.denialFor(record);
3605
+ return;
3606
+ }
3607
+ // Intersected like every other authority here: the principal must hold
3608
+ // the verb, and the subject must be able to reach this record —
3609
+ // without which an owner principal would carry its subject to records
3610
+ // the subject cannot touch. create() withholds the same reach via
3611
+ // mayGrantAccess().
3612
+ if (!this.mayReshare(this.principalEntityId, record))
3613
+ throw await this.denialFor(record);
3614
+ if (this.delegated && !this.mayReshare(this.subjectEntityId, record)) {
3615
+ throw await this.denialFor(record);
3616
+ }
3293
3617
  }
3294
3618
  /**
3295
3619
  * Naming the software behind a key is the trust decision the `_app`
@@ -3388,62 +3712,6 @@ export class ScopedStack {
3388
3712
  await this.requireUpdatable(id);
3389
3713
  return this.stack.dissociate(id, association, { ...opts, ...this.actor });
3390
3714
  }
3391
- async setPermissions(id, permissions, opts = {}) {
3392
- const record = await this.stack.get(id);
3393
- if (!record)
3394
- throw new StackNotFoundError(`Record not found: "${id}"`);
3395
- await this.requireOwnerForGrantRecord(record);
3396
- if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
3397
- // Group management, not authorship: a creator later demoted from the
3398
- // admin roster shouldn't retain a side door to reassign who can read
3399
- // or write the group record. Same gate as update/associate/delete.
3400
- if (!this.isGroupManager(record))
3401
- throw await this.denialFor(record);
3402
- }
3403
- else {
3404
- // Intersected like every other authority here: the principal must
3405
- // hold the verb, and the subject must be able to reach this record —
3406
- // without which an owner principal would carry its subject to records
3407
- // the subject cannot touch. create() withholds the same reach via
3408
- // mayGrantAccess().
3409
- if (!this.mayReshare(this.principalEntityId, record))
3410
- throw await this.denialFor(record);
3411
- if (this.delegated && !this.mayReshare(this.subjectEntityId, record)) {
3412
- throw await this.denialFor(record);
3413
- }
3414
- }
3415
- // Reached through its own gate rather than requireUpdatable(), so the
3416
- // soft-delete refusal has to be asked here too — after the authority
3417
- // decision above, for the reason refuseIfDeleted() gives.
3418
- this.refuseIfDeleted(record, true);
3419
- return this.stack.setPermissions(id, permissions, { ...opts, ...this.actor });
3420
- }
3421
- /**
3422
- * Withhold a record from enumeration, or restore it — gated exactly like
3423
- * setPermissions(), since both decide who or what can discover the
3424
- * record rather than merely read it once found. See
3425
- * docs/spec/unlisted.md.
3426
- */
3427
- async setUnlisted(id, unlisted, opts = {}) {
3428
- const record = await this.stack.get(id);
3429
- if (!record)
3430
- throw new StackNotFoundError(`Record not found: "${id}"`);
3431
- await this.requireOwnerForGrantRecord(record);
3432
- if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
3433
- if (!this.isGroupManager(record))
3434
- throw await this.denialFor(record);
3435
- }
3436
- else {
3437
- if (!this.mayReshare(this.principalEntityId, record))
3438
- throw await this.denialFor(record);
3439
- if (this.delegated && !this.mayReshare(this.subjectEntityId, record)) {
3440
- throw await this.denialFor(record);
3441
- }
3442
- }
3443
- // See setPermissions() above — same gate, same reason.
3444
- this.refuseIfDeleted(record, true);
3445
- return this.stack.setUnlisted(id, unlisted, { ...opts, ...this.actor });
3446
- }
3447
3715
  /**
3448
3716
  * Hard delete is owner-only: it is irreversible and destroys version
3449
3717
  * history, so neither the write bit nor delete-own/delete-any grants
@@ -3468,7 +3736,7 @@ export class ScopedStack {
3468
3736
  }
3469
3737
  /**
3470
3738
  * History is the mutation/recovery surface, not a read surface — gated
3471
- * like update(), with snapshot `permissions` stripped for everyone but
3739
+ * like patchContent(), with snapshot `permissions` stripped for everyone but
3472
3740
  * the owner acting alone — a snapshot's permissions are the stack's
3473
3741
  * sharing graph, which delegation is not a route to. Reading history
3474
3742
  * changes nothing, so it is the one path the `_grant` write fence leaves
@@ -3493,9 +3761,11 @@ export class ScopedStack {
3493
3761
  /**
3494
3762
  * Re-runs the reference-creation checks against the snapshot, so a
3495
3763
  * restore can't re-convey access to a file or record the subject can no
3496
- * longer reach today. Only the owner acting alone is exempt: under
3497
- * delegation the checks resolve against the subject, which is whose reach
3498
- * the restore would widen. See docs/spec/versioning.md § Restore semantics.
3764
+ * longer reach today — the snapshot's `parentId` among them, gated
3765
+ * exactly as a change set's `parentId` gates a destination named directly. Only the
3766
+ * owner acting alone is exempt: under delegation the checks resolve
3767
+ * against the subject, which is whose reach the restore would widen.
3768
+ * See docs/spec/versioning.md § Restore semantics.
3499
3769
  */
3500
3770
  async restoreVersion(id, version, opts = {}) {
3501
3771
  const record = await this.requireUpdatable(id);
@@ -3503,12 +3773,29 @@ export class ScopedStack {
3503
3773
  const target = await this.stack.getVersion(id, version);
3504
3774
  if (target) {
3505
3775
  // A rollback that would move a card's binding is the same trust
3506
- // decision update() reserves to the owner, reached by another route.
3776
+ // decision a content patch reserves to the owner, reached by another route.
3507
3777
  this.requireOwnerForAppIdentity(record.typeId, (field) => target.content[field] !==
3508
3778
  record.content[field]);
3779
+ // Only a restore that moves the record *into* a container creates a
3780
+ // reference. A snapshot at the root names nothing, and a parentId
3781
+ // the restore would not change is not re-gated: the record is
3782
+ // already there, so a content rollback is not refused over a move
3783
+ // it isn't making.
3784
+ if (target.parentId !== undefined &&
3785
+ target.parentId !== record.parentId &&
3786
+ !(await this.canReadReferent(target.parentId))) {
3787
+ throw new StackPermissionError();
3788
+ }
3509
3789
  await this.requireFileRefAccess(target.typeId, target.content);
3510
- for (const association of target.associations ?? []) {
3511
- await this.requireAssociationAccess(target.typeId, association);
3790
+ // Gated against the associations the restore will actually write.
3791
+ // A `_group`'s don't roll back at all, so such a restore introduces
3792
+ // no association and there is nothing here to gate — the same
3793
+ // reason a `parentId` the restore would not change is not re-gated
3794
+ // above.
3795
+ if (!isGroupRecord(record)) {
3796
+ for (const association of target.associations ?? []) {
3797
+ await this.requireAssociationAccess(target.typeId, association);
3798
+ }
3512
3799
  }
3513
3800
  }
3514
3801
  }
@@ -3526,7 +3813,7 @@ export class ScopedStack {
3526
3813
  * The restriction is what makes the verb safe to expose at all. Migrate
3527
3814
  * replaces `content` and `typeId` wholesale, so a grant-based version
3528
3815
  * would have to re-derive every gate `create()` applies at the
3529
- * destination *and* every gate `update()` applies over the existing
3816
+ * destination *and* every gate `mutate()` applies over the existing
3530
3817
  * content, and would reopen each one it missed. The sharpest is the
3531
3818
  * non-owner `_attachment@1` refusal create() carries: without it, a
3532
3819
  * requester holding a create grant on `_attachment@1` and write access