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
- 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)).
- Deploy schema B plus the A→B migration edge (any column addition) and the permissions.
- Connect a browser client on schema B as the same
alice. The row is visible (lens read works).
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
- 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).
- 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.
- 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.
- 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).
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 fromstartLocalJazzServer()(same package version) and the cloud sync server atv2.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: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
alice(local-first session), insert a row into a table whose policy ismanagedByCreator()(or any policy whose delete rule needs row content/provenance, e.g.allowDelete.where(isCreator)).alice. The row is visible (lens read works).db.delete(table, id).Observed: the row disappears (optimistic local apply), then
onMutationErrorfires withDelete denied on table <t> - missing row contentand the row reappears.Additional findings
update(touch)+deletein 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.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.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 — noonMutationError, no apply), leaving the row effectively immortal from the client.Expected behavior
Either of these would resolve it:
And ideally: a rejected delete should not leave the row in a state where subsequent updates are also denied (finding 4).