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 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 *)
(* 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
| 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
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
@ -695,15 +716,42 @@ let check_field_types ~file (syms : symbols) (collector : Diag.Collector.t)
name
| _ -> Printf.sprintf "unknown type `%s`" name
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
| Ast.Class c ->
List.iter (fun (f : Ast.field) ->
match scalar_name_of f.ty with
| Some name when not (is_known_type_name syms name) ->
(match scalar_name_of f.ty with
| 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.error ~code:unknown_type_name_code ~file
(Diag.error ~code:dangling_ref_code ~file
~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
| 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
added codes afterwards — 9b (WO-E250), 15/17 (WO-E106–E109), the
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
below. Code ranges are reserved
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-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-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-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` `` |

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;
}