@haverstack/core 0.8.0 → 0.10.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 (65) hide show
  1. package/README.md +30 -14
  2. package/dist/access.d.ts +20 -7
  3. package/dist/access.d.ts.map +1 -1
  4. package/dist/access.js +37 -16
  5. package/dist/access.js.map +1 -1
  6. package/dist/adapter-entry.d.ts +12 -0
  7. package/dist/adapter-entry.d.ts.map +1 -0
  8. package/dist/adapter-entry.js +11 -0
  9. package/dist/adapter-entry.js.map +1 -0
  10. package/dist/attachment-download.d.ts +75 -0
  11. package/dist/attachment-download.d.ts.map +1 -0
  12. package/dist/attachment-download.js +128 -0
  13. package/dist/attachment-download.js.map +1 -0
  14. package/dist/auth.d.ts +88 -0
  15. package/dist/auth.d.ts.map +1 -0
  16. package/dist/auth.js +145 -0
  17. package/dist/auth.js.map +1 -0
  18. package/dist/combine.d.ts.map +1 -1
  19. package/dist/combine.js +20 -3
  20. package/dist/combine.js.map +1 -1
  21. package/dist/did-entry.d.ts +12 -0
  22. package/dist/did-entry.d.ts.map +1 -0
  23. package/dist/did-entry.js +16 -0
  24. package/dist/did-entry.js.map +1 -0
  25. package/dist/did.d.ts +51 -0
  26. package/dist/did.d.ts.map +1 -0
  27. package/dist/did.js +153 -0
  28. package/dist/did.js.map +1 -0
  29. package/dist/id.d.ts +18 -6
  30. package/dist/id.d.ts.map +1 -1
  31. package/dist/id.js +39 -10
  32. package/dist/id.js.map +1 -1
  33. package/dist/index.d.ts +15 -10
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +14 -9
  36. package/dist/index.js.map +1 -1
  37. package/dist/merge.d.ts +11 -0
  38. package/dist/merge.d.ts.map +1 -0
  39. package/dist/merge.js +38 -0
  40. package/dist/merge.js.map +1 -0
  41. package/dist/schema.d.ts +31 -9
  42. package/dist/schema.d.ts.map +1 -1
  43. package/dist/schema.js +134 -11
  44. package/dist/schema.js.map +1 -1
  45. package/dist/stack.d.ts +850 -152
  46. package/dist/stack.d.ts.map +1 -1
  47. package/dist/stack.js +2017 -365
  48. package/dist/stack.js.map +1 -1
  49. package/dist/testing.d.ts +85 -23
  50. package/dist/testing.d.ts.map +1 -1
  51. package/dist/testing.js +261 -19
  52. package/dist/testing.js.map +1 -1
  53. package/dist/types.d.ts +281 -31
  54. package/dist/types.d.ts.map +1 -1
  55. package/dist/types.js +14 -0
  56. package/dist/types.js.map +1 -1
  57. package/dist/validate.d.ts +26 -0
  58. package/dist/validate.d.ts.map +1 -1
  59. package/dist/validate.js +51 -1
  60. package/dist/validate.js.map +1 -1
  61. package/dist/wire-entry.d.ts +17 -0
  62. package/dist/wire-entry.d.ts.map +1 -0
  63. package/dist/wire-entry.js +24 -0
  64. package/dist/wire-entry.js.map +1 -0
  65. package/package.json +13 -1
package/dist/stack.d.ts CHANGED
@@ -7,51 +7,240 @@
7
7
  * - ID generation
8
8
  * - Type definition and schema hashing
9
9
  * - Content validation on write
10
- * - Migration registry and auto-migration on read
10
+ * - Migration registry and explicit, owner-driven migrateAll()
11
11
  * - Version snapshotting on update
12
12
  * - Soft and hard delete
13
13
  *
14
14
  * Apps should never talk to a StackAdapter directly.
15
15
  */
16
+ import type { SchemaDriftViolation } from './schema.js';
16
17
  import type { ValidationError } from './validate.js';
17
- import type { StackRecord, StackType, TypeSchema, TypeId, StackAdapter, StackQuery, QueryResult, Association, Permission, Migration, RecordVersion, StackFeatures, GrantAction } from './types.js';
18
+ import type { StackRecord, StackType, TypeSchema, TypeId, StackAdapter, StackQuery, RecordFilter, QueryResult, Association, Permission, Migration, RecordVersion, StackFeatures, GrantAction, AttachmentContent, EntityId, AppId, TokenSession } from './types.js';
18
19
  export type CreateRecordOptions = {
20
+ /**
21
+ * Client-minted record ID. Must be 12 lowercase Crockford base-32
22
+ * characters and may not use the reserved `_` prefix. Omit to let the
23
+ * library generate one. See Stack.create() and ScopedStack.create()
24
+ * for the validation each applies.
25
+ */
26
+ id?: string;
19
27
  parentId?: string;
20
- entityId?: string;
21
- appId?: string;
28
+ entityId?: EntityId;
29
+ /** Reverse-DNS identifier of the writing software — see AppId. */
30
+ appId?: AppId;
31
+ /**
32
+ * The authenticated principal, when it isn't the author. ScopedStack sets
33
+ * this from its own principal; callers of plain Stack supply it only when
34
+ * reconstructing a delegated write. See StackRecord.principalId.
35
+ */
36
+ principalId?: EntityId;
22
37
  permissions?: Permission[];
23
38
  associations?: Association[];
24
39
  };
40
+ export type ScopedStackOptions = {
41
+ /**
42
+ * The entity a delegated app acts for. Omit when the principal acts as
43
+ * itself — the case for an app riding its user's identity, which needs
44
+ * no delegation. See docs/spec/identity.md § App.
45
+ */
46
+ onBehalfOf?: EntityId;
47
+ };
48
+ export type StackOptions = {
49
+ /**
50
+ * Ensures the owner's own `_entity` profile record exists, creating it on
51
+ * first run. Idempotent — safe to pass on every open. See
52
+ * docs/spec/identity.md § Entity.
53
+ */
54
+ ownerProfile?: {
55
+ name: string;
56
+ handle?: string;
57
+ };
58
+ /**
59
+ * Clock-skew tolerance (ms) for the timestamp-prefix check
60
+ * ScopedStack.create() runs on grantee-supplied IDs; unscoped
61
+ * Stack.create() never runs it. Default: 24 hours; null disables.
62
+ * See docs/spec/data-model.md § Record IDs.
63
+ */
64
+ idTimestampSkewMs?: number | null;
65
+ };
66
+ export type CollectAttachmentGarbageOptions = {
67
+ /**
68
+ * How recent an unreferenced file must be to survive collection, covering
69
+ * the upload-then-associate window. Default: 24 hours. Pass 0 to collect
70
+ * anything unreferenced right now.
71
+ */
72
+ graceMs?: number;
73
+ /** Compute what would be deleted without deleting anything. Default: false. */
74
+ dryRun?: boolean;
75
+ };
76
+ export type CollectAttachmentGarbageResult = {
77
+ deleted: string[];
78
+ reclaimedBytes: number;
79
+ };
25
80
  export type GetRecordOptions = {
26
- /** If false, return the raw stored record without auto-migrating. Default: true */
27
- migrate?: boolean;
81
+ /**
82
+ * Records are returned exactly as stored by default. Pass "latest" to
83
+ * apply the registered migration chain in memory — never written back.
84
+ * See docs/spec/data-model.md § Type migrations.
85
+ */
86
+ presentAt?: 'stored' | 'latest';
28
87
  };
29
- export type DeleteRecordOptions = {
88
+ /**
89
+ * Opt-in optimistic-concurrency precondition, accepted by every mutation
90
+ * that bumps a record's version. On mismatch the mutation throws
91
+ * StackVersionConflictError and changes nothing; omit to keep
92
+ * last-writer-wins. See docs/spec/versioning.md § Optimistic concurrency (`ifVersion`).
93
+ */
94
+ export type IfVersionOptions = {
95
+ ifVersion?: number;
96
+ };
97
+ export type DeleteRecordOptions = IfVersionOptions & {
30
98
  /** If true, permanently remove the record and all its history. Default: false */
31
99
  hard?: boolean;
32
100
  };
33
101
  export type DefineTypeOptions = {
34
102
  migratesFrom?: TypeId;
35
103
  };
36
- export declare class StackValidationError extends Error {
104
+ /**
105
+ * The wire-protocol discriminator vocabulary, one code per Stack-domain
106
+ * error class. Lives here rather than in @haverstack/wire-types because the
107
+ * classes that carry these codes are defined here; wire-types re-exports it
108
+ * as WireErrorCode. See docs/spec/wire-format.md § Wire error body.
109
+ */
110
+ export type StackErrorCode = 'bad_request' | 'permission' | 'not_found' | 'conflict' | 'version_conflict' | 'validation' | 'migration' | 'schema_drift' | 'payload_too_large' | 'timeout';
111
+ /**
112
+ * Root of the Stack error taxonomy. A single `instanceof StackError` answers
113
+ * "is this a Stack-domain error or a bug?" — the question a server's error
114
+ * middleware asks before serializing a wire body, and one a nine-arm
115
+ * instanceof ladder answers only by exhaustion. Every subclass carries its
116
+ * discriminator as an instance `code`, so serialization is a lookup rather
117
+ * than a chain of class tests.
118
+ *
119
+ * Membership implies a wire mapping: every code has an entry in
120
+ * WIRE_ERROR_STATUS. Errors with no wire representation (IdGenerationError,
121
+ * InvalidDidError) deliberately stay outside this hierarchy.
122
+ *
123
+ * Subclassing adds no hierarchy beyond this root — notably
124
+ * StackVersionConflictError is a sibling of StackConflictError, not a
125
+ * subtype. See docs/spec/wire-format.md § Error responses.
126
+ */
127
+ export declare abstract class StackError extends Error {
128
+ abstract readonly code: StackErrorCode;
129
+ }
130
+ export declare class StackValidationError extends StackError {
37
131
  readonly errors: ValidationError[];
132
+ static readonly code: "validation";
133
+ readonly code: "validation";
38
134
  constructor(errors: ValidationError[]);
39
135
  }
40
- export declare class StackMigrationError extends Error {
136
+ export declare class StackMigrationError extends StackError {
137
+ static readonly code: "migration";
138
+ readonly code: "migration";
41
139
  constructor(message: string);
42
140
  }
43
141
  /** Thrown by ScopedStack when a requester lacks permission for the operation. */
44
- export declare class StackPermissionError extends Error {
142
+ export declare class StackPermissionError extends StackError {
143
+ static readonly code: "permission";
144
+ readonly code: "permission";
45
145
  constructor(message?: string);
46
146
  }
47
147
  /** Thrown when a record (or specific version) does not exist. */
48
- export declare class StackNotFoundError extends Error {
148
+ export declare class StackNotFoundError extends StackError {
149
+ static readonly code: "not_found";
150
+ readonly code: "not_found";
49
151
  constructor(message: string);
50
152
  }
51
153
  /** Thrown when an operation cannot proceed due to a constraint violation (e.g. deleting an attachment that is still referenced). */
52
- export declare class StackConflictError extends Error {
154
+ export declare class StackConflictError extends StackError {
155
+ static readonly code: "conflict";
156
+ readonly code: "conflict";
157
+ constructor(message: string);
158
+ }
159
+ /**
160
+ * Thrown when an `ifVersion` precondition doesn't match a record's current
161
+ * version. Deliberately not a StackConflictError subtype — the two have
162
+ * different recovery stories and HTTP statuses (409 vs. 412). See
163
+ * docs/spec/versioning.md § Optimistic concurrency (`ifVersion`).
164
+ */
165
+ export declare class StackVersionConflictError extends StackError {
166
+ readonly recordId: string;
167
+ readonly expectedVersion: number;
168
+ readonly actualVersion: number;
169
+ static readonly code: "version_conflict";
170
+ readonly code: "version_conflict";
171
+ constructor(message: string, recordId: string, expectedVersion: number, actualVersion: number);
172
+ }
173
+ /**
174
+ * Thrown when a request is structurally malformed — not a content-validation
175
+ * failure, but input the adapter/server can't even interpret (e.g. an
176
+ * undecodable pagination cursor). Distinct from StackValidationError, which
177
+ * means the request was well-formed but content failed schema validation.
178
+ */
179
+ export declare class StackQueryError extends StackError {
180
+ static readonly code: "bad_request";
181
+ readonly code: "bad_request";
182
+ constructor(message: string);
183
+ }
184
+ /**
185
+ * Fail loud rather than silently widen: a filter the adapter can't honor
186
+ * would otherwise be dropped, returning an unfiltered superset presented
187
+ * as the filtered result. Shared by Stack.query() and
188
+ * APIAdapter.queryRecords(). See docs/spec/data-model.md
189
+ * § Capability-gated filters.
190
+ */
191
+ export declare function assertQueryCapabilities(filter: RecordFilter | undefined, capabilities: Pick<StackFeatures, 'fullTextSearch' | 'contentFieldQuery'>): void;
192
+ /**
193
+ * Thrown when an attachment upload exceeds the adapter's declared
194
+ * `maxAttachmentBytes` ceiling — checked client-side before any bytes are
195
+ * sent; a server still enforces 413 authoritatively regardless. See
196
+ * docs/spec/wire-format.md § Attachments.
197
+ */
198
+ export declare class StackPayloadTooLargeError extends StackError {
199
+ static readonly code: "payload_too_large";
200
+ readonly code: "payload_too_large";
201
+ constructor(message: string);
202
+ }
203
+ /**
204
+ * Thrown when a server abandons an operation for taking too long — in
205
+ * practice a full-text search, the one query whose cost the sanitizers
206
+ * bound the *grammar* of but not the execution of (see
207
+ * docs/spec/data-model.md § Capability-gated filters).
208
+ *
209
+ * Never produced in-process: both SQLite engines run synchronously, so
210
+ * there is nothing to interrupt from inside the call. It exists so a
211
+ * server that bounds query time has a class to serialize, and so the app
212
+ * catching it can tell "too expensive, narrow it and retry" from
213
+ * StackQueryError's "malformed, don't bother retrying" — the distinction
214
+ * that would be lost if a timeout reused `bad_request`.
215
+ */
216
+ export declare class StackTimeoutError extends StackError {
217
+ static readonly code: "timeout";
218
+ readonly code: "timeout";
53
219
  constructor(message: string);
54
220
  }
221
+ /**
222
+ * Thrown by defineType() when redefining an existing typeId with a schema
223
+ * change beyond additive evolution. The remedy is a new version, never an
224
+ * in-place redefinition. See docs/spec/data-model.md § Schema drift
225
+ * detection.
226
+ */
227
+ export declare class StackSchemaDriftError extends StackError {
228
+ readonly typeId: TypeId;
229
+ readonly violations: SchemaDriftViolation[];
230
+ static readonly code: "schema_drift";
231
+ readonly code: "schema_drift";
232
+ constructor(typeId: TypeId, violations: SchemaDriftViolation[]);
233
+ }
234
+ /**
235
+ * Thrown when a Stack or ScopedStack is used after close(). Deliberately
236
+ * outside the StackError taxonomy, alongside IdGenerationError and
237
+ * InvalidDidError: a caller holding a closed client is a local programming
238
+ * error with no wire representation — no server ever responds with it.
239
+ * See docs/spec/adapters.md § Lifecycle.
240
+ */
241
+ export declare class StackClosedError extends Error {
242
+ constructor(message?: string);
243
+ }
55
244
  /**
56
245
  * The app-facing record API, implemented by both Stack and ScopedStack.
57
246
  * Accept this type in plugin or extension code to remain adapter-agnostic
@@ -64,48 +253,106 @@ export interface StackClient {
64
253
  }>;
65
254
  get(id: string, opts?: GetRecordOptions): Promise<StackRecord | null>;
66
255
  query(query?: StackQuery): Promise<QueryResult>;
67
- update(id: string, content: Record<string, unknown | null>): Promise<StackRecord>;
68
- associate(id: string, association: Association): Promise<void>;
69
- dissociate(id: string, association: Association): Promise<void>;
70
- setPermissions(id: string, permissions: Permission[]): Promise<void>;
256
+ update(id: string, content: Record<string, unknown | null>, opts?: IfVersionOptions): Promise<StackRecord>;
257
+ associate(id: string, association: Association, opts?: IfVersionOptions): Promise<void>;
258
+ dissociate(id: string, association: Association, opts?: IfVersionOptions): Promise<void>;
259
+ setPermissions(id: string, permissions: Permission[], opts?: IfVersionOptions): Promise<void>;
71
260
  delete(id: string, opts?: DeleteRecordOptions): Promise<void>;
261
+ undelete(id: string, opts?: IfVersionOptions): Promise<StackRecord>;
72
262
  getVersions(id: string): Promise<RecordVersion[]>;
73
263
  getVersion(id: string, version: number): Promise<RecordVersion | null>;
74
- restoreVersion(id: string, version: number): Promise<StackRecord>;
264
+ restoreVersion(id: string, version: number, opts?: IfVersionOptions): Promise<StackRecord>;
265
+ /**
266
+ * Commit a per-record migration: change `typeId` and `content` together,
267
+ * validated against `toTypeId`'s schema. The only way a record's typeId
268
+ * changes after creation — see docs/spec/wire-format.md § Migration
269
+ * commit. Takes `ifVersion` like every other mutation that bumps a
270
+ * record's version (see docs/spec/versioning.md § Optimistic
271
+ * concurrency); over the wire that is `If-Match`.
272
+ */
273
+ commitMigration(id: string, toTypeId: TypeId, content: Record<string, unknown>, opts?: IfVersionOptions): Promise<StackRecord>;
75
274
  getAttachment(fileId: string): Promise<Uint8Array>;
76
- putAttachment(data: Uint8Array, mimeType: string, filename?: string): Promise<string>;
275
+ putAttachment(data: Uint8Array, mimeType: string, filename?: string, appId?: AppId): Promise<StackRecord & {
276
+ content: AttachmentContent;
277
+ }>;
77
278
  deleteAttachment(fileId: string): Promise<void>;
279
+ collectAttachmentGarbage(opts?: CollectAttachmentGarbageOptions): Promise<CollectAttachmentGarbageResult>;
78
280
  }
79
281
  export declare class Stack implements StackClient {
80
282
  private readonly adapter;
283
+ private readonly idTimestampSkewMsValue;
81
284
  private readonly migrations;
285
+ /**
286
+ * Highest version this instance has defineType()'d, per baseId — what
287
+ * this app process understands, as distinct from what exists in shared
288
+ * storage. Used to detect the stale-writer case in presentAtLatest().
289
+ */
290
+ private readonly maxDefinedVersion;
291
+ /**
292
+ * Types this instance has fetched or defined, keyed by versioned id. A
293
+ * Type's schema is immutable once defined, so entries are never
294
+ * invalidated, only added; listTypes() refreshes wholesale. See
295
+ * docs/spec/data-model.md § Type cache.
296
+ */
297
+ private readonly typeCache;
298
+ /** Set by close(). See docs/spec/adapters.md § Lifecycle. */
299
+ private closed;
82
300
  private constructor();
301
+ private getTypeCached;
83
302
  /**
84
303
  * Create a Stack instance. Reads ownerEntityId and timezone from the adapter.
85
304
  */
86
- static create(adapter: StackAdapter): Promise<Stack>;
87
- get ownerEntityId(): string;
88
- get timezone(): string;
305
+ static create(adapter: StackAdapter, opts?: StackOptions): Promise<Stack>;
306
+ /**
307
+ * Idempotent bootstrap for StackOptions.ownerProfile. Filters by family
308
+ * only (contentFieldQuery is capability-gated) and matches `content.did`
309
+ * in memory, cursor-walking so an owner card past page one isn't missed
310
+ * and duplicated. The probe must be blind to nothing the binding rules
311
+ * see, or it mints a card they then refuse and the stack won't open with
312
+ * `ownerProfile`: soft-deleted cards still reserve their `did`, and a card
313
+ * migrated to a later version still holds one.
314
+ * See docs/spec/identity.md § DID bindings.
315
+ */
316
+ private ensureOwnerEntity;
317
+ get ownerEntityId(): EntityId;
318
+ get timezone(): string | undefined;
89
319
  get features(): StackFeatures;
90
320
  /**
91
- * Get a permission-scoped view of this Stack, as if the request came from
92
- * the given entity. Pass null for an anonymous/unauthenticated requester.
93
- * Reads and writes are checked against each Record's `permissions`; the
94
- * owner always has full access.
321
+ * Get a permission-scoped view of this Stack, as if requests came from
322
+ * the given principal (null = anonymous). Plain Stack methods are
323
+ * unscoped; use asEntity() when one Stack serves multiple, possibly
324
+ * untrusted, entities.
95
325
  *
96
- * Plain Stack methods are unscoped and skip permission checks entirely —
97
- * correct for single-entity embedded use, where there's no requester
98
- * distinct from the app itself. Use asEntity() when one Stack instance
99
- * serves requests from multiple, possibly untrusted, entities (e.g. a
100
- * multi-tenant API server).
326
+ * `onBehalfOf` names the subject a delegated app acts for: authority is
327
+ * then the intersection of both parties' grants, while authorship and
328
+ * `-own` resolve against the subject. See
329
+ * docs/spec/access-control.md § Enforcement: Stack.asEntity().
101
330
  */
102
- asEntity(entityId: string | null): ScopedStack;
331
+ asEntity(entityId: EntityId | null, opts?: ScopedStackOptions): ScopedStack;
103
332
  /**
104
- * Define and persist a new Type. Computes the schemaHash automatically.
105
- * Should be called at app startup before creating any records of this type.
333
+ * Scope to an authenticated session — what StackTokenStore.lookupToken()
334
+ * returns. Equivalent to asEntity(principalId, { onBehalfOf: subjectId }),
335
+ * and what a server should reach for at its request boundary.
336
+ *
337
+ * Both identities are DIDs, so passing them positionally leaves nothing
338
+ * to catch a swap, and a swapped pair is undetectable in the undelegated
339
+ * case where they are equal — it would surface only once delegation is in
340
+ * use, as authority no longer fenced by the app's grants and every write
341
+ * attributed to the app rather than the person. Taking the pair whole
342
+ * removes the order to get wrong. See
343
+ * docs/spec/access-control.md § Delegation: principal and subject.
344
+ */
345
+ forSession(session: TokenSession): ScopedStack;
346
+ /**
347
+ * Define and persist a Type; call at app startup before creating records
348
+ * of the type. Redefining an existing typeId is checked against the
349
+ * stored schema: identical is a no-op, a name-only change persists, and
350
+ * anything beyond additive evolution throws StackSchemaDriftError. See
351
+ * docs/spec/data-model.md § Schema drift detection.
106
352
  */
107
353
  defineType(id: TypeId, name: string, schema: TypeSchema, opts?: DefineTypeOptions): Promise<StackType>;
108
354
  getType(id: TypeId): Promise<StackType | null>;
355
+ /** Refreshes typeCache wholesale — the explicit way to see a rename made by another writer. */
109
356
  listTypes(): Promise<StackType[]>;
110
357
  /**
111
358
  * Check whether a record's type is compatible with a required schema.
@@ -113,12 +360,10 @@ export declare class Stack implements StackClient {
113
360
  */
114
361
  typeIsCompatible(typeId: TypeId, requiredSchema: TypeSchema): Promise<boolean>;
115
362
  /**
116
- * Register a migration function between two adjacent Type versions.
117
- * Call at app startup after defineType(). The library composes
118
- * adjacent migrations into chains automatically.
119
- *
120
- * Migrations run in-memory — they do not write to the adapter unless
121
- * you call migrateAll() explicitly.
363
+ * Register a migration function between two adjacent Type versions; call
364
+ * at app startup after defineType(). Runs in-memory — nothing is written
365
+ * until migrateAll(). Adjacent migrations are composed into chains
366
+ * automatically.
122
367
  */
123
368
  registerMigration(migration: Migration): void;
124
369
  /**
@@ -132,99 +377,245 @@ export declare class Stack implements StackClient {
132
377
  */
133
378
  private latestTypeId;
134
379
  /**
135
- * Eagerly migrate all records of a type family to the latest version,
136
- * committing the results to disk immediately.
137
- *
138
- * Normally migration is lazy — records are migrated in memory on read
139
- * and written back only when next updated. Call migrateAll() when you
140
- * want to commit all pending migrations in one deliberate pass, for
141
- * example before a deployment or after a major schema change.
142
- *
143
- * Previous content is preserved in version history before each write.
380
+ * Eagerly migrate all records of a type family to the latest version —
381
+ * the only way disk state changes version. Sweeps soft-deleted records
382
+ * too, validates each result before writing, and aborts on the first
383
+ * validation failure. See docs/spec/data-model.md § Type migrations.
144
384
  */
145
385
  migrateAll(baseTypeId: string): Promise<{
146
386
  migrated: number;
147
387
  }>;
148
388
  /**
149
389
  * Create a new record. Validates content against the type's schema.
390
+ * `_group` records get their author stamped as the first `admin` roster
391
+ * association here — the single stamping site for both Stack.create()
392
+ * and ScopedStack.create(). See docs/spec/identity.md § Group.
150
393
  */
151
394
  create<T extends Record<string, unknown> = Record<string, unknown>>(typeId: TypeId, content: T, opts?: CreateRecordOptions): Promise<StackRecord & {
152
395
  content: T;
153
396
  }>;
154
397
  /**
155
- * Get a record by ID.
156
- *
157
- * If the record is on an older type version and a migration path is
158
- * registered, the content is migrated in memory and returned at the
159
- * latest type version — but nothing is written to disk. Migration is
160
- * only persisted when the record is next updated via update().
161
- *
162
- * Pass { migrate: false } to get the raw stored record without migration.
398
+ * Apply the registered migration chain in memory, for presentAt:
399
+ * 'latest'. Never writes back. Throws StackMigrationError when the
400
+ * record's version can't be reconciled with what this instance has
401
+ * registered — the stale-writer case. See docs/spec/data-model.md
402
+ * § Type migrations.
403
+ */
404
+ private presentAtLatest;
405
+ /**
406
+ * Get a record by ID, exactly as stored — no implicit migration. Pass
407
+ * { presentAt: 'latest' } to migrate in memory; only migrateAll()
408
+ * commits migrations to disk.
163
409
  */
164
410
  get(id: string, opts?: GetRecordOptions): Promise<StackRecord | null>;
165
411
  /**
166
- * Update a record's content. Accepts a partial content object — only the
167
- * fields provided are changed. Omitted fields retain their current values.
168
- *
169
- * To remove an optional field, set it explicitly to null:
170
- * stack.update(id, { title: null }) // removes 'title' from content
171
- *
172
- * If the record is on an older type version, its content is migrated
173
- * in memory first, then the patch is applied. The updated record is
174
- * written at the latest type version — this is when lazy migration
175
- * is committed to disk.
176
- *
177
- * Validates the merged result against the type's schema. Saves the
178
- * previous state to version history before updating.
179
- *
180
- * For association or permission changes, use associate(), dissociate(),
181
- * and setPermissions() instead.
412
+ * Update a record's content via JSON Merge Patch: omitted fields are
413
+ * kept, null removes a field. Validates the merged result against the
414
+ * record's *current* stored type and never changes typeId (see
415
+ * docs/spec/data-model.md § Type migrations); snapshots the prior state
416
+ * to version history. Associations and permissions have their own methods.
182
417
  */
183
- update(id: string, content: Record<string, unknown | null>): Promise<StackRecord>;
418
+ update(id: string, content: Record<string, unknown | null>, opts?: IfVersionOptions): Promise<StackRecord>;
184
419
  /**
185
- * Add an association to a record.
420
+ * Add an association to a record. Snapshots the record's prior state and
421
+ * bumps version, same as update() — associations are covered by the same
422
+ * versioning rule as content.
186
423
  * If the association already exists (same kind, label, and payload), this is a no-op.
187
424
  */
188
- associate(id: string, association: Association): Promise<void>;
425
+ associate(id: string, association: Association, opts?: IfVersionOptions): Promise<void>;
189
426
  /**
190
- * Remove an association from a record.
191
- * Matched by kind, label, and payload. No-op if not found.
427
+ * Remove an association from a record. Snapshots and bumps version, same
428
+ * as associate(). Matched by kind, label, and payload. No-op if not found.
192
429
  */
193
- dissociate(id: string, association: Association): Promise<void>;
430
+ dissociate(id: string, association: Association, opts?: IfVersionOptions): Promise<void>;
194
431
  /**
195
- * Replace all permissions on a record.
196
- * Pass an empty array to make the record private (the default).
432
+ * Replace all permissions on a record. Snapshots and bumps version, same
433
+ * as associate(). Pass an empty array to make the record private (the
434
+ * default). No-op if the new set is deep-equal to the current one.
197
435
  */
198
- setPermissions(id: string, permissions: Permission[]): Promise<void>;
436
+ setPermissions(id: string, permissions: Permission[], opts?: IfVersionOptions): Promise<void>;
199
437
  /**
200
- * Soft-delete a record (default) or hard-delete it permanently.
201
- * Soft-deleted records are excluded from queries unless includeDeleted is set.
202
- * Hard-deleted records and all their version history are permanently removed.
438
+ * Soft-delete a record (default) or hard-delete it permanently, removing
439
+ * the record and its version history. Soft delete snapshots and bumps
440
+ * version; a no-op if already deleted. `_config` is never deletable
441
+ * (docs/spec.md § The `_config` record). See docs/spec/versioning.md
442
+ * § Deletion.
203
443
  */
204
444
  delete(id: string, opts?: DeleteRecordOptions): Promise<void>;
205
445
  /**
206
- * Query records. See StackQuery for filter, sort, and pagination options.
446
+ * Reverse a soft delete. Idempotent — undeleting a record that isn't
447
+ * deleted returns it unchanged. Hard-deleted records are gone, so this
448
+ * throws StackNotFoundError for them just like any other missing record.
449
+ * Snapshots and bumps version, same as delete().
450
+ */
451
+ undelete(id: string, opts?: IfVersionOptions): Promise<StackRecord>;
452
+ /**
453
+ * Query records. filter.baseId matches every version of a type family,
454
+ * resolved against registered Types. Results come back exactly as
455
+ * stored; pass presentAt: 'latest' to migrate in memory. See
456
+ * docs/spec/data-model.md § Queries.
207
457
  */
208
458
  query(query?: StackQuery): Promise<QueryResult>;
459
+ /**
460
+ * Resolve filter.baseId into a concrete typeId set (intersected with
461
+ * filter.typeId when both are given), so adapters never need their own
462
+ * baseId concept. Returns EMPTY_FAMILY when the resolved set is empty so
463
+ * the caller can short-circuit without an adapter round trip.
464
+ */
465
+ private resolveBaseIdFilter;
209
466
  getVersions(id: string): Promise<RecordVersion[]>;
210
467
  getVersion(id: string, version: number): Promise<RecordVersion | null>;
211
468
  /**
212
- * Restore a record to a previous version by creating a new version
213
- * with the old content. Never rewrites history.
469
+ * Restore a record to a previous version by creating a new version —
470
+ * never rewrites history. The snapshot is validated against its own
471
+ * stored typeId (not the record's current type), restores associations,
472
+ * and never restores permissions. See docs/spec/versioning.md § Restore
473
+ * semantics.
214
474
  */
215
- restoreVersion(id: string, version: number): Promise<StackRecord>;
475
+ restoreVersion(id: string, version: number, opts?: IfVersionOptions): Promise<StackRecord>;
216
476
  /**
217
- * Store raw bytes and return the content-addressed file ID.
218
- * Does not create an _attachment@1 record — use putAttachment() or
219
- * ScopedStack.putAttachment() for the full upload flow.
477
+ * Commit a per-record migration: replace `content` and `typeId` together
478
+ * in one step, validated against `toTypeId`'s schema exactly as
479
+ * create()/update() validate against a type's schema. The single-record
480
+ * counterpart to migrateAll() — content here is supplied by the caller
481
+ * (computed client-side by the type's owning app, per
482
+ * docs/spec/wire-format.md § Migration commit) rather than a registered
483
+ * Migration function. Snapshots the prior state to version history, same
484
+ * as update()/restoreVersion(), and takes the same optional `ifVersion`
485
+ * precondition every version-bumping mutation takes — checked atomically
486
+ * at the adapter, not here (see docs/spec/versioning.md § Optimistic
487
+ * concurrency).
488
+ *
489
+ * Because `content` is a full replacement written under a new `typeId`,
490
+ * this is create-shaped at the destination *and* update-shaped over the
491
+ * record as it stands, so it owes both sets of integrity checks — the
492
+ * binding rules, the attachment rules, and `_config`'s. Missing either
493
+ * half would make migrate a second, unguarded write path to the same
494
+ * state create()/update() refuse to reach.
220
495
  */
221
- putAttachmentBytes(data: Uint8Array): Promise<string>;
496
+ commitMigration(id: string, toTypeId: TypeId, content: Record<string, unknown>, opts?: IfVersionOptions): Promise<StackRecord>;
222
497
  /**
223
- * Store bytes and create an _attachment@1 metadata record (owner-attributed,
224
- * no entityId). Use ScopedStack.putAttachment() when the uploader is a
225
- * specific entity rather than the stack owner.
498
+ * The checked migration write, shared by commitMigration() and
499
+ * migrateAll(). Takes the record already in hand rather than an id: a
500
+ * batch pass holds each record from its own query page, and re-fetching
501
+ * per record would cost a read apiece for nothing.
502
+ *
503
+ * Both callers owe the same checks. migrateAll()'s content comes from a
504
+ * registered Migration function rather than a request body, but "app
505
+ * code" is not a trust boundary here — the app calling commitMigration()
506
+ * is the same app that registered the function, and neither may move a
507
+ * DID binding or repoint an attachment. registerMigration() also places
508
+ * no constraint on `from` and `to` sharing a baseId, so a migration path
509
+ * can cross type families; family-crossing is exactly what the checks
510
+ * below care about.
511
+ */
512
+ private commitMigrationChecked;
513
+ /** Uniqueness for every unique binding field a newly created card claims. */
514
+ private checkBindingsOnCreate;
515
+ /**
516
+ * Immutability for every binding field a patch touches, then uniqueness
517
+ * for the subset that carries it. Fields absent from the patch carry no
518
+ * new claim — update() is a merge, so an untouched binding is the one the
519
+ * card already holds.
520
+ */
521
+ private checkBindingsOnUpdate;
522
+ /**
523
+ * Bindings across a migration. `content` is a full replacement rather
524
+ * than a patch, so there is no "field absent from the patch" case to
525
+ * exempt: every binding field either keeps its value, moves to a new
526
+ * one, or is shed by omission — and immutability refuses the last two
527
+ * whichever family they happen in. Asked across the union of the two
528
+ * families' binding fields, so a card can neither shed its DID by
529
+ * migrating out of `_entity`/`_app` nor pick one up on the way in.
530
+ *
531
+ * Uniqueness is asked only of the destination family, which is where the
532
+ * record's claim lives once the write lands, and excludes the record
533
+ * itself — re-sending the value it already holds claims nothing.
534
+ * See docs/spec/identity.md § DID bindings.
226
535
  */
227
- putAttachment(data: Uint8Array, mimeType: string, filename?: string): Promise<string>;
536
+ private checkBindingsOnMigrate;
537
+ /**
538
+ * A unique binding field is what a lookup resolves *by* — a record's
539
+ * `principalId` by `_app.did`, its `entityId` by `_entity.did`. Two cards
540
+ * claiming one value would leave that lookup without a single answer, and
541
+ * ambiguity is all an impersonating card needs. Enforced here rather than
542
+ * by schema, since uniqueness is a property of the set, not of the value.
543
+ * Called by the paths that can introduce a binding: create and update.
544
+ *
545
+ * Short-circuits on the first clash rather than materialising the family,
546
+ * so a stack whose `_entity` family is larger than a single scan settles
547
+ * the common case — the value is already taken — without walking the rest.
548
+ *
549
+ * Read-then-write, so two creates racing on one value can both pass.
550
+ * Closing that properly means a unique index over a JSON field, which
551
+ * each adapter would enforce separately — a decision about where
552
+ * uniqueness lives, not a local fix.
553
+ * See docs/spec/identity.md § DID bindings.
554
+ */
555
+ private checkBindingUnique;
556
+ /**
557
+ * A binding is permanent once made: uniqueness stops a second card
558
+ * claiming a value, but only immutability stops an existing card being
559
+ * moved onto one, which reaches the same impersonation by another route.
560
+ * Adopting a value is therefore a one-way step, and a subject whose key
561
+ * changes gets a new card — matching identity.md's deferral of key
562
+ * rotation, where a new key is a new identity rather than the same one
563
+ * relabelled. See docs/spec/identity.md § DID bindings.
564
+ */
565
+ private checkBindingImmutable;
566
+ /**
567
+ * mimeType is a property of the fileId, not the uploader's perspective:
568
+ * the first metadata record for a fileId fixes it, and a conflicting
569
+ * later upload is rejected. Cursor-walked so earlier records past page
570
+ * one are seen. See docs/spec/attachments.md § The `_attachment` record
571
+ * type.
572
+ *
573
+ * Best-effort by construction — check-then-create with no storage-level
574
+ * uniqueness behind it, so two racing first uploads can both land on a
575
+ * concurrent server. What survives that race is the *resolution*: which
576
+ * record establishes the type is firstRecordedAttachment()'s total
577
+ * order, the same one a server serving Content-Type applies, so both
578
+ * sides name the same winner however many records exist.
579
+ */
580
+ private checkAttachmentMimeTypeOnCreate;
581
+ /**
582
+ * filename is the only mutable field on an _attachment@1 record; fileId,
583
+ * size, and mimeType are immutable (even a same-value mimeType rewrite
584
+ * is refused). The correction flow is delete + re-upload. See
585
+ * docs/spec/attachments.md § The `_attachment` record type.
586
+ */
587
+ private checkAttachmentImmutableFields;
588
+ /**
589
+ * The same immutability checkAttachmentImmutableFields() enforces, asked
590
+ * value-wise instead of presence-wise: a migration replaces content
591
+ * wholesale, so it necessarily re-sends `mimeType`, `fileId` and `size`
592
+ * (all required) and a presence check would refuse every migration. Only
593
+ * an actual change is a violation.
594
+ *
595
+ * Repointing `fileId` is the one that matters most: an `_attachment@1`
596
+ * record naming a fileId is what canAccessFile()'s uploader clause reads,
597
+ * so moving an existing record onto another file's hash is a route to
598
+ * bytes the record's author never uploaded.
599
+ */
600
+ private checkAttachmentImmutableOnMigrate;
601
+ /**
602
+ * `_config.entityId` defines stack ownership; neither update() nor
603
+ * restoreVersion() may change it. A conflict with stack integrity, not a
604
+ * schema violation — hence StackConflictError. See docs/spec.md § The
605
+ * `_config` record.
606
+ */
607
+ private checkConfigEntityIdUnchanged;
608
+ /**
609
+ * Store bytes and create an _attachment@1 metadata record (owner-
610
+ * attributed, no entityId), returning that record — `content.fileId`
611
+ * addresses the bytes, `id` addresses the metadata. Delegates to the
612
+ * adapter's atomic putAttachmentWithMetadata() when implemented, trusting
613
+ * the returned record as backend-authoritative; otherwise falls back to
614
+ * bytes-then-create(). See docs/spec/wire-format.md § Attachments.
615
+ */
616
+ putAttachment(data: Uint8Array, mimeType: string, filename?: string, appId?: AppId): Promise<StackRecord & {
617
+ content: AttachmentContent;
618
+ }>;
228
619
  getAttachment(fileId: string): Promise<Uint8Array>;
229
620
  /**
230
621
  * Delete an attachment's bytes and its _attachment@1 metadata record(s).
@@ -233,107 +624,409 @@ export declare class Stack implements StackClient {
233
624
  */
234
625
  deleteAttachment(fileId: string): Promise<void>;
235
626
  /**
236
- * Flush any pending writes to the underlying storage.
237
- * For adapters that write immediately (SQLite, JSON), this is a no-op.
238
- * For the API adapter, this commits the offline write queue to the server.
239
- * Safe to call at any time — always resolves, never rejects on its own.
627
+ * Non-atomic fallback for adapters that don't implement
628
+ * deleteUnreferencedAttachmentRecords(): a concurrent associate() can
629
+ * race between the reference check below and the deletes it guards.
630
+ */
631
+ private deleteUnreferencedAttachmentRecordsFallback;
632
+ /**
633
+ * Sweep for attachment bytes unreachable from any record — live or
634
+ * soft-deleted — and delete bytes + metadata. Deletion goes through
635
+ * deleteAttachment(), so a file re-referenced by sweep time is skipped,
636
+ * not a failure. See docs/spec/attachments.md § Garbage collection.
637
+ */
638
+ collectAttachmentGarbage(opts?: CollectAttachmentGarbageOptions): Promise<CollectAttachmentGarbageResult>;
639
+ /**
640
+ * Flush pending writes to the underlying storage. A no-op for adapters
641
+ * that commit on every call (SQLite, the API adapter); meaningful for
642
+ * ones that buffer, and for checkpointing a stack that stays open —
643
+ * close() covers the teardown case on its own.
240
644
  */
241
645
  flush(): Promise<void>;
242
646
  /**
243
- * Release any resources held by the adapter (connections, file handles, timers).
244
- * Call this when the stack is no longer needed — especially important for the
245
- * API adapter, which holds an open connection and retry timers.
246
- * Safe to call even if the adapter has no resources to release.
647
+ * Flush, then release any resources the adapter holds (connections, file
648
+ * handles, lock files). A failed flush still releases them before it
649
+ * propagates: an unwritable stack must not also leak a lock file.
650
+ * See docs/spec/adapters.md § Lifecycle.
247
651
  */
248
652
  close(): Promise<void>;
249
653
  /**
250
- * Create _grant records that allow entities to call ScopedStack.create()
251
- * for specific record types. Pass null as entityId for a default grant
252
- * that applies to any authenticated entity.
654
+ * Throws once close() has run. Public only so ScopedStack can gate the
655
+ * one path it takes to the adapter directly; not an app-facing API.
656
+ */
657
+ assertOpen(): void;
658
+ /**
659
+ * Create _grant records authorizing entities to act on records of
660
+ * specific types; null entityId writes a default grant (any
661
+ * authenticated entity).
662
+ *
663
+ * Granting an **app** a `-own` action does not contain it the way the
664
+ * suffix suggests: when that app acts for someone, `-own` is read as the
665
+ * bare verb and the subject decides which records are in reach, so in a
666
+ * personal stack — where nearly every record is owner-authored — a
667
+ * delegated `read-own` is close to `read-any`. Grant an app the types it
668
+ * needs, not the suffix that looks narrowest. See
669
+ * docs/spec/access-control.md § Delegation: principal and subject.
253
670
  *
254
- * The _grant@1 type is defined automatically on first use.
671
+ * The grantee lives in content.granteeEntityId,
672
+ * not record.entityId. See docs/spec/access-control.md § Type-level
673
+ * grants.
255
674
  */
256
- grant(entityId: string | null, grants: Array<{
675
+ grant(entityId: EntityId | null, grants: Array<{
257
676
  actions: GrantAction[];
258
677
  typeId: TypeId;
259
678
  }>): Promise<StackRecord[]>;
679
+ /**
680
+ * List _grant records. Omit `entityId` for all grants; pass null for
681
+ * only default grants; pass a specific entityId for the grants that
682
+ * currently apply to that entity (ones naming them, plus every default
683
+ * grant) — the same resolution hasGrant() uses.
684
+ */
685
+ listGrants(entityId?: EntityId | null): Promise<StackRecord[]>;
686
+ /**
687
+ * The inverse of grant(): soft-deletes _grant records matching `entityId`
688
+ * (null for default grants) and each `{ typeId, actions }` pair, at the
689
+ * same granularity grant() writes. A soft delete like any other — the
690
+ * owner can undelete a revocation.
691
+ */
692
+ revoke(entityId: EntityId | null, grants: Array<{
693
+ actions: GrantAction[];
694
+ typeId: TypeId;
695
+ }>): Promise<void>;
696
+ /**
697
+ * Validates the whole grant batch before any record is created, so a bad
698
+ * entry fails clean rather than leaving a partial set written. Actions
699
+ * must be known GrantAction values, typeIds must be well-formed, and the
700
+ * _grant/_config families are refused — see docs/spec/access-control.md
701
+ * § Type-level grants.
702
+ */
703
+ private checkGrantsValid;
260
704
  private seedSystemTypes;
261
- private saveVersion;
705
+ /**
706
+ * Fast-fail for the ifVersion precondition using the already-fetched
707
+ * record. The adapter re-checks atomically at write time (the source of
708
+ * truth for concurrent writers) — this just skips validation and
709
+ * snapshotting work when the mismatch is already visible.
710
+ */
711
+ private checkIfVersion;
712
+ /**
713
+ * Snapshot of a record's prior state, passed with the mutating adapter
714
+ * call so snapshot and mutation land in one atomic write. `associations`
715
+ * is always present ([] when empty) so restore can distinguish "cleared"
716
+ * from a snapshot that omits the key entirely ("leave as-is"). See
717
+ * docs/spec/versioning.md § Version history.
718
+ */
719
+ private buildVersionSnapshot;
262
720
  }
263
721
  /**
264
- * A permission-enforcing view of a Stack for a single requester. Obtained
265
- * via `stack.asEntity(entityId)` — see there for when to use it.
722
+ * A permission-enforcing view of a Stack for a single (principal, subject)
723
+ * pair, obtained
724
+ * via `stack.asEntity(entityId)`. Missing records throw StackNotFoundError
725
+ * (or return null on reads); existing-but-inaccessible ones throw
726
+ * StackPermissionError. See docs/spec/access-control.md.
727
+ *
728
+ * Two identities, one rule: **the principal governs authority, the subject
729
+ * governs attribution.** Grant lookup and the privilege-bearing gates that
730
+ * no grant reaches (setPermissions, group management, hard delete, widening
731
+ * access at create time) key on `principalEntityId`; authorship, `-own`
732
+ * matching, record-level permission resolution, and "files I uploaded"
733
+ * lookups key on `subjectEntityId`.
266
734
  *
267
- * Read methods return null for a Record that doesn't exist, and throw
268
- * StackPermissionError for one that exists but isn't readable by the requester.
269
- * Write methods throw StackNotFoundError for a missing Record and
270
- * StackPermissionError for one that exists but isn't writable. This lets
271
- * callers distinguish "not found" from "forbidden".
735
+ * Unconditional owner access follows that same split rather than one
736
+ * identity: it answers *what data is reachable* for the subject (an owner
737
+ * subject resolves past every permission check) and *who may exercise a
738
+ * privileged verb* for the principal (an owner app is not bounded by
739
+ * grants). Under delegation both halves apply, and a mistake on the
740
+ * authority side is an escalation rather than a preference.
272
741
  */
273
742
  export declare class ScopedStack implements StackClient {
274
743
  private readonly stack;
275
- private readonly requesterEntityId;
276
- constructor(stack: Stack, requesterEntityId: string | null);
744
+ private readonly principalEntityId;
745
+ private readonly subjectEntityId;
746
+ private readonly idTimestampSkewMs;
747
+ private readonly adapter;
748
+ constructor(stack: Stack, principalEntityId: EntityId | null, subjectEntityId: EntityId | null, idTimestampSkewMs: number | null, adapter: StackAdapter);
277
749
  get features(): StackFeatures;
278
750
  private resolveRecord;
751
+ /** Whether a delegated app is acting for someone other than itself. */
752
+ private get delegated();
753
+ /**
754
+ * Whether unconditional owner authority applies — the owner acting as
755
+ * itself. The verbs that rest on it are irreversible or disclose the
756
+ * sharing graph, so delegation never carries one to a subject, whichever
757
+ * side the owner is on. See
758
+ * docs/spec/access-control.md § Delegation: principal and subject.
759
+ */
760
+ private get ownerActingAlone();
279
761
  private checkRead;
280
762
  private checkWrite;
281
763
  /**
282
- * Check whether the requester holds a _grant record covering at least one
283
- * of the given actions for the given type. For -own actions, additionally
284
- * requires that `record.entityId` matches the requester (authorship check).
285
- * Anonymous requesters always return false.
764
+ * Whether `grantee` holds a _grant covering one of `actions` for the
765
+ * type's family (grants match by baseId, so a version bump never orphans
766
+ * one). -own actions additionally require record.entityId === grantee,
767
+ * unless `matchOwn` is false — on the principal side of a delegated
768
+ * request the suffix is read as the bare verb, since which records are
769
+ * reachable is the subject's business. `allowDefault` decides whether a
770
+ * grant naming nobody counts. Anonymous grantees always return false.
771
+ *
772
+ * Reached only through subjectAllows()/principalAllows(), which fix those
773
+ * two flags per side of the intersection. Call one of those instead.
774
+ * See docs/spec/access-control.md § Type-level grants.
286
775
  */
287
776
  private hasGrant;
777
+ /**
778
+ * The principal half of a delegated request's authority: does the app
779
+ * hold any grant permitting these verbs on this type at all. Bounds what
780
+ * the subject's own authority can reach through it, so a powerful app
781
+ * can never lend its reach to a weaker subject — nor the reverse.
782
+ * Vacuously true when there's no delegation, where the principal and
783
+ * subject checks would be the same question asked twice.
784
+ *
785
+ * Default grants don't count here. "Any authenticated entity" is about
786
+ * people who turn up, not software the owner installed — an app reaches
787
+ * only the types named to it, which is the whole of what containment
788
+ * promises.
789
+ */
790
+ private principalAllows;
791
+ /**
792
+ * The subject half: which records are reachable, answered with `-own`
793
+ * matching and default grants both in force — the ordinary reading of a
794
+ * grant, since the subject is the entity a grant is written about.
795
+ *
796
+ * Paired with principalAllows() so that the two halves of the
797
+ * intersection are the only callers of hasGrant(): its flags differ per
798
+ * side and mean nothing on their own, so no call site sets them by hand.
799
+ */
800
+ private subjectAllows;
288
801
  private canRead;
289
802
  private checkCreateGrant;
290
- /** Fetch a record the requester can update (via permissions or an update grant), or throw. */
803
+ /**
804
+ * `_group` records are managed, not merely written: only the owner or an
805
+ * `admin` roster holder may mutate them — ordinary write permissions and
806
+ * grants don't apply. Asked of both identities under delegation, like
807
+ * setPermissions(). See docs/spec/identity.md § Group.
808
+ */
809
+ private isGroupManager;
810
+ /** Whether one identity, on its own, manages `record` — see isGroupManager(). */
811
+ private managesGroup;
812
+ /**
813
+ * Fetch a record the subject can reach and the principal holds `update` on
814
+ * (via permissions or an update grant), or throw. `mutating: false` marks
815
+ * the history readers, which borrow this gate without changing anything —
816
+ * the one way through it a `_grant` Record stays open to.
817
+ */
291
818
  private requireUpdatable;
292
- /** Fetch a record the requester can delete (via permissions or a delete grant), or throw. */
819
+ /**
820
+ * Fetch a record the subject can reach and the principal holds `delete` on
821
+ * (via permissions or a delete grant), or throw.
822
+ */
293
823
  private requireDeletable;
294
824
  /**
295
- * Create a new record on behalf of the authenticated requester.
296
- * Requires either an entity-specific _grant or a default _grant for
297
- * the target type. Anonymous requesters (null entityId) are always denied.
298
- * The created record's entityId is always set to the requester.
825
+ * Whether this request may reference `recordId` (as a parentId or
826
+ * relationship target). Missing and unreadable both return false —
827
+ * indistinguishable, so this can't probe for a record's existence.
828
+ */
829
+ private canReadReferent;
830
+ /**
831
+ * Whether this request can read some record referencing `fileId` —
832
+ * shared by canAccessFile() and the non-owner _attachment@1 create()
833
+ * carve-out, which deliberately excludes the uploader clause. Walks
834
+ * every referencing record, short-circuiting on the first readable one.
835
+ * See docs/spec/attachments.md § Creating `_attachment@1` records directly.
836
+ */
837
+ private hasReadableReference;
838
+ /**
839
+ * Whether this request may reference or download `fileId` — the dual of
840
+ * getAttachment()'s access rule. Nonexistent and inaccessible are
841
+ * indistinguishable (both false), so no confirmation oracle for guessed
842
+ * hashes. See docs/spec/access-control.md § Reference-creation gating.
843
+ */
844
+ private canAccessFile;
845
+ /** Names of the type's top-level file-ref fields — the content-reference half of attachmentFileId matching. */
846
+ private fileRefFieldNames;
847
+ /**
848
+ * Gates file-ref content fields on file access, mirroring the
849
+ * attachment-association gate — a file-ref field conveys attachment
850
+ * access exactly like an `attachment` association. Only fields present
851
+ * in `content` are checked (update() is a merge patch; untouched fields
852
+ * carry no new reference).
853
+ */
854
+ private requireFileRefAccess;
855
+ /**
856
+ * Reference-creation gate for one association: `attachment` requires
857
+ * file access, `relationship` read access to its target; `tag` is
858
+ * unchecked, and `_group` roster associations are gated by the stricter
859
+ * isGroupManager() instead. See docs/spec/access-control.md
860
+ * § Reference-creation gating.
861
+ */
862
+ private requireAssociationAccess;
863
+ /**
864
+ * Create a record on behalf of the subject: create grant required,
865
+ * anonymous denied, entityId set to the subject, client IDs skew-checked,
866
+ * reference-creating options gated, and non-owner `_attachment@1`
867
+ * creation refused save one carve-out. A scoped create always stamps
868
+ * authorship — an absent entityId means an unscoped `Stack` wrote it.
869
+ * See docs/spec/access-control.md and docs/spec/attachments.md.
299
870
  */
300
871
  create<T extends Record<string, unknown> = Record<string, unknown>>(typeId: TypeId, content: T, opts?: CreateRecordOptions): Promise<StackRecord & {
301
872
  content: T;
302
873
  }>;
874
+ /**
875
+ * Whether this request may decide who else reaches a record — the rule
876
+ * setPermissions() enforces, asked at create time too so the reach it
877
+ * withholds can't be taken one step earlier while authoring. A delegated
878
+ * app is denied it: widening access is the one thing containment most
879
+ * needs to hold. Refused rather than silently ignored, so an app never
880
+ * believes it published something it didn't. Not `ownerActingAlone`:
881
+ * the record is the subject's own, so an owner principal grants it no
882
+ * reach the subject lacks.
883
+ * See docs/spec/access-control.md § Delegation: principal and subject.
884
+ */
885
+ private mayGrantAccess;
886
+ /**
887
+ * Whether one identity, on its own, may decide who else reaches `record`
888
+ * — the owner-or-creator rule setPermissions() enforces, asked of one
889
+ * side at a time. See
890
+ * docs/spec/access-control.md § Delegation: principal and subject.
891
+ */
892
+ private mayReshare;
303
893
  get(id: string, opts?: GetRecordOptions): Promise<StackRecord | null>;
304
894
  /**
305
- * Query records, filtered to only those the requester can read.
306
- *
307
- * Pagination is filtered-then-refilled: each adapter page is fetched in
308
- * full and entirely evaluated before deciding whether to fetch another,
309
- * so the returned page may overshoot `limit` slightly, but never skips a
310
- * record that shared a page with the one that crossed the threshold (the
311
- * adapter's cursor can't address a position mid-page).
895
+ * Query records, filtered to those this request can read. Pages are
896
+ * filtered then refilled, so a page may slightly overshoot `limit` but
897
+ * never skips a record. `total` is always null (see QueryResult.total).
898
+ * Grants are prefetched once, cursor-walked to exhaustion.
899
+ */
900
+ query(query?: StackQuery): Promise<QueryResult>;
901
+ update(id: string, content: Record<string, unknown | null>, opts?: IfVersionOptions): Promise<StackRecord>;
902
+ /**
903
+ * Naming the software behind a key is the trust decision the `_app`
904
+ * registry exists to record, so both halves of that binding — `did` and
905
+ * `appId` — belong to the owner alone. Same reasoning that makes `_app`
906
+ * ungrantable, applied to the fields a lookup reads. Registering a card
907
+ * is already owner-only; without this, record-level `write` shared on a
908
+ * card would be a second way in: a card carrying no DID yet could be
909
+ * pointed at a write-holder's own key while keeping the name the owner
910
+ * gave it, or relabelled to claim another app's `appId`. `name` and
911
+ * `version` stay writable — they are display, not lookup.
312
912
  *
313
- * `total` is always null — see the QueryResult.total doc comment.
913
+ * Owner *acting alone*, in both directions: a delegated app never holds
914
+ * it, and an owner principal doesn't lend it to a subject holding
915
+ * record-level `write` on a card — which would reopen the same route
916
+ * from the other side.
314
917
  *
315
- * Grant records are pre-fetched once before the pagination loop so read
316
- * grants don't trigger a separate _grant@1 query per record.
918
+ * `_entity` deliberately does not get this rule: naming people is what a
919
+ * contacts app does, so its cards stay writable by grant. Uniqueness and
920
+ * immutability still bind them. See docs/spec/identity.md § DID bindings.
921
+ */
922
+ private requireOwnerForAppIdentity;
923
+ /**
924
+ * A self-reported `appId` must agree with the `_app` card naming the
925
+ * principal's DID, where the owner registered one. `principalId` is
926
+ * verified, so letting the pair disagree would leave a verified principal
927
+ * claiming a name the owner gave different software — the cross-check the
928
+ * registry exists for, refused at the write instead of left to each reader.
929
+ * A principal with no card keeps `appId` as the bare self-report it is for
930
+ * every undelegated writer.
931
+ * See docs/spec/identity.md § Attribution and what can be trusted.
932
+ */
933
+ private requireAppIdMatchesPrincipal;
934
+ /**
935
+ * The owner's own DID is the one `_entity` binding a grantee may not claim.
936
+ * `ownerProfile` adopts whichever card holds it, so a card minted by
937
+ * someone else becomes the stack's own profile, and uniqueness then makes
938
+ * that permanent. Every other DID stays open to a contacts app, which is
939
+ * the reach `_entity` is grantable for.
940
+ * See docs/spec/identity.md § DID bindings.
941
+ */
942
+ private requireOwnerForOwnerDid;
943
+ /**
944
+ * A `_grant` Record *is* authority, so rewriting one is the escalation
945
+ * UNGRANTABLE_SYSTEM_TYPES refuses at evaluation, reached by editing an
946
+ * existing grant rather than minting a fresh one. `grant()` and `revoke()`
947
+ * live on `Stack`, never `StackClient`, so no scoped write is lost. Writes
948
+ * only: reading a grant Record and its history stays on the ordinary gate.
949
+ * See docs/spec/access-control.md § Type-level grants.
950
+ */
951
+ private requireOwnerForGrantRecord;
952
+ associate(id: string, association: Association, opts?: IfVersionOptions): Promise<void>;
953
+ dissociate(id: string, association: Association, opts?: IfVersionOptions): Promise<void>;
954
+ setPermissions(id: string, permissions: Permission[], opts?: IfVersionOptions): Promise<void>;
955
+ /**
956
+ * Hard delete is owner-only: it is irreversible and destroys version
957
+ * history, so neither the write bit nor delete-own/delete-any grants
958
+ * reach it, and delegation doesn't carry it either. Everyone else is
959
+ * limited to soft delete.
317
960
  */
318
- query(query?: StackQuery): Promise<QueryResult>;
319
- update(id: string, content: Record<string, unknown | null>): Promise<StackRecord>;
320
- associate(id: string, association: Association): Promise<void>;
321
- dissociate(id: string, association: Association): Promise<void>;
322
- setPermissions(id: string, permissions: Permission[]): Promise<void>;
323
961
  delete(id: string, opts?: DeleteRecordOptions): Promise<void>;
962
+ /**
963
+ * Reverse a soft delete. Gated the same as delete() — undelete is the
964
+ * inverse of soft delete, so granting one direction without the other
965
+ * would be backwards. Idempotent, per Stack.undelete().
966
+ */
967
+ undelete(id: string, opts?: IfVersionOptions): Promise<StackRecord>;
968
+ /**
969
+ * History is the mutation/recovery surface, not a read surface — gated
970
+ * like update(), with snapshot `permissions` stripped for everyone but
971
+ * the owner acting alone — a snapshot's permissions are the stack's
972
+ * sharing graph, which delegation is not a route to. Reading history
973
+ * changes nothing, so it is the one path the `_grant` write fence leaves
974
+ * alone: seeing how a Record you can already read got that way is not
975
+ * the escalation that fence exists to stop, and losing it would leave a
976
+ * write-holder unable to audit the Record they hold.
977
+ * See docs/spec/versioning.md § History access.
978
+ */
324
979
  getVersions(id: string): Promise<RecordVersion[]>;
980
+ /** See getVersions() — same mutate-surface gate, same permissions stripping. */
325
981
  getVersion(id: string, version: number): Promise<RecordVersion | null>;
326
- restoreVersion(id: string, version: number): Promise<StackRecord>;
327
982
  /**
328
- * Store bytes and create an _attachment@1 metadata record owned by the
329
- * requester. Requires a `create` grant on `_attachment@1`.
330
- * Anonymous requesters are always denied.
983
+ * Re-runs the reference-creation checks against the snapshot, so a
984
+ * restore can't re-convey access to a file or record the subject can no
985
+ * longer reach today. Only the owner acting alone is exempt: under
986
+ * delegation the checks resolve against the subject, which is whose reach
987
+ * the restore would widen. See docs/spec/versioning.md § Restore semantics.
988
+ */
989
+ restoreVersion(id: string, version: number, opts?: IfVersionOptions): Promise<StackRecord>;
990
+ /**
991
+ * Commit a per-record migration — **the owner acting alone, only**.
992
+ *
993
+ * Migration is owner-driven by design: `migrateAll()`, the bulk path,
994
+ * lives on `Stack` and is deliberately absent from `StackClient`, the
995
+ * same way `grant()`/`revoke()` are. This is its per-record counterpart
996
+ * and carries the same restriction, rather than inventing a grant model
997
+ * that the bulk path deliberately doesn't have.
998
+ *
999
+ * The restriction is what makes the verb safe to expose at all. Migrate
1000
+ * replaces `content` and `typeId` wholesale, so a grant-based version
1001
+ * would have to re-derive every gate `create()` applies at the
1002
+ * destination *and* every gate `update()` applies over the existing
1003
+ * content, and would reopen each one it missed. The sharpest is the
1004
+ * non-owner `_attachment@1` refusal create() carries: without it, a
1005
+ * requester holding a create grant on `_attachment@1` and write access
1006
+ * to any record they authored could migrate that record into the family
1007
+ * naming any `fileId`, then read the bytes through canAccessFile()'s
1008
+ * uploader clause — the exact escalation that carve-out exists to refuse
1009
+ * (see docs/spec/attachments.md § Creating `_attachment@1` records
1010
+ * directly). Ordinary write access to a record is not consent to move it
1011
+ * between families.
1012
+ *
1013
+ * A server implementing `POST /records/:id/migrate` therefore serves it
1014
+ * to the stack owner and answers 403 otherwise. See
1015
+ * docs/spec/data-model.md § Type migrations.
331
1016
  */
332
- putAttachment(data: Uint8Array, mimeType: string, filename?: string): Promise<string>;
1017
+ commitMigration(id: string, toTypeId: TypeId, content: Record<string, unknown>, opts?: IfVersionOptions): Promise<StackRecord>;
333
1018
  /**
334
- * Download attachment bytes. Accessible if the requester is the owner,
335
- * can read any record referencing the file, or uploaded the file themselves
336
- * and it hasn't been associated with a record yet.
1019
+ * Store bytes and create an _attachment@1 metadata record (create grant
1020
+ * on `_attachment@1` required; anonymous denied), returning that record.
1021
+ * Authorship and principal are stamped exactly as create() does.
1022
+ */
1023
+ putAttachment(data: Uint8Array, mimeType: string, filename?: string, appId?: AppId): Promise<StackRecord & {
1024
+ content: AttachmentContent;
1025
+ }>;
1026
+ /**
1027
+ * Download attachment bytes. Accessible to the owner, a reader of any
1028
+ * referencing record, or the uploader pre-association — the same
1029
+ * predicate as the reference-creation gate (canAccessFile).
337
1030
  */
338
1031
  getAttachment(fileId: string): Promise<Uint8Array>;
339
1032
  /**
@@ -341,5 +1034,10 @@ export declare class ScopedStack implements StackClient {
341
1034
  * Delegates to Stack.deleteAttachment(), which enforces the "not referenced" check.
342
1035
  */
343
1036
  deleteAttachment(fileId: string): Promise<void>;
1037
+ /**
1038
+ * Sweep for unreferenced attachment bytes and delete them. Only the stack
1039
+ * owner may run this. Delegates to Stack.collectAttachmentGarbage().
1040
+ */
1041
+ collectAttachmentGarbage(opts?: CollectAttachmentGarbageOptions): Promise<CollectAttachmentGarbageResult>;
344
1042
  }
345
1043
  //# sourceMappingURL=stack.d.ts.map