@haverstack/core 0.8.0 → 0.9.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 +748 -152
  46. package/dist/stack.d.ts.map +1 -1
  47. package/dist/stack.js +1805 -328
  48. package/dist/stack.js.map +1 -1
  49. package/dist/testing.d.ts +84 -23
  50. package/dist/testing.d.ts.map +1 -1
  51. package/dist/testing.js +260 -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';
87
+ };
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;
28
96
  };
29
- export type DeleteRecordOptions = {
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";
53
201
  constructor(message: string);
54
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";
219
+ constructor(message: string);
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,97 @@ 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>;
75
265
  getAttachment(fileId: string): Promise<Uint8Array>;
76
- putAttachment(data: Uint8Array, mimeType: string, filename?: string): Promise<string>;
266
+ putAttachment(data: Uint8Array, mimeType: string, filename?: string, appId?: AppId): Promise<StackRecord & {
267
+ content: AttachmentContent;
268
+ }>;
77
269
  deleteAttachment(fileId: string): Promise<void>;
270
+ collectAttachmentGarbage(opts?: CollectAttachmentGarbageOptions): Promise<CollectAttachmentGarbageResult>;
78
271
  }
79
272
  export declare class Stack implements StackClient {
80
273
  private readonly adapter;
274
+ private readonly idTimestampSkewMsValue;
81
275
  private readonly migrations;
276
+ /**
277
+ * Highest version this instance has defineType()'d, per baseId — what
278
+ * this app process understands, as distinct from what exists in shared
279
+ * storage. Used to detect the stale-writer case in presentAtLatest().
280
+ */
281
+ private readonly maxDefinedVersion;
282
+ /**
283
+ * Types this instance has fetched or defined, keyed by versioned id. A
284
+ * Type's schema is immutable once defined, so entries are never
285
+ * invalidated, only added; listTypes() refreshes wholesale. See
286
+ * docs/spec/data-model.md § Type cache.
287
+ */
288
+ private readonly typeCache;
289
+ /** Set by close(). See docs/spec/adapters.md § Lifecycle. */
290
+ private closed;
82
291
  private constructor();
292
+ private getTypeCached;
83
293
  /**
84
294
  * Create a Stack instance. Reads ownerEntityId and timezone from the adapter.
85
295
  */
86
- static create(adapter: StackAdapter): Promise<Stack>;
87
- get ownerEntityId(): string;
88
- get timezone(): string;
296
+ static create(adapter: StackAdapter, opts?: StackOptions): Promise<Stack>;
297
+ /**
298
+ * Idempotent bootstrap for StackOptions.ownerProfile. Filters by family
299
+ * only (contentFieldQuery is capability-gated) and matches `content.did`
300
+ * in memory, cursor-walking so an owner card past page one isn't missed
301
+ * and duplicated. The probe must be blind to nothing the binding rules
302
+ * see, or it mints a card they then refuse and the stack won't open with
303
+ * `ownerProfile`: soft-deleted cards still reserve their `did`, and a card
304
+ * migrated to a later version still holds one.
305
+ * See docs/spec/identity.md § DID bindings.
306
+ */
307
+ private ensureOwnerEntity;
308
+ get ownerEntityId(): EntityId;
309
+ get timezone(): string | undefined;
89
310
  get features(): StackFeatures;
90
311
  /**
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.
312
+ * Get a permission-scoped view of this Stack, as if requests came from
313
+ * the given principal (null = anonymous). Plain Stack methods are
314
+ * unscoped; use asEntity() when one Stack serves multiple, possibly
315
+ * untrusted, entities.
95
316
  *
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).
317
+ * `onBehalfOf` names the subject a delegated app acts for: authority is
318
+ * then the intersection of both parties' grants, while authorship and
319
+ * `-own` resolve against the subject. See
320
+ * docs/spec/access-control.md § Enforcement: Stack.asEntity().
101
321
  */
102
- asEntity(entityId: string | null): ScopedStack;
322
+ asEntity(entityId: EntityId | null, opts?: ScopedStackOptions): ScopedStack;
103
323
  /**
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.
324
+ * Scope to an authenticated session — what StackTokenStore.lookupToken()
325
+ * returns. Equivalent to asEntity(principalId, { onBehalfOf: subjectId }),
326
+ * and what a server should reach for at its request boundary.
327
+ *
328
+ * Both identities are DIDs, so passing them positionally leaves nothing
329
+ * to catch a swap, and a swapped pair is undetectable in the undelegated
330
+ * case where they are equal — it would surface only once delegation is in
331
+ * use, as authority no longer fenced by the app's grants and every write
332
+ * attributed to the app rather than the person. Taking the pair whole
333
+ * removes the order to get wrong. See
334
+ * docs/spec/access-control.md § Delegation: principal and subject.
335
+ */
336
+ forSession(session: TokenSession): ScopedStack;
337
+ /**
338
+ * Define and persist a Type; call at app startup before creating records
339
+ * of the type. Redefining an existing typeId is checked against the
340
+ * stored schema: identical is a no-op, a name-only change persists, and
341
+ * anything beyond additive evolution throws StackSchemaDriftError. See
342
+ * docs/spec/data-model.md § Schema drift detection.
106
343
  */
107
344
  defineType(id: TypeId, name: string, schema: TypeSchema, opts?: DefineTypeOptions): Promise<StackType>;
108
345
  getType(id: TypeId): Promise<StackType | null>;
346
+ /** Refreshes typeCache wholesale — the explicit way to see a rename made by another writer. */
109
347
  listTypes(): Promise<StackType[]>;
110
348
  /**
111
349
  * Check whether a record's type is compatible with a required schema.
@@ -113,12 +351,10 @@ export declare class Stack implements StackClient {
113
351
  */
114
352
  typeIsCompatible(typeId: TypeId, requiredSchema: TypeSchema): Promise<boolean>;
115
353
  /**
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.
354
+ * Register a migration function between two adjacent Type versions; call
355
+ * at app startup after defineType(). Runs in-memory — nothing is written
356
+ * until migrateAll(). Adjacent migrations are composed into chains
357
+ * automatically.
122
358
  */
123
359
  registerMigration(migration: Migration): void;
124
360
  /**
@@ -132,99 +368,180 @@ export declare class Stack implements StackClient {
132
368
  */
133
369
  private latestTypeId;
134
370
  /**
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.
371
+ * Eagerly migrate all records of a type family to the latest version —
372
+ * the only way disk state changes version. Sweeps soft-deleted records
373
+ * too, validates each result before writing, and aborts on the first
374
+ * validation failure. See docs/spec/data-model.md § Type migrations.
144
375
  */
145
376
  migrateAll(baseTypeId: string): Promise<{
146
377
  migrated: number;
147
378
  }>;
148
379
  /**
149
380
  * Create a new record. Validates content against the type's schema.
381
+ * `_group` records get their author stamped as the first `admin` roster
382
+ * association here — the single stamping site for both Stack.create()
383
+ * and ScopedStack.create(). See docs/spec/identity.md § Group.
150
384
  */
151
385
  create<T extends Record<string, unknown> = Record<string, unknown>>(typeId: TypeId, content: T, opts?: CreateRecordOptions): Promise<StackRecord & {
152
386
  content: T;
153
387
  }>;
154
388
  /**
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.
389
+ * Apply the registered migration chain in memory, for presentAt:
390
+ * 'latest'. Never writes back. Throws StackMigrationError when the
391
+ * record's version can't be reconciled with what this instance has
392
+ * registered — the stale-writer case. See docs/spec/data-model.md
393
+ * § Type migrations.
394
+ */
395
+ private presentAtLatest;
396
+ /**
397
+ * Get a record by ID, exactly as stored — no implicit migration. Pass
398
+ * { presentAt: 'latest' } to migrate in memory; only migrateAll()
399
+ * commits migrations to disk.
163
400
  */
164
401
  get(id: string, opts?: GetRecordOptions): Promise<StackRecord | null>;
165
402
  /**
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.
403
+ * Update a record's content via JSON Merge Patch: omitted fields are
404
+ * kept, null removes a field. Validates the merged result against the
405
+ * record's *current* stored type and never changes typeId (see
406
+ * docs/spec/data-model.md § Type migrations); snapshots the prior state
407
+ * to version history. Associations and permissions have their own methods.
182
408
  */
183
- update(id: string, content: Record<string, unknown | null>): Promise<StackRecord>;
409
+ update(id: string, content: Record<string, unknown | null>, opts?: IfVersionOptions): Promise<StackRecord>;
184
410
  /**
185
- * Add an association to a record.
411
+ * Add an association to a record. Snapshots the record's prior state and
412
+ * bumps version, same as update() — associations are covered by the same
413
+ * versioning rule as content.
186
414
  * If the association already exists (same kind, label, and payload), this is a no-op.
187
415
  */
188
- associate(id: string, association: Association): Promise<void>;
416
+ associate(id: string, association: Association, opts?: IfVersionOptions): Promise<void>;
189
417
  /**
190
- * Remove an association from a record.
191
- * Matched by kind, label, and payload. No-op if not found.
418
+ * Remove an association from a record. Snapshots and bumps version, same
419
+ * as associate(). Matched by kind, label, and payload. No-op if not found.
192
420
  */
193
- dissociate(id: string, association: Association): Promise<void>;
421
+ dissociate(id: string, association: Association, opts?: IfVersionOptions): Promise<void>;
194
422
  /**
195
- * Replace all permissions on a record.
196
- * Pass an empty array to make the record private (the default).
423
+ * Replace all permissions on a record. Snapshots and bumps version, same
424
+ * as associate(). Pass an empty array to make the record private (the
425
+ * default). No-op if the new set is deep-equal to the current one.
197
426
  */
198
- setPermissions(id: string, permissions: Permission[]): Promise<void>;
427
+ setPermissions(id: string, permissions: Permission[], opts?: IfVersionOptions): Promise<void>;
199
428
  /**
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.
429
+ * Soft-delete a record (default) or hard-delete it permanently, removing
430
+ * the record and its version history. Soft delete snapshots and bumps
431
+ * version; a no-op if already deleted. `_config` is never deletable
432
+ * (docs/spec.md § The `_config` record). See docs/spec/versioning.md
433
+ * § Deletion.
203
434
  */
204
435
  delete(id: string, opts?: DeleteRecordOptions): Promise<void>;
205
436
  /**
206
- * Query records. See StackQuery for filter, sort, and pagination options.
437
+ * Reverse a soft delete. Idempotent — undeleting a record that isn't
438
+ * deleted returns it unchanged. Hard-deleted records are gone, so this
439
+ * throws StackNotFoundError for them just like any other missing record.
440
+ * Snapshots and bumps version, same as delete().
441
+ */
442
+ undelete(id: string, opts?: IfVersionOptions): Promise<StackRecord>;
443
+ /**
444
+ * Query records. filter.baseId matches every version of a type family,
445
+ * resolved against registered Types. Results come back exactly as
446
+ * stored; pass presentAt: 'latest' to migrate in memory. See
447
+ * docs/spec/data-model.md § Queries.
207
448
  */
208
449
  query(query?: StackQuery): Promise<QueryResult>;
450
+ /**
451
+ * Resolve filter.baseId into a concrete typeId set (intersected with
452
+ * filter.typeId when both are given), so adapters never need their own
453
+ * baseId concept. Returns EMPTY_FAMILY when the resolved set is empty so
454
+ * the caller can short-circuit without an adapter round trip.
455
+ */
456
+ private resolveBaseIdFilter;
209
457
  getVersions(id: string): Promise<RecordVersion[]>;
210
458
  getVersion(id: string, version: number): Promise<RecordVersion | null>;
211
459
  /**
212
- * Restore a record to a previous version by creating a new version
213
- * with the old content. Never rewrites history.
460
+ * Restore a record to a previous version by creating a new version —
461
+ * never rewrites history. The snapshot is validated against its own
462
+ * stored typeId (not the record's current type), restores associations,
463
+ * and never restores permissions. See docs/spec/versioning.md § Restore
464
+ * semantics.
465
+ */
466
+ restoreVersion(id: string, version: number, opts?: IfVersionOptions): Promise<StackRecord>;
467
+ /** Uniqueness for every unique binding field a newly created card claims. */
468
+ private checkBindingsOnCreate;
469
+ /**
470
+ * Immutability for every binding field a patch touches, then uniqueness
471
+ * for the subset that carries it. Fields absent from the patch carry no
472
+ * new claim — update() is a merge, so an untouched binding is the one the
473
+ * card already holds.
474
+ */
475
+ private checkBindingsOnUpdate;
476
+ /**
477
+ * A unique binding field is what a lookup resolves *by* — a record's
478
+ * `principalId` by `_app.did`, its `entityId` by `_entity.did`. Two cards
479
+ * claiming one value would leave that lookup without a single answer, and
480
+ * ambiguity is all an impersonating card needs. Enforced here rather than
481
+ * by schema, since uniqueness is a property of the set, not of the value.
482
+ * Called by the paths that can introduce a binding: create and update.
483
+ *
484
+ * Short-circuits on the first clash rather than materialising the family,
485
+ * so a stack whose `_entity` family is larger than a single scan settles
486
+ * the common case — the value is already taken — without walking the rest.
487
+ *
488
+ * Read-then-write, so two creates racing on one value can both pass.
489
+ * Closing that properly means a unique index over a JSON field, which
490
+ * each adapter would enforce separately — a decision about where
491
+ * uniqueness lives, not a local fix.
492
+ * See docs/spec/identity.md § DID bindings.
493
+ */
494
+ private checkBindingUnique;
495
+ /**
496
+ * A binding is permanent once made: uniqueness stops a second card
497
+ * claiming a value, but only immutability stops an existing card being
498
+ * moved onto one, which reaches the same impersonation by another route.
499
+ * Adopting a value is therefore a one-way step, and a subject whose key
500
+ * changes gets a new card — matching identity.md's deferral of key
501
+ * rotation, where a new key is a new identity rather than the same one
502
+ * relabelled. See docs/spec/identity.md § DID bindings.
214
503
  */
215
- restoreVersion(id: string, version: number): Promise<StackRecord>;
504
+ private checkBindingImmutable;
216
505
  /**
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.
506
+ * mimeType is a property of the fileId, not the uploader's perspective:
507
+ * the first metadata record for a fileId fixes it, and a conflicting
508
+ * later upload is rejected. Cursor-walked so earlier records past page
509
+ * one are seen. See docs/spec/attachments.md § The `_attachment` record
510
+ * type.
511
+ *
512
+ * Best-effort by construction — check-then-create with no storage-level
513
+ * uniqueness behind it, so two racing first uploads can both land on a
514
+ * concurrent server. What survives that race is the *resolution*: which
515
+ * record establishes the type is firstRecordedAttachment()'s total
516
+ * order, the same one a server serving Content-Type applies, so both
517
+ * sides name the same winner however many records exist.
220
518
  */
221
- putAttachmentBytes(data: Uint8Array): Promise<string>;
519
+ private checkAttachmentMimeTypeOnCreate;
222
520
  /**
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.
521
+ * filename is the only mutable field on an _attachment@1 record; fileId,
522
+ * size, and mimeType are immutable (even a same-value mimeType rewrite
523
+ * is refused). The correction flow is delete + re-upload. See
524
+ * docs/spec/attachments.md § The `_attachment` record type.
226
525
  */
227
- putAttachment(data: Uint8Array, mimeType: string, filename?: string): Promise<string>;
526
+ private checkAttachmentImmutableFields;
527
+ /**
528
+ * `_config.entityId` defines stack ownership; neither update() nor
529
+ * restoreVersion() may change it. A conflict with stack integrity, not a
530
+ * schema violation — hence StackConflictError. See docs/spec.md § The
531
+ * `_config` record.
532
+ */
533
+ private checkConfigEntityIdUnchanged;
534
+ /**
535
+ * Store bytes and create an _attachment@1 metadata record (owner-
536
+ * attributed, no entityId), returning that record — `content.fileId`
537
+ * addresses the bytes, `id` addresses the metadata. Delegates to the
538
+ * adapter's atomic putAttachmentWithMetadata() when implemented, trusting
539
+ * the returned record as backend-authoritative; otherwise falls back to
540
+ * bytes-then-create(). See docs/spec/wire-format.md § Attachments.
541
+ */
542
+ putAttachment(data: Uint8Array, mimeType: string, filename?: string, appId?: AppId): Promise<StackRecord & {
543
+ content: AttachmentContent;
544
+ }>;
228
545
  getAttachment(fileId: string): Promise<Uint8Array>;
229
546
  /**
230
547
  * Delete an attachment's bytes and its _attachment@1 metadata record(s).
@@ -233,107 +550,381 @@ export declare class Stack implements StackClient {
233
550
  */
234
551
  deleteAttachment(fileId: string): Promise<void>;
235
552
  /**
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.
553
+ * Non-atomic fallback for adapters that don't implement
554
+ * deleteUnreferencedAttachmentRecords(): a concurrent associate() can
555
+ * race between the reference check below and the deletes it guards.
556
+ */
557
+ private deleteUnreferencedAttachmentRecordsFallback;
558
+ /**
559
+ * Sweep for attachment bytes unreachable from any record — live or
560
+ * soft-deleted — and delete bytes + metadata. Deletion goes through
561
+ * deleteAttachment(), so a file re-referenced by sweep time is skipped,
562
+ * not a failure. See docs/spec/attachments.md § Garbage collection.
563
+ */
564
+ collectAttachmentGarbage(opts?: CollectAttachmentGarbageOptions): Promise<CollectAttachmentGarbageResult>;
565
+ /**
566
+ * Flush pending writes to the underlying storage. A no-op for adapters
567
+ * that commit on every call (SQLite, the API adapter); meaningful for
568
+ * ones that buffer, and for checkpointing a stack that stays open —
569
+ * close() covers the teardown case on its own.
240
570
  */
241
571
  flush(): Promise<void>;
242
572
  /**
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.
573
+ * Flush, then release any resources the adapter holds (connections, file
574
+ * handles, lock files). A failed flush still releases them before it
575
+ * propagates: an unwritable stack must not also leak a lock file.
576
+ * See docs/spec/adapters.md § Lifecycle.
247
577
  */
248
578
  close(): Promise<void>;
249
579
  /**
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.
580
+ * Throws once close() has run. Public only so ScopedStack can gate the
581
+ * one path it takes to the adapter directly; not an app-facing API.
582
+ */
583
+ assertOpen(): void;
584
+ /**
585
+ * Create _grant records authorizing entities to act on records of
586
+ * specific types; null entityId writes a default grant (any
587
+ * authenticated entity).
253
588
  *
254
- * The _grant@1 type is defined automatically on first use.
589
+ * Granting an **app** a `-own` action does not contain it the way the
590
+ * suffix suggests: when that app acts for someone, `-own` is read as the
591
+ * bare verb and the subject decides which records are in reach, so in a
592
+ * personal stack — where nearly every record is owner-authored — a
593
+ * delegated `read-own` is close to `read-any`. Grant an app the types it
594
+ * needs, not the suffix that looks narrowest. See
595
+ * docs/spec/access-control.md § Delegation: principal and subject.
596
+ *
597
+ * The grantee lives in content.granteeEntityId,
598
+ * not record.entityId. See docs/spec/access-control.md § Type-level
599
+ * grants.
255
600
  */
256
- grant(entityId: string | null, grants: Array<{
601
+ grant(entityId: EntityId | null, grants: Array<{
257
602
  actions: GrantAction[];
258
603
  typeId: TypeId;
259
604
  }>): Promise<StackRecord[]>;
605
+ /**
606
+ * List _grant records. Omit `entityId` for all grants; pass null for
607
+ * only default grants; pass a specific entityId for the grants that
608
+ * currently apply to that entity (ones naming them, plus every default
609
+ * grant) — the same resolution hasGrant() uses.
610
+ */
611
+ listGrants(entityId?: EntityId | null): Promise<StackRecord[]>;
612
+ /**
613
+ * The inverse of grant(): soft-deletes _grant records matching `entityId`
614
+ * (null for default grants) and each `{ typeId, actions }` pair, at the
615
+ * same granularity grant() writes. A soft delete like any other — the
616
+ * owner can undelete a revocation.
617
+ */
618
+ revoke(entityId: EntityId | null, grants: Array<{
619
+ actions: GrantAction[];
620
+ typeId: TypeId;
621
+ }>): Promise<void>;
622
+ /**
623
+ * Validates the whole grant batch before any record is created, so a bad
624
+ * entry fails clean rather than leaving a partial set written. Actions
625
+ * must be known GrantAction values, typeIds must be well-formed, and the
626
+ * _grant/_config families are refused — see docs/spec/access-control.md
627
+ * § Type-level grants.
628
+ */
629
+ private checkGrantsValid;
260
630
  private seedSystemTypes;
261
- private saveVersion;
631
+ /**
632
+ * Fast-fail for the ifVersion precondition using the already-fetched
633
+ * record. The adapter re-checks atomically at write time (the source of
634
+ * truth for concurrent writers) — this just skips validation and
635
+ * snapshotting work when the mismatch is already visible.
636
+ */
637
+ private checkIfVersion;
638
+ /**
639
+ * Snapshot of a record's prior state, passed with the mutating adapter
640
+ * call so snapshot and mutation land in one atomic write. `associations`
641
+ * is always present ([] when empty) so restore can distinguish "cleared"
642
+ * from a snapshot that omits the key entirely ("leave as-is"). See
643
+ * docs/spec/versioning.md § Version history.
644
+ */
645
+ private buildVersionSnapshot;
262
646
  }
263
647
  /**
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.
648
+ * A permission-enforcing view of a Stack for a single (principal, subject)
649
+ * pair, obtained
650
+ * via `stack.asEntity(entityId)`. Missing records throw StackNotFoundError
651
+ * (or return null on reads); existing-but-inaccessible ones throw
652
+ * StackPermissionError. See docs/spec/access-control.md.
266
653
  *
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".
654
+ * Two identities, one rule: **the principal governs authority, the subject
655
+ * governs attribution.** Grant lookup and the privilege-bearing gates that
656
+ * no grant reaches (setPermissions, group management, hard delete, widening
657
+ * access at create time) key on `principalEntityId`; authorship, `-own`
658
+ * matching, record-level permission resolution, and "files I uploaded"
659
+ * lookups key on `subjectEntityId`.
660
+ *
661
+ * Unconditional owner access follows that same split rather than one
662
+ * identity: it answers *what data is reachable* for the subject (an owner
663
+ * subject resolves past every permission check) and *who may exercise a
664
+ * privileged verb* for the principal (an owner app is not bounded by
665
+ * grants). Under delegation both halves apply, and a mistake on the
666
+ * authority side is an escalation rather than a preference.
272
667
  */
273
668
  export declare class ScopedStack implements StackClient {
274
669
  private readonly stack;
275
- private readonly requesterEntityId;
276
- constructor(stack: Stack, requesterEntityId: string | null);
670
+ private readonly principalEntityId;
671
+ private readonly subjectEntityId;
672
+ private readonly idTimestampSkewMs;
673
+ private readonly adapter;
674
+ constructor(stack: Stack, principalEntityId: EntityId | null, subjectEntityId: EntityId | null, idTimestampSkewMs: number | null, adapter: StackAdapter);
277
675
  get features(): StackFeatures;
278
676
  private resolveRecord;
677
+ /** Whether a delegated app is acting for someone other than itself. */
678
+ private get delegated();
679
+ /**
680
+ * Whether unconditional owner authority applies — the owner acting as
681
+ * itself. The verbs that rest on it are irreversible or disclose the
682
+ * sharing graph, so delegation never carries one to a subject, whichever
683
+ * side the owner is on. See
684
+ * docs/spec/access-control.md § Delegation: principal and subject.
685
+ */
686
+ private get ownerActingAlone();
279
687
  private checkRead;
280
688
  private checkWrite;
281
689
  /**
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.
690
+ * Whether `grantee` holds a _grant covering one of `actions` for the
691
+ * type's family (grants match by baseId, so a version bump never orphans
692
+ * one). -own actions additionally require record.entityId === grantee,
693
+ * unless `matchOwn` is false — on the principal side of a delegated
694
+ * request the suffix is read as the bare verb, since which records are
695
+ * reachable is the subject's business. `allowDefault` decides whether a
696
+ * grant naming nobody counts. Anonymous grantees always return false.
697
+ *
698
+ * Reached only through subjectAllows()/principalAllows(), which fix those
699
+ * two flags per side of the intersection. Call one of those instead.
700
+ * See docs/spec/access-control.md § Type-level grants.
286
701
  */
287
702
  private hasGrant;
703
+ /**
704
+ * The principal half of a delegated request's authority: does the app
705
+ * hold any grant permitting these verbs on this type at all. Bounds what
706
+ * the subject's own authority can reach through it, so a powerful app
707
+ * can never lend its reach to a weaker subject — nor the reverse.
708
+ * Vacuously true when there's no delegation, where the principal and
709
+ * subject checks would be the same question asked twice.
710
+ *
711
+ * Default grants don't count here. "Any authenticated entity" is about
712
+ * people who turn up, not software the owner installed — an app reaches
713
+ * only the types named to it, which is the whole of what containment
714
+ * promises.
715
+ */
716
+ private principalAllows;
717
+ /**
718
+ * The subject half: which records are reachable, answered with `-own`
719
+ * matching and default grants both in force — the ordinary reading of a
720
+ * grant, since the subject is the entity a grant is written about.
721
+ *
722
+ * Paired with principalAllows() so that the two halves of the
723
+ * intersection are the only callers of hasGrant(): its flags differ per
724
+ * side and mean nothing on their own, so no call site sets them by hand.
725
+ */
726
+ private subjectAllows;
288
727
  private canRead;
289
728
  private checkCreateGrant;
290
- /** Fetch a record the requester can update (via permissions or an update grant), or throw. */
729
+ /**
730
+ * `_group` records are managed, not merely written: only the owner or an
731
+ * `admin` roster holder may mutate them — ordinary write permissions and
732
+ * grants don't apply. Asked of both identities under delegation, like
733
+ * setPermissions(). See docs/spec/identity.md § Group.
734
+ */
735
+ private isGroupManager;
736
+ /** Whether one identity, on its own, manages `record` — see isGroupManager(). */
737
+ private managesGroup;
738
+ /**
739
+ * Fetch a record the subject can reach and the principal holds `update` on
740
+ * (via permissions or an update grant), or throw. `mutating: false` marks
741
+ * the history readers, which borrow this gate without changing anything —
742
+ * the one way through it a `_grant` Record stays open to.
743
+ */
291
744
  private requireUpdatable;
292
- /** Fetch a record the requester can delete (via permissions or a delete grant), or throw. */
745
+ /**
746
+ * Fetch a record the subject can reach and the principal holds `delete` on
747
+ * (via permissions or a delete grant), or throw.
748
+ */
293
749
  private requireDeletable;
294
750
  /**
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.
751
+ * Whether this request may reference `recordId` (as a parentId or
752
+ * relationship target). Missing and unreadable both return false —
753
+ * indistinguishable, so this can't probe for a record's existence.
754
+ */
755
+ private canReadReferent;
756
+ /**
757
+ * Whether this request can read some record referencing `fileId` —
758
+ * shared by canAccessFile() and the non-owner _attachment@1 create()
759
+ * carve-out, which deliberately excludes the uploader clause. Walks
760
+ * every referencing record, short-circuiting on the first readable one.
761
+ * See docs/spec/attachments.md § Creating `_attachment@1` records directly.
762
+ */
763
+ private hasReadableReference;
764
+ /**
765
+ * Whether this request may reference or download `fileId` — the dual of
766
+ * getAttachment()'s access rule. Nonexistent and inaccessible are
767
+ * indistinguishable (both false), so no confirmation oracle for guessed
768
+ * hashes. See docs/spec/access-control.md § Reference-creation gating.
769
+ */
770
+ private canAccessFile;
771
+ /** Names of the type's top-level file-ref fields — the content-reference half of attachmentFileId matching. */
772
+ private fileRefFieldNames;
773
+ /**
774
+ * Gates file-ref content fields on file access, mirroring the
775
+ * attachment-association gate — a file-ref field conveys attachment
776
+ * access exactly like an `attachment` association. Only fields present
777
+ * in `content` are checked (update() is a merge patch; untouched fields
778
+ * carry no new reference).
779
+ */
780
+ private requireFileRefAccess;
781
+ /**
782
+ * Reference-creation gate for one association: `attachment` requires
783
+ * file access, `relationship` read access to its target; `tag` is
784
+ * unchecked, and `_group` roster associations are gated by the stricter
785
+ * isGroupManager() instead. See docs/spec/access-control.md
786
+ * § Reference-creation gating.
787
+ */
788
+ private requireAssociationAccess;
789
+ /**
790
+ * Create a record on behalf of the subject: create grant required,
791
+ * anonymous denied, entityId set to the subject, client IDs skew-checked,
792
+ * reference-creating options gated, and non-owner `_attachment@1`
793
+ * creation refused save one carve-out. A scoped create always stamps
794
+ * authorship — an absent entityId means an unscoped `Stack` wrote it.
795
+ * See docs/spec/access-control.md and docs/spec/attachments.md.
299
796
  */
300
797
  create<T extends Record<string, unknown> = Record<string, unknown>>(typeId: TypeId, content: T, opts?: CreateRecordOptions): Promise<StackRecord & {
301
798
  content: T;
302
799
  }>;
800
+ /**
801
+ * Whether this request may decide who else reaches a record — the rule
802
+ * setPermissions() enforces, asked at create time too so the reach it
803
+ * withholds can't be taken one step earlier while authoring. A delegated
804
+ * app is denied it: widening access is the one thing containment most
805
+ * needs to hold. Refused rather than silently ignored, so an app never
806
+ * believes it published something it didn't. Not `ownerActingAlone`:
807
+ * the record is the subject's own, so an owner principal grants it no
808
+ * reach the subject lacks.
809
+ * See docs/spec/access-control.md § Delegation: principal and subject.
810
+ */
811
+ private mayGrantAccess;
812
+ /**
813
+ * Whether one identity, on its own, may decide who else reaches `record`
814
+ * — the owner-or-creator rule setPermissions() enforces, asked of one
815
+ * side at a time. See
816
+ * docs/spec/access-control.md § Delegation: principal and subject.
817
+ */
818
+ private mayReshare;
303
819
  get(id: string, opts?: GetRecordOptions): Promise<StackRecord | null>;
304
820
  /**
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).
821
+ * Query records, filtered to those this request can read. Pages are
822
+ * filtered then refilled, so a page may slightly overshoot `limit` but
823
+ * never skips a record. `total` is always null (see QueryResult.total).
824
+ * Grants are prefetched once, cursor-walked to exhaustion.
825
+ */
826
+ query(query?: StackQuery): Promise<QueryResult>;
827
+ update(id: string, content: Record<string, unknown | null>, opts?: IfVersionOptions): Promise<StackRecord>;
828
+ /**
829
+ * Naming the software behind a key is the trust decision the `_app`
830
+ * registry exists to record, so both halves of that binding — `did` and
831
+ * `appId` — belong to the owner alone. Same reasoning that makes `_app`
832
+ * ungrantable, applied to the fields a lookup reads. Registering a card
833
+ * is already owner-only; without this, record-level `write` shared on a
834
+ * card would be a second way in: a card carrying no DID yet could be
835
+ * pointed at a write-holder's own key while keeping the name the owner
836
+ * gave it, or relabelled to claim another app's `appId`. `name` and
837
+ * `version` stay writable — they are display, not lookup.
312
838
  *
313
- * `total` is always null — see the QueryResult.total doc comment.
839
+ * Owner *acting alone*, in both directions: a delegated app never holds
840
+ * it, and an owner principal doesn't lend it to a subject holding
841
+ * record-level `write` on a card — which would reopen the same route
842
+ * from the other side.
314
843
  *
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.
844
+ * `_entity` deliberately does not get this rule: naming people is what a
845
+ * contacts app does, so its cards stay writable by grant. Uniqueness and
846
+ * immutability still bind them. See docs/spec/identity.md § DID bindings.
847
+ */
848
+ private requireOwnerForAppIdentity;
849
+ /**
850
+ * A self-reported `appId` must agree with the `_app` card naming the
851
+ * principal's DID, where the owner registered one. `principalId` is
852
+ * verified, so letting the pair disagree would leave a verified principal
853
+ * claiming a name the owner gave different software — the cross-check the
854
+ * registry exists for, refused at the write instead of left to each reader.
855
+ * A principal with no card keeps `appId` as the bare self-report it is for
856
+ * every undelegated writer.
857
+ * See docs/spec/identity.md § Attribution and what can be trusted.
858
+ */
859
+ private requireAppIdMatchesPrincipal;
860
+ /**
861
+ * The owner's own DID is the one `_entity` binding a grantee may not claim.
862
+ * `ownerProfile` adopts whichever card holds it, so a card minted by
863
+ * someone else becomes the stack's own profile, and uniqueness then makes
864
+ * that permanent. Every other DID stays open to a contacts app, which is
865
+ * the reach `_entity` is grantable for.
866
+ * See docs/spec/identity.md § DID bindings.
867
+ */
868
+ private requireOwnerForOwnerDid;
869
+ /**
870
+ * A `_grant` Record *is* authority, so rewriting one is the escalation
871
+ * UNGRANTABLE_SYSTEM_TYPES refuses at evaluation, reached by editing an
872
+ * existing grant rather than minting a fresh one. `grant()` and `revoke()`
873
+ * live on `Stack`, never `StackClient`, so no scoped write is lost. Writes
874
+ * only: reading a grant Record and its history stays on the ordinary gate.
875
+ * See docs/spec/access-control.md § Type-level grants.
876
+ */
877
+ private requireOwnerForGrantRecord;
878
+ associate(id: string, association: Association, opts?: IfVersionOptions): Promise<void>;
879
+ dissociate(id: string, association: Association, opts?: IfVersionOptions): Promise<void>;
880
+ setPermissions(id: string, permissions: Permission[], opts?: IfVersionOptions): Promise<void>;
881
+ /**
882
+ * Hard delete is owner-only: it is irreversible and destroys version
883
+ * history, so neither the write bit nor delete-own/delete-any grants
884
+ * reach it, and delegation doesn't carry it either. Everyone else is
885
+ * limited to soft delete.
317
886
  */
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
887
  delete(id: string, opts?: DeleteRecordOptions): Promise<void>;
888
+ /**
889
+ * Reverse a soft delete. Gated the same as delete() — undelete is the
890
+ * inverse of soft delete, so granting one direction without the other
891
+ * would be backwards. Idempotent, per Stack.undelete().
892
+ */
893
+ undelete(id: string, opts?: IfVersionOptions): Promise<StackRecord>;
894
+ /**
895
+ * History is the mutation/recovery surface, not a read surface — gated
896
+ * like update(), with snapshot `permissions` stripped for everyone but
897
+ * the owner acting alone — a snapshot's permissions are the stack's
898
+ * sharing graph, which delegation is not a route to. Reading history
899
+ * changes nothing, so it is the one path the `_grant` write fence leaves
900
+ * alone: seeing how a Record you can already read got that way is not
901
+ * the escalation that fence exists to stop, and losing it would leave a
902
+ * write-holder unable to audit the Record they hold.
903
+ * See docs/spec/versioning.md § History access.
904
+ */
324
905
  getVersions(id: string): Promise<RecordVersion[]>;
906
+ /** See getVersions() — same mutate-surface gate, same permissions stripping. */
325
907
  getVersion(id: string, version: number): Promise<RecordVersion | null>;
326
- restoreVersion(id: string, version: number): Promise<StackRecord>;
327
908
  /**
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.
909
+ * Re-runs the reference-creation checks against the snapshot, so a
910
+ * restore can't re-convey access to a file or record the subject can no
911
+ * longer reach today. Only the owner acting alone is exempt: under
912
+ * delegation the checks resolve against the subject, which is whose reach
913
+ * the restore would widen. See docs/spec/versioning.md § Restore semantics.
331
914
  */
332
- putAttachment(data: Uint8Array, mimeType: string, filename?: string): Promise<string>;
915
+ restoreVersion(id: string, version: number, opts?: IfVersionOptions): Promise<StackRecord>;
333
916
  /**
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.
917
+ * Store bytes and create an _attachment@1 metadata record (create grant
918
+ * on `_attachment@1` required; anonymous denied), returning that record.
919
+ * Authorship and principal are stamped exactly as create() does.
920
+ */
921
+ putAttachment(data: Uint8Array, mimeType: string, filename?: string, appId?: AppId): Promise<StackRecord & {
922
+ content: AttachmentContent;
923
+ }>;
924
+ /**
925
+ * Download attachment bytes. Accessible to the owner, a reader of any
926
+ * referencing record, or the uploader pre-association — the same
927
+ * predicate as the reference-creation gate (canAccessFile).
337
928
  */
338
929
  getAttachment(fileId: string): Promise<Uint8Array>;
339
930
  /**
@@ -341,5 +932,10 @@ export declare class ScopedStack implements StackClient {
341
932
  * Delegates to Stack.deleteAttachment(), which enforces the "not referenced" check.
342
933
  */
343
934
  deleteAttachment(fileId: string): Promise<void>;
935
+ /**
936
+ * Sweep for unreferenced attachment bytes and delete them. Only the stack
937
+ * owner may run this. Delegates to Stack.collectAttachmentGarbage().
938
+ */
939
+ collectAttachmentGarbage(opts?: CollectAttachmentGarbageOptions): Promise<CollectAttachmentGarbageResult>;
344
940
  }
345
941
  //# sourceMappingURL=stack.d.ts.map