feat(woc): WO-E224 — refuse a durable ref into a volatile table

Task 2 of docs/superpowers/plans/2026-08-26-table-residency.md.

- the check lives in `check_field_types`, which already runs over the raw AST
  (so the diagnostic lands at the field's own position, once per declaration)
  in Pass 2 with `syms` fully built
- only the durable -> volatile direction is refused. volatile -> durable is
  legal: the referencing row is the one that disappears, so nothing is left
  holding a stale id
- the message names both classes and both escapes, because "this is wrong" is
  less useful than "make Session durable, or declare Order volatile too"
- sees through a `?` wrapper, so `?ref S` is caught too
- code picked as 24 by sweeping `<stage>_prefix ^ "NN"` — 01-23, 25, 26 and
  50 were taken, so 24 was a genuine hole. Grepping the literal WO-E224
  would have found nothing, which is how ten codes once went missing
- catalogued in the same commit, and the completeness sweep re-run: 53
  emitted, 54 catalogued (the extra is retired WO-W201), none missing

PLAN CORRECTION: the plan's second step said to apply the same check to
`backlink` fields. Dropped — a backlink is "NOT a stored column" (ast.ml:72),
so after a restart it resolves to an EMPTY COLLECTION, which is a legal state
indistinguishable from "nothing references me". There is no id to dangle.
Implementing it would have refused correct programs; a spurious diagnostic is
worse than a missing one. A run fixture now pins that the backlink shape stays
legal, so the check cannot silently grow over-broad later.

Gates: woc-test 557/0, oop-e2e 118/0 (was 116 — one compile-fail and one run
fixture added), employee 8/0, db-actor 8/0; employee, db-bench, db-actor,
porch, log-watcher and gc-cycle all still typecheck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-26 23:09:40 +02:00
parent 37f7267115
commit e120129f2c
6 changed files with 116 additions and 5 deletions

View file

@ -468,6 +468,14 @@ let private_name_code = Diag.types_prefix ^ "17" (* WO-E217 *)
let use_collision_code = Diag.types_prefix ^ "18" (* WO-E218 *) let use_collision_code = Diag.types_prefix ^ "18" (* WO-E218 *)
let unused_use_code = Diag.warning_prefix ^ "202" (* WO-W202 *) let unused_use_code = Diag.warning_prefix ^ "202" (* WO-W202 *)
let dangling_ref_code = Diag.types_prefix ^ "24"
(* WO-E224 (databasev2 2): a durable table holding a `ref` into a volatile one.
The referencing row survives a restart; the referenced row does not, so the
stored row id dangles and FK-restrict cannot help — restrict asks "does a
row reference this?", and after a restart the answer is a truthful no while
the id is still sitting in a durable slot. Provable from the class table, so
it fails at compile time rather than becoming a wrong query result. *)
let unknown_type_name_code = Diag.types_prefix ^ "25" (* WO-E225 *) let unknown_type_name_code = Diag.types_prefix ^ "25" (* WO-E225 *)
(* iteration 36: a LITERAL shift count outside 0..63 — rejected here so (* iteration 36: a LITERAL shift count outside 0..63 — rejected here so
@ -678,6 +686,19 @@ let rec scalar_name_of (ft : field_ty) : string option =
| Nullable inner -> scalar_name_of inner | Nullable inner -> scalar_name_of inner
| Ref _ | Multi _ | Map _ | Backlink _ | Actor _ -> None | Ref _ | Multi _ | Map _ | Backlink _ | Actor _ -> None
(* databasev2 2: the target class of a `ref` field, through any `?` wrapper.
Only `Ref` stores a row id, which is why this exists and why the
durable/volatile check below looks at nothing else — a `Backlink` is the
computed inverse of a ref and stores NO column (ast.ml), so after a restart
it resolves to an empty collection, which is a legal state indistinguishable
from "nothing references me". Checking backlinks would refuse correct
programs. *)
let rec ref_name_of (ft : field_ty) : string option =
match ft with
| Ref name -> Some name
| Nullable inner -> ref_name_of inner
| Scalar _ | Multi _ | Map _ | Backlink _ | Actor _ -> None
(* Checked once per field declaration (not at every access/use site), so (* Checked once per field declaration (not at every access/use site), so
the diagnostic lands at the field's own declaration position and the diagnostic lands at the field's own declaration position and
never fires more than once for the same bad field. Runs over the raw never fires more than once for the same bad field. Runs over the raw
@ -695,15 +716,42 @@ let check_field_types ~file (syms : symbols) (collector : Diag.Collector.t)
name name
| _ -> Printf.sprintf "unknown type `%s`" name | _ -> Printf.sprintf "unknown type `%s`" name
in in
(* databasev2 2: is this class a table, and is it durable? A non-table
declaring class cannot dangle across a restart because it does not
survive one, so only a durable TABLE is checked. *)
let durable_table (t : Ast.table_cfg option) : bool =
match t with Some cfg -> cfg.Ast.durable | None -> false
in
let volatile_table (name : string) : bool =
match StringMap.find_opt name syms.classes with
| Some ci -> (match ci.table with Some cfg -> not cfg.Ast.durable | None -> false)
| None -> false
in
List.iter (function List.iter (function
| Ast.Class c -> | Ast.Class c ->
List.iter (fun (f : Ast.field) -> List.iter (fun (f : Ast.field) ->
match scalar_name_of f.ty with (match scalar_name_of f.ty with
| Some name when not (is_known_type_name syms name) -> | Some name when not (is_known_type_name syms name) ->
Diag.Collector.add collector
(Diag.error ~code:unknown_type_name_code ~file
~line:f.pos.line ~col:f.pos.col
~message:(unknown_type_msg name) ())
| _ -> ());
(* databasev2 2: WO-E224. Only the durable -> volatile direction is
refused; volatile -> durable is legal (the referencing row is the
one that disappears, so nothing is left holding a stale id). *)
match ref_name_of f.ty with
| Some target when durable_table c.table && volatile_table target ->
Diag.Collector.add collector Diag.Collector.add collector
(Diag.error ~code:unknown_type_name_code ~file (Diag.error ~code:dangling_ref_code ~file
~line:f.pos.line ~col:f.pos.col ~line:f.pos.line ~col:f.pos.col
~message:(unknown_type_msg name) ()) ~message:
(Printf.sprintf
"durable table `%s` cannot hold `ref %s`: `%s` is declared \
`durable: false`, so its rows are gone after a restart and \
this stored row id would dangle — FK restrict cannot catch \
it. Make `%s` durable, or declare `%s` `durable: false` too"
c.name target target target c.name) ())
| _ -> () | _ -> ()
) c.fields ) c.fields
| Ast.Union u -> | Ast.Union u ->

View file

@ -4,7 +4,7 @@ Every `WO-E###`/`WO-W###` code the `woc` front end (`compiler/`) actually
emits, as of plan 2 tasks 2–8, plan 3 tasks 1–2, and the iterations that emits, as of plan 2 tasks 2–8, plan 3 tasks 1–2, and the iterations that
added codes afterwards — 9b (WO-E250), 15/17 (WO-E106–E109), the added codes afterwards — 9b (WO-E250), 15/17 (WO-E106–E109), the
language-surface-strictness branch (WO-E219/E220), the shard-fiber arc language-surface-strictness branch (WO-E219/E220), the shard-fiber arc
(WO-E221/E222), 24 (WO-E226) and 36 (WO-E223). Re-swept against the source (WO-E221/E222), 24 (WO-E226), 36 (WO-E223) and databasev2 2 (WO-E224). Re-swept against the source
2026-08-26; the eight codes that sweep found missing are now in the tables 2026-08-26; the eight codes that sweep found missing are now in the tables
below. Code ranges are reserved below. Code ranges are reserved
per stage (`compiler/src/diag.ml`): `WO-E0xx` lexing, `WO-E1xx` parsing, per stage (`compiler/src/diag.ml`): `WO-E0xx` lexing, `WO-E1xx` parsing,
@ -77,6 +77,7 @@ half of the story ("moved here" / "borrowed here" / etc.).
| WO-E221 | the shard-fiber arc (iteration 8+11). A `spawn C { ... }` whose target class declares no `fn receive(msg: M)`, or declares one whose `M` is not a class, record or union. `receive` is an ordinary identifier, not a keyword — declaring it is what makes a class an actor. | `` `spawn Worker { ... }`: no `fn receive` — an actor is a class with `fn receive(msg: M)` where M is a class, record, or union `` | | WO-E221 | the shard-fiber arc (iteration 8+11). A `spawn C { ... }` whose target class declares no `fn receive(msg: M)`, or declares one whose `M` is not a class, record or union. `receive` is an ordinary identifier, not a keyword — declaring it is what makes a class an actor. | `` `spawn Worker { ... }`: no `fn receive` — an actor is a class with `fn receive(msg: M)` where M is a class, record, or union `` |
| WO-E222 | the shard-fiber arc. A traced (inferred-GC) type, or a type containing one, used as an actor message or as actor state. Aliased object graphs cannot cross shard heaps, and `spawn` placement makes every actor potentially remote, so this is refused statically rather than trapped at the send. | `` message type `Graph` is traced (or contains a traced class) — aliased graphs cannot cross shard heaps; spawn placement makes every actor potentially remote `` | | WO-E222 | the shard-fiber arc. A traced (inferred-GC) type, or a type containing one, used as an actor message or as actor state. Aliased object graphs cannot cross shard heaps, and `spawn` placement makes every actor potentially remote, so this is refused statically rather than trapped at the send. | `` message type `Graph` is traced (or contains a traced class) — aliased graphs cannot cross shard heaps; spawn placement makes every actor potentially remote `` |
| WO-E223 | iteration 36 (operators). A literal shift count outside `0..63` on `<<` or `>>`. A non-literal count is not caught here — the VM traps it at run time (`WOP_SHL`/`WOP_SHR`). | ``shift count is out of range 0..63 for `<<` `` | | WO-E223 | iteration 36 (operators). A literal shift count outside `0..63` on `<<` or `>>`. A non-literal count is not caught here — the VM traps it at run time (`WOP_SHL`/`WOP_SHR`). | ``shift count is out of range 0..63 for `<<` `` |
| WO-E224 | databasev2 2 (2026-08-26). A **durable** table holds a `ref` into a **volatile** one (`durable: false`). The referencing row survives a restart and the referenced row does not, so the stored row id dangles — and FK restrict cannot catch it, because restrict asks "does a row reference this?" and after a restart the honest answer is no while the id is still sitting in a durable slot. Provable from the class table, so it fails at compile time instead of becoming a wrong query result. Reported once, at the field's own declaration. Only this direction is refused: **volatile → durable is legal** (the referencing row is the one that disappears, leaving nothing holding a stale id), and a `backlink` is never checked at all — it stores no column, so after a restart it resolves to an empty collection, a legal state indistinguishable from "nothing references me". Sees through a `?` wrapper. | `` durable table `Order` cannot hold `ref Session`: `Session` is declared `durable: false`, so its rows are gone after a restart and this stored row id would dangle — FK restrict cannot catch it `` |
| WO-E226 | iteration 24 (`call`). `call`'s reply type has to survive actor-`M` erasure, so every `fn receive(msg: M)` program-wide must declare the SAME return type, and in v1 that type must be a copyable scalar. Fires when two `receive` declarations disagree, or when the agreed type is not scalar. | `` `call` on `actor Msg` needs one reply type, but `Room` and `Registry` declare different `receive` returns `` | | WO-E226 | iteration 24 (`call`). `call`'s reply type has to survive actor-`M` erasure, so every `fn receive(msg: M)` program-wide must declare the SAME return type, and in v1 that type must be a copyable scalar. Fires when two `receive` declarations disagree, or when the agreed type is not scalar. | `` `call` on `actor Msg` needs one reply type, but `Room` and `Registry` declare different `receive` returns `` |
| WO-E250 | iteration 9b (the query surface). Every diagnostic the language-integrated query grammar raises, one code: a `from v in C` whose `C` is not a declared table class, a navigation source that is not a `backlink`/`multi` of a table class, and the two not-yet-supported clauses — `group … by … into` on a table query and on a navigation query. The group-by rows are why the clause parses and still cannot run. | ``group-by aggregation is not supported yet`` | | WO-E250 | iteration 9b (the query surface). Every diagnostic the language-integrated query grammar raises, one code: a `from v in C` whose `C` is not a declared table class, a navigation source that is not a `backlink`/`multi` of a table class, and the two not-yet-supported clauses — `group … by … into` on a table query and on a navigation query. The group-by rows are why the clause parses and still cannot run. | ``group-by aggregation is not supported yet`` |
| WO-W202 *(warning)* | haxe-parity Task 1 (modules). A file's own `use` clause is never actually referenced — neither a bare name resolving through it nor a qualified `alias.name(...)` call — anywhere in that file's surviving parse tree. | `` unused `use fs` `` | | WO-W202 *(warning)* | haxe-parity Task 1 (modules). A file's own `use` clause is never actually referenced — neither a bare name resolving through it nor a qualified `alias.name(...)` call — anywhere in that file's surviving parse tree. | `` unused `use fs` `` |

View file

@ -0,0 +1 @@
WO-E224

View file

@ -0,0 +1,20 @@
-- databasev2 2: a durable table may not hold a `ref` into a volatile one.
-- The referencing row survives a restart; the referenced row does not, so
-- the stored row id dangles. FK restrict cannot catch it — restrict asks
-- "does a row reference this?", and after a restart the honest answer is no
-- while the id is still sitting in a durable slot. WO-E224, at the field.
@table(name: "sessions", durable: false)
class Session {
token: Text
}
@table(name: "orders", index: [sess])
class Order {
sess: ref Session
total: Int
}
fn main() -> Int {
return 0;
}

View file

@ -0,0 +1,2 @@
visit scratch
audit kept

View file

@ -0,0 +1,39 @@
-- databasev2 2: the directions that are LEGAL, so WO-E224 cannot quietly
-- grow over-broad. Three shapes must all compile and run:
-- 1. volatile -> durable ref: the referencing row is the one that
-- disappears, so nothing is left holding a stale id.
-- 2. a backlink on a durable table whose referencer is volatile: a
-- backlink stores NO column, so after a restart it resolves to an
-- empty collection — a legal state, not a dangling id.
-- 3. durable -> durable, the ordinary case.
@table(name: "users", index: [name])
class User {
name: Text
visits: backlink Visit.who
}
@table(name: "visits", durable: false, index: [who])
class Visit {
who: ref User
note: Text
}
@table(name: "audits", index: [who])
class Audit {
who: ref User
what: Text
}
fn main() -> Int {
let u = insert User { name: "asha" };
insert Visit { who: u, note: "scratch" };
insert Audit { who: u, what: "kept" };
for v in from x in Visit select x {
print("visit ${v.note}");
}
for a in from x in Audit select x {
print("audit ${a.what}");
}
return 0;
}