0046. What a denied write reports on an atomic action
0046. What a denied write reports on an atomic action
Status
Proposed
Date
2026-10-04
Deciders
operator. The lead set the working assumption (option A) on 2026-10-04 and asked for this record.
Context
A policy is a declared rule about who may run an action (ADR-0022). Some policy checks need the record: “only the author may publish this post”. An update in Mesh is atomic by default, one statement with no read first (ADR-0017), so such a check cannot be evaluated in memory beforehand. It is compiled into the statement.
There are two ways to compile it, and they give the caller different answers when the check fails.
Ash, the Elixir framework Mesh is modelled on, compiles the check into the update statement as an expression that raises an error inside the database, so the caller gets a forbidden error (Ash runtime internals, sections 2.1 and 6.6). For reads Ash does the other thing: a read policy becomes a filter, so a forbidden row looks like “not found” (research synthesis, section 2.2).
The roadmap’s working assumption for Mesh is the filter on writes too (roadmap, M8). The lead accepted it on 2026-10-04 believing it to be Ash’s behaviour, then confirmed it as a deliberate deviation once the research showed otherwise (rulings of 2026-10-04, section “Lead decisions of the architecture-docs brief”). The ruling that Mesh copies Ash covers the DSL’s names and structure (ADR-0034), not run-time behaviour, so nothing forces Ash’s outcome here; but a caller-visible difference from the framework Mesh is modelled on is worth a ruling.
Decision
Not decided. Working assumption: option A. It blocks M8.
Options considered
Option A: Fold the check in as a filter; a denied row reports not found
| Dimension | Assessment |
|---|---|
| Complexity | Low: the same compilation as a read policy, added to the statement’s condition |
| Cost | Nothing new in the data adapters |
| Fidelity to Ash | Deviates for writes; matches Ash for reads |
| What the caller learns | Less: forbidden and missing look the same on atomic actions |
Pros: Needs no way to raise an error from SQL, which SQLite lacks in a direct form (not checked). When the read policy hides the row as well, reads and writes agree: the row does not exist for that caller, and its existence is not revealed.
Cons: In the common case the read policy does not hide the row: in the roadmap’s own example a non-author can read a published post and may not rename it. That caller, having just read the post, is told it does not exist. The same denied write reports not found on an atomic action and forbidden on a require-atomic=false action, where the row is loaded and the check runs in memory. The policy breakdown, which the research calls the best debugging tool in Ash (research synthesis, section 2.2), is not produced on the atomic path; the caller must ask can.
Option B: Compile the check as an expression that raises; a denied row reports forbidden
| Dimension | Assessment |
|---|---|
| Complexity | Medium to high: a raise mechanism per data adapter |
| Cost | A SQL function or equivalent in Postgres and in SQLite; Ash’s atomic path uses an ash_raise_error function for this (Ash runtime internals, section 2.1) |
| Fidelity to Ash | Exact |
| What the caller learns | The same error on both paths |
Pros: Ash’s behaviour. One outcome for a denied write whichever path the action takes.
Cons: Installing functions in the user’s database is a step Mesh otherwise avoids. Which check failed still needs a second query or can. Tells a caller that a row they may not change exists.
Option C: Record-reading write policies make the action non-atomic
| Dimension | Assessment |
|---|---|
| Complexity | Low: the rule v1 already applies to record-reading validations (ADR-0017) |
| Cost | A row lock and a read on every update whose policy reads the record; how common that is has not been measured |
| Fidelity to Ash | Deviates: Ash keeps these atomic |
| What the caller learns | Everything: forbidden, with the breakdown |
Pros: One outcome, full breakdown, no SQL tricks.
Cons: Atomic updates become rare in any project with policies, which undoes the point of ADR-0017.
Option D: Fold the check in as a filter, and re-read when no row is affected
| Dimension | Assessment |
|---|---|
| Complexity | Medium: the filter of option A plus one read and an in-memory evaluation on the failure path |
| Cost | A second query only when a write is denied or the row is missing; nothing new in the adapters |
| Fidelity to Ash | Same outcome as Ash (forbidden), by a different mechanism |
| What the caller learns | Forbidden with the breakdown when the row is visible to them; not found when it is not |
Pros: Reaches Ash’s outcome with no function installed in the database. The breakdown is available, because the check is evaluated in memory on the re-read row. Reports not found only when the caller could not have read the row anyway.
Cons: The re-read sees the row as it is after the statement, so under a concurrent writer it can explain a denial with a row that has changed; ADR-0044 describes the same caveat for folded validations and the conflict case that comes with it. The in-memory and SQL forms of the check must agree.
Trade-off analysis
B gives one consistent outcome and is exactly what Ash does, at the price of database functions on two engines. A is the cheapest and is consistent with reads only when the read policy hides the row too; where it does not, A tells a caller that a row they can see does not exist. C is the simplest to reason about and the most expensive at run time. D reaches B’s outcome without B’s database functions and costs a query only on denial, but it brings the re-read protocol that v1 otherwise avoids (ADR-0044).
Recommendation of the roadmap author: keep A as the working assumption only until the operator rules, and prefer D if the cost of its failure path is acceptable, because A’s wrong answer in the common case is a real defect and D removes it without touching the database. The lead’s working assumption is A.
Consequences
- Under A, user documentation must say that an atomic action reports not found for a row the caller may not change, and that
canexplains why. - Under A, a test compares
canwith the action by decision, not by error class (roadmap, M8, test 8). - Under D, M8 adds a re-read and an in-memory policy check on the failure path, and must say what is reported when the row changed between the statement and the re-read (the caveat ADR-0044 describes).
- If B is chosen later, callers of atomic actions start receiving forbidden where they received not found: a behaviour change, though not a change to resource files.
Action items
- Before M8: the operator rules among A, B and D.
- M8: implement the ruled option; state the outcome in the user docs and in
mesh explain. - After v1: revisit with ADR-0044 if a raise mechanism is added to the adapters.