Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/spec/access-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ A revocation is a soft delete like any other mutation — the owner can `undelet
- **No wildcard `typeId`**: there is no `*` or catch-all. Every grant is opt-in per type. Adding a new type never implicitly inherits existing grants — it starts default-deny.
- **Some system types can't be granted at all**: `grant()` refuses `_grant`, `_config`, and `_app`. Each would hand the grantee the machinery the model rests on — minting their own grants, rewriting stack ownership, or registering an app card claiming a DID that isn't theirs (see [App](./identity.md#app)). Other reserved types (`_attachment`, `_entity`, `_group`) stay grantable. The refusal is enforced **again at evaluation**: a `_grant` Record naming one of these families confers nothing, however it came to exist. `grant()` is not the only way a Record gets written — an unscoped `Stack`, a JSON import, or a server mapping a request body onto `Stack` can all mint one — so a rule enforced only at the writing helper would hold only for records that went through it.
- **A `_grant` Record is only writable by the owner acting alone.** Refusing grants _on_ `_grant` closes one route to authority; record-level `write` on a grant Record is another, reaching the same escalation by editing what an existing grant confers — its `actions`, `typeId`, or `granteeEntityId` — rather than by minting a fresh one. So `ScopedStack` refuses `update()`, `associate()`, `dissociate()`, `delete()`, `undelete()`, `restoreVersion()` and `setPermissions()` on any `_grant` Record with `StackPermissionError`, whatever the Record's own `permissions` say, and delegation does not carry it (see [Delegation](#delegation-principal-and-subject)). Nothing legitimate is lost: `grant()` and `revoke()` live on `Stack`, never on `StackClient`, so a scoped caller has no business writing one.
- **`commitMigration()` is owner-acting-alone, and no grant substitutes for it.** Moving a Record between type families is not something record-level `write` or an `update` grant confers, in any combination: `ScopedStack.commitMigration()` refuses every requester but the owner acting alone, delegation included. This mirrors the bulk path — `migrateAll()` lives on `Stack` and is absent from `StackClient` for the same reason `grant()`/`revoke()` are — so the per-record verb carries the restriction the family-wide one already had, instead of introducing a grant model beside it.

The restriction is what makes the verb safe to expose. `commitMigration()` replaces `content` and `typeId` wholesale, so it is create-shaped at the destination and update-shaped over the Record as it stands: a grant-based version would have to re-derive every gate `create()` applies _and_ every gate `update()` applies, and would reopen each one it missed. The sharpest is the non-owner `_attachment@1` refusal (see [Attachments](./attachments.md#creating-_attachment1-records-directly)) — a requester holding a create grant on `_attachment@1` and write access to any Record they authored could otherwise migrate that Record into the family naming any `fileId`, then read the bytes through the uploader clause. Ordinary write access to a Record is not consent to move it between families.

**The fence is on writes only.** `get()`, `query()`, `getVersions()` and `getVersion()` on a `_grant` Record stay on their ordinary gates. Reading how a Record you can already reach came to be is not the escalation the fence exists to stop, and taking history away would leave a write-holder unable to audit the grant they hold — [history](./versioning.md#history-access) is the recovery surface, so losing it costs more than it protects. Snapshot `permissions` are stripped there as everywhere, so a grant's history discloses no more of the sharing graph than its current state does. `restoreVersion()` is a write and stays refused, even though reading the snapshot it would restore does not. `_config` is already unreachable through `Stack.get()`; `_app` keeps record-level `write` for its display fields, fenced only on the bindings a trust decision reads (see [DID bindings](./identity.md#did-bindings)).

Expand Down
9 changes: 7 additions & 2 deletions docs/spec/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ Reserved, library-defined types: `_config@1` ([Stack initialization](../spec.md#

### Type migrations

A Type's defining app is the only serious writer of its own types, so migration is **explicit and owner-driven**, not a side effect of a read or an unrelated write. Disk state changes version only via a deliberate `migrateAll()` pass — the invariant is that all Records of a family sit at version N until the owning app moves them, so `query({ filter: { typeId } })` and grants targeting a type never silently miss not-yet-migrated records.
A Type's defining app is the only serious writer of its own types, so migration is **explicit and owner-driven**, not a side effect of a read or an unrelated write. Disk state changes version only via a deliberate `migrateAll()` pass or a per-record `commitMigration()` call — the invariant is that a Record's `typeId` never moves except through one of these two, so `query({ filter: { typeId } })` and grants targeting a type never silently miss not-yet-migrated records.

Apps register migration functions between adjacent Type versions at startup. The library composes them into a full migration graph, so an app that only knows about v3 doesn't need to know that v1 ever existed.

Expand All @@ -178,7 +178,12 @@ The migration registry is **per-stack-instance** — different stacks can be at
- **`presentAt: 'latest'`** — an explicit opt-in on both `get()` and `query()` that applies the registered migration chain in memory before returning. Nothing is written to disk; this is a read-time convenience, never a persistence mechanism. Throws `StackMigrationError` when a matched Record's version can't be reconciled with what this app instance has registered (see stale-writer behavior below).
- **`update()` never migrates.** It validates the merge-patched content against the Record's _own current_ stored Type — never the latest — and writes back at the same `typeId`. An unrelated content edit can never fold an invisible schema rewrite into the same version-history entry.
- **Path composition** — migrations between adjacent versions are automatically chained (v1→v2→v3), so apps only ever register one step at a time.
- **`migrateAll("com.example.myapp/note")`** is the _only_ way disk state changes version. It eagerly commits all pending migrations for a type family in one deliberate pass — call it at app startup after registering migrations, or after a schema change. It sweeps soft-deleted Records unconditionally (`includeDeleted` is not a caller option in either direction — see [Deletion](./versioning.md#deletion)), validates each migrated result against the target Type's schema before writing, and aborts immediately on the first validation failure (a buggy migration function is a bug to surface, not to paper over by skipping the offending records) — anything already committed earlier in the pass stays committed. Previous content is snapshotted to version history before each write.
- **`migrateAll("com.example.myapp/note")`** eagerly commits all pending migrations for a type family in one deliberate pass — call it at app startup after registering migrations, or after a schema change. It sweeps soft-deleted Records unconditionally (`includeDeleted` is not a caller option in either direction — see [Deletion](./versioning.md#deletion)), validates each migrated result against the target Type's schema before writing, and aborts immediately on the first validation failure (a buggy migration function is a bug to surface, not to paper over by skipping the offending records) — anything already committed earlier in the pass stays committed. Previous content is snapshotted to version history before each write.
- **`commitMigration(id, toTypeId, content)`** is the single-record counterpart, changing one Record's `typeId` and `content` together in one step. Unlike `migrateAll()`, `content` here is supplied by the caller rather than produced by a registered `Migration` function — the client-side app that owns `toTypeId` computes it, and the library validates it against `toTypeId`'s schema exactly as `create()`/`update()` validate against a schema. This is what backs the wire's `POST /records/:id/migrate` (see [Wire format](./wire-format.md#records)). Under `ScopedStack` it is **owner-acting-alone**, matching `migrateAll()`'s own absence from `StackClient` — no grant or record-level `write` substitutes for it (see [Access control](./access-control.md#type-level-grants)). Previous content and `typeId` are snapshotted to version history first, same as `migrateAll()`.

Because `content` is a full replacement written under a new `typeId`, a migration commit is create-shaped at the destination and update-shaped over the Record as it stands, and owes both sets of integrity checks. DID bindings are held to immutability across the union of the two families' binding fields — a card can neither shed its `did` by migrating out of `_entity`/`_app` nor pick one up on the way in — and to uniqueness in the destination family (see [Identity § DID bindings](./identity.md#did-bindings)). An `_attachment@1` Record's `fileId`, `mimeType` and `size` stay immutable, and a Record arriving from outside that family is held to the same mimeType-establishment check `create()` applies. Migrating _into_ `_group` is refused outright: a group's `admin` roster entry is stamped at creation and a migration cannot stamp one, so it would produce a group nobody but the owner can manage — version-to-version migration within `_group` stays open and carries the existing roster with it.

**`migrateAll()` applies these same checks**, on the same shared write path. That a `Migration` function is app code rather than a request body is not a trust boundary here: the app calling `commitMigration()` is the same app that registered the function, and neither is entitled to move a DID binding or repoint an attachment. `registerMigration()` also places no constraint on `from` and `to` sharing a `baseId`, so a registered path can itself cross type families — which is precisely what these checks are about. A migration function that would violate one aborts the pass like any other validation failure.

**Stale-writer behavior.** A Record whose version this app instance can't reconcile — older than what it's registered _and_ not bridged by a migration path, or newer than anything it has ever `defineType()`'d — is an explicit error (`StackMigrationError`) under `presentAt: 'latest'`, not a silent pass-through. This covers both directions of "the same app at two versions" meeting via a shared stack. Reading the Record as stored (the default, no `presentAt`) always succeeds regardless — the stale-writer signal only fires when the app explicitly asks for the migrated view and the library can't honestly provide one.

Expand Down
2 changes: 1 addition & 1 deletion docs/spec/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Restoring an `_app` or `_entity` Record is fenced for the same reason, by the ru

## Optimistic concurrency (`ifVersion`)

`version` is not conflict detection by itself — it exists to power rollback and soft-delete recovery. Nothing reads or writes it unless a caller opts in. Every mutating method (`update`, `delete`, `undelete`, `associate`, `dissociate`, `setPermissions`, `restoreVersion`) accepts an optional `ifVersion`:
`version` is not conflict detection by itself — it exists to power rollback and soft-delete recovery. Nothing reads or writes it unless a caller opts in. Every mutating method (`update`, `delete`, `undelete`, `associate`, `dissociate`, `setPermissions`, `restoreVersion`, `commitMigration`) accepts an optional `ifVersion`:

```ts
await stack.update(id, { title: 'New' }, { ifVersion: 5 });
Expand Down
4 changes: 2 additions & 2 deletions docs/spec/wire-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ POST /records/:id/migrate — commit a migration (change typeId + content tog

`PATCH /records/:id` accepts a partial content object. Omitted fields retain their current values. A field set to `null` is removed (RFC 7396 / JSON Merge Patch). Associations and permissions are managed via their own endpoints.

**Optimistic concurrency:** `PATCH`, `DELETE`, `POST .../undelete`, `POST .../restore/:version`, and the association/permission endpoints below all accept an optional `If-Match` header:
**Optimistic concurrency:** `PATCH`, `DELETE`, `POST .../undelete`, `POST .../restore/:version`, `POST .../migrate`, and the association/permission endpoints below all accept an optional `If-Match` header:

```
PATCH /records/abc123
Expand All @@ -256,7 +256,7 @@ When present, the server applies the mutation only if the record's current versi

**`entityId` and `principalId` are assigned by the server from the authenticated session, and MUST be ignored if a request body carries them.** They are the two fields that answer "who did this", so a server that echoes back what it was handed makes both self-reported — and `principalId` exists precisely to be the field that isn't (see [Identity § Attribution and what can be trusted](./identity.md#attribution-and-what-can-be-trusted)). A client naming its own `principalId` could dress any write up as a verified app action, defeating the `_app` cross-check that reads it. `ScopedStack` already overrides both regardless of what a caller passes, so a server built on it inherits this; one that maps a request body onto `Stack` directly has to drop them itself. The same applies to `version`, `createdAt`, and `updatedAt`, which the server assigns as it does on any write. `appId` is the deliberate exception — self-reported by design, and never a permission input. For `typeId: "_attachment@1"`, a non-owner requester gets `403` regardless of grants — see [Attachments](./attachments.md#creating-_attachment1-records-directly) for the refusal, its carve-out, and `POST /attachments` as the non-owner-safe combined path.

`POST /records/:id/migrate` is the only way a record's `typeId` changes after creation. Body: `{ "toTypeId": "...", "content": {...} }` — the full post-migration content, computed client-side by the type's owning app (migration functions are app code, not server code) and validated by the server against `toTypeId`'s schema before writing. This is what `stack.update()` uses to commit a pending lazy migration alongside a content patch (a content-only `PATCH` can't carry a `typeId` change), and what `stack.migrateAll()` uses for each record in a batch pass.
`POST /records/:id/migrate` is the only way a record's `typeId` changes after creation. Body: `{ "toTypeId": "...", "content": {...} }` — the full post-migration content, computed client-side by the type's owning app (migration functions are app code, not server code) and validated by the server against `toTypeId`'s schema before writing. This is what `stack.update()` uses to commit a pending lazy migration alongside a content patch (a content-only `PATCH` can't carry a `typeId` change), and what `stack.migrateAll()` uses for each record in a batch pass. `Stack.commitMigration()`/`ScopedStack.commitMigration()` is the client-side entry point that backs this endpoint for a single record — see [Type migrations](./data-model.md#type-migrations). A server built on `ScopedStack` serves this endpoint to the **stack owner** and answers `403` otherwise: migration is owner-driven, and no grant confers it (see [Access control](./access-control.md#type-level-grants)). Like every other endpoint that bumps a record's version, it accepts `If-Match` — a migration commit replaces content wholesale, so it is precisely the write a caller most needs to be able to fence. `stack.migrateAll()` sends none, since a batch pass doesn't know each record's version going in; a single `commitMigration()` passes whatever `ifVersion` its caller supplied.

### Response envelope

Expand Down
11 changes: 7 additions & 4 deletions packages/adapter-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -665,11 +665,14 @@ export class APIAdapter implements StackAdapter {
id: RecordId,
toTypeId: TypeId,
content: Record<string, unknown>,
opts: { expectedVersion?: number } = {},
): Promise<StackRecord> {
const raw = await this.request<WireRecord>('POST', `/records/${id}/migrate`, {
toTypeId,
content,
});
const raw = await this.request<WireRecord>(
'POST',
`/records/${id}/migrate`,
{ toTypeId, content },
{ ifMatch: opts.expectedVersion },
);
return parseRecord(raw);
}

Expand Down
16 changes: 16 additions & 0 deletions packages/adapter-api/tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,22 @@ describe('commitMigration', () => {
const result = await adapter.commitMigration('rec-abc123', 'com.example/note@2', {});
expect(result.typeId).toBe('com.example/note@2');
});

test('sends If-Match when expectedVersion is given', async () => {
const adapter = await openAdapter();
mockFetch.mockResolvedValueOnce(jsonResponse(RECORD_RAW));
await adapter.commitMigration('rec-abc123', 'com.example/note@2', {}, { expectedVersion: 5 });
const [, init] = mockFetch.mock.lastCall as [string, RequestInit];
expect((init.headers as Record<string, string>)['If-Match']).toBe('"5"');
});

test('omits If-Match when expectedVersion is not given', async () => {
const adapter = await openAdapter();
mockFetch.mockResolvedValueOnce(jsonResponse(RECORD_RAW));
await adapter.commitMigration('rec-abc123', 'com.example/note@2', {});
const [, init] = mockFetch.mock.lastCall as [string, RequestInit];
expect((init.headers as Record<string, string>)['If-Match']).toBeUndefined();
});
});

// -------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion packages/adapter-local/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ export class LocalAdapter implements StackAdapter {
id: RecordId,
toTypeId: TypeId,
content: Record<string, unknown>,
opts?: { snapshot?: RecordVersion },
opts?: { expectedVersion?: number; snapshot?: RecordVersion },
): Promise<StackRecord> {
return this.record.commitMigration(id, toTypeId, content, opts);
}
Expand Down
Loading
Loading