Skip to content

Deletes Denied with "Missing Row Content" Error #1079

Description

@spiicy-sauce

Version: jazz-tools 2.0.0-alpha.52. Reproduced with the browser client (jazz-wasm 2.0.0-alpha.52, OPFS dual-runtime) against both a local server from startLocalJazzServer() (same package version) and the cloud sync server at v2.sync.jazz.tools.

Summary

After a schema migration, rows created under the previous schema hash remain readable (lens-composed reads work as documented), but a client on the new schema cannot delete them when the table has a row-dependent permission policy (e.g. isCreator / managedByCreator()). The client-side policy engine allows the delete and applies it optimistically; the server's User-role permission check then rejects it with:

permission_denied: "Delete denied on table <t> - missing row content"

The rollback syncs down and the row visibly pops back. This recurs for every row that predates every future migration, so any app using creator-owned policies loses the ability to delete pre-migration data permanently.

Reproduction

  1. Deploy schema A; as user alice (local-first session), insert a row into a table whose policy is managedByCreator() (or any policy whose delete rule needs row content/provenance, e.g. allowDelete.where(isCreator)).
  2. Deploy schema B plus the A→B migration edge (any column addition) and the permissions.
  3. Connect a browser client on schema B as the same alice. The row is visible (lens read works).
  4. db.delete(table, id).

Observed: the row disappears (optimistic local apply), then onMutationError fires with Delete denied on table <t> - missing row content and the row reappears.

Additional findings

  1. Updates on the same rows are accepted. After one accepted update, a subsequent delete succeeds. This is the basis of the workaround I'm using for now, which is to update a column before deleting (though annoyingly, this requires actually writing something meaningful).
  2. Sequencing inside one batch or one transaction does not help. update(touch) + delete in a single direct batch, or in a single transaction, is still denied the same way. Validation seems to read committed pre-batch storage for every op rather than intra-batch state.
  3. Back-to-back separate writes do not help either. The delete is only accepted if it arrives after the touch is confirmed (I wait on the update's WriteHandle.wait({ tier: "edge" }) before issuing the delete). Fire-and-forget touch immediately followed by delete over the same connection is still denied, so the delete's permission check appears to run against a snapshot that does not yet include a just-queued update.
  4. A rejected delete "poisons" the row. After the server rejects a bare delete and restores the row, a subsequent plain update on that row is denied with Update denied by USING policy on table books - no old content. Later touch attempts from that client do not settle at all (no batch fate I can observe — no onMutationError, no apply), leaving the row effectively immortal from the client.

Expected behavior

Either of these would resolve it:

  • The server-side permission evaluator resolves row content/provenance through lens paths / branch ancestry, the same way reads do; or
  • A delete on a lens-projected row triggers the same copy-on-write materialization that updates get, so the permission check has content to evaluate.

And ideally: a rejected delete should not leave the row in a state where subsequent updates are also denied (finding 4).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions