diff --git a/crates/typed-wasm-codegen/README.md b/crates/typed-wasm-codegen/README.md
index 139946b..2da32a4 100644
--- a/crates/typed-wasm-codegen/README.md
+++ b/crates/typed-wasm-codegen/README.md
@@ -52,7 +52,7 @@ cargo test -p typed-wasm-codegen
 | WAT (text) emission | **emitted** via `--emit wat\|both` (#125) |
 | `typedwasm.ownership` (L7/L10) | **emitted** for any `own`/`&mut`/`&` source discipline (parser-recorded, incl. Linear returns) and for the multi-module callee (#128) |
 | Multi-module (linear boundary) | **emitted** — callee export + caller import round-trip through `verify_cross_module` (#128) |
-| Function-body lowering | **real statement lowering** — `let`, assignment, `if`/`else`, `while`, indexed `region.get`/`region.set`, `cast<>` (wasmi-executed, `tests/example04.rs`); `region.scan` and `opt<T>` unwraps still stub |
+| Function-body lowering | **real statement lowering** — `let`, assignment, `if`/`else`, `while`, indexed access, `cast<>`, `region.scan … -> \|e\| { … }` with `where` predicates, `is_null`/`opt<T>` (v0 null = 0) — wasmi-executed (`tests/example04.rs`, `tests/scan_lowering.rs`); remaining stubs: handle-typed locals, embedded-region paths |
 
 ## Where this goes next
 
diff --git a/crates/typed-wasm-codegen/src/lib.rs b/crates/typed-wasm-codegen/src/lib.rs
index ab45448..315365b 100644
--- a/crates/typed-wasm-codegen/src/lib.rs
+++ b/crates/typed-wasm-codegen/src/lib.rs
@@ -33,8 +33,10 @@
 //!   and `emit` writes the carrier (exercised by `tests/example03.rs`).
 //! * Function bodies lower for real where the statement lowerer covers
 //!   them (`let`, assignment, `if`/`else`, `while`, indexed access,
-//!   `cast<>` — see `parser.rs`); `region.scan` closures and `opt<T>`
-//!   null-checks still fall back to type-correct representative stubs.
+//!   `cast<>`, `region.scan` closures with `where` predicates,
+//!   `is_null` under the v0 null-is-zero convention — see `parser.rs`);
+//!   handle-typed locals and embedded-region field paths still fall
+//!   back to type-correct representative stubs.
 
 use typed_wasm_verify::section::{
     build_access_sites_section_payload, AccessSiteEntry, ACCESS_SITE_UNPINNED, NO_TARGET_REGION,
@@ -257,6 +259,8 @@ pub enum Op {
     BrIf(u32),
     Return,
     I32Eqz,
+    I32And,
+    I32Or,
     I32Add,
     I32Sub,
     I32Mul,
@@ -472,6 +476,8 @@ fn op_to_instruction(op: Op) -> Instruction<'static> {
         Op::BrIf(d) => Instruction::BrIf(d),
         Op::Return => Instruction::Return,
         Op::I32Eqz => Instruction::I32Eqz,
+        Op::I32And => Instruction::I32And,
+        Op::I32Or => Instruction::I32Or,
         Op::I32Add => Instruction::I32Add,
         Op::I32Sub => Instruction::I32Sub,
         Op::I32Mul => Instruction::I32Mul,
diff --git a/crates/typed-wasm-codegen/src/parser.rs b/crates/typed-wasm-codegen/src/parser.rs
index 60a695e..8450c39 100644
--- a/crates/typed-wasm-codegen/src/parser.rs
+++ b/crates/typed-wasm-codegen/src/parser.rs
@@ -37,6 +37,13 @@ struct Parser<'a> {
     funcs: Vec<crate::Func>,
     ownership: Vec<(usize, Vec<crate::Ownership>, crate::Ownership)>,
     region_imports: Vec<typed_wasm_verify::RegionImportEntry>,
+    /// Declared instance count per region (`region Name[N]`; 1 if bare).
+    /// Parallel to `regions`. Bounds `region.scan` loops and sizes the
+    /// synthesised memory.
+    region_instances: Vec<u64>,
+    /// True when the source declared `memory { … }` — a synthesised
+    /// memory must never override it.
+    memory_declared: bool,
 }
 
 impl<'a> Parser<'a> {
@@ -51,6 +58,8 @@ impl<'a> Parser<'a> {
             funcs: Vec::new(),
             ownership: Vec::new(),
             region_imports: Vec::new(),
+            region_instances: Vec::new(),
+            memory_declared: false,
         }
     }
 
@@ -177,11 +186,12 @@ impl<'a> Parser<'a> {
         let name = self.parse_ident();
         self.skip_whitespace();
 
-        // Parse optional array specifier: region Name[N]. We don't yet track
-        // region cardinality, so the parsed size is discarded.
+        // Parse optional array specifier: region Name[N] — the declared
+        // instance count (bounds `region.scan`, sizes synthesised memory).
+        let mut instances = 1u64;
         if self.peek_char('[') {
             self.expect("[")?;
-            let _n: u64 = self.parse_number()?;
+            instances = self.parse_number()?.max(1);
             self.expect("]")?;
         }
 
@@ -273,6 +283,7 @@ impl<'a> Parser<'a> {
             fields,
             byte_size,
         });
+        self.region_instances.push(instances);
 
         self.skip_whitespace();
         Ok(())
@@ -510,6 +521,7 @@ impl<'a> Parser<'a> {
     }
 
     fn parse_memory(&mut self) -> Result<(), String> {
+        self.memory_declared = true;
         self.expect("memory")?;
         let _name = self.parse_ident();
         self.skip_whitespace();
@@ -1022,12 +1034,21 @@ impl<'a> Parser<'a> {
     /// cannot point past the declared memory and trap at runtime. Only fills in
     /// a memory when the module declared none; a declared memory is the author's.
     fn ensure_memory_for_region(&mut self, region: usize) {
-        if self.memory.is_none() {
-            let bytes = self.regions.get(region).map_or(0, |r| r.byte_size as u64);
-            self.memory = Some(Memory {
-                min_pages: bytes.div_ceil(65536).max(1),
-                max_pages: None,
-            });
+        if self.memory_declared {
+            return; // never override a source-declared memory
+        }
+        let bytes = self.regions.get(region).map_or(0, |r| r.byte_size as u64)
+            * self.region_instances.get(region).copied().unwrap_or(1);
+        let pages = bytes.div_ceil(65536).max(1);
+        match &mut self.memory {
+            Some(m) if m.min_pages >= pages => {}
+            Some(m) => m.min_pages = pages,
+            None => {
+                self.memory = Some(Memory {
+                    min_pages: pages,
+                    max_pages: None,
+                })
+            }
         }
     }
 
@@ -1452,6 +1473,10 @@ struct Lower {
     accesses: Vec<crate::AccessSite>,
     /// Regions touched — memory is synthesised only on successful lowering.
     used_regions: Vec<usize>,
+    /// Active `region.scan` contexts, innermost last: (region index,
+    /// slot of the current-instance address local). Bare idents in scan
+    /// predicates/bodies resolve to fields of the innermost scan region.
+    scan_stack: Vec<(usize, u32)>,
 }
 
 impl Lower {
@@ -1481,6 +1506,7 @@ impl<'a> Parser<'a> {
             ops: Vec::new(),
             accesses: Vec::new(),
             used_regions: Vec::new(),
+            scan_stack: Vec::new(),
         };
         // Prologue: copy each region param into a base local (its single
         // use), bind scalar params by name.
@@ -1542,7 +1568,10 @@ impl<'a> Parser<'a> {
             if self.try_keyword("set") {
                 return self.lower_region_set(cx).map(|_| false);
             }
-            return None; // alloc / free / scan / place: unsupported here
+            if self.try_keyword("scan") {
+                return self.lower_region_scan(cx, results).map(|_| false);
+            }
+            return None; // alloc / free / place: unsupported here
         }
         if self.try_keyword("let") {
             self.try_keyword("mut");
@@ -1729,12 +1758,84 @@ impl<'a> Parser<'a> {
         Some(())
     }
 
-    // --- Typed expression lowering (precedence: cmp > add > mul > primary).
+    /// `region.scan $h [where <pred>] -> |e| { body }` — a bounded loop
+    /// over the region's declared instances. Bare idents in the
+    /// predicate resolve to fields of the scanned region; `$e` in the
+    /// body is the current instance's handle.
+    fn lower_region_scan(&mut self, cx: &mut Lower, results: &[Wty]) -> Option<()> {
+        if !self.try_char('$') {
+            return None;
+        }
+        let hname = self.parse_ident();
+        let &(region, base) = cx.handles.get(&hname)?;
+        let instances = i32::try_from(*self.region_instances.get(region)?).ok()?;
+        let stride = i32::try_from(self.regions.get(region)?.byte_size).ok()?;
+
+        let i_slot = cx.alloc(Wty::I32);
+        let addr_slot = cx.alloc(Wty::I32);
+
+        // i = 0; block { loop { i < N eqz br_if 1; addr = base + i*stride; …
+        cx.ops.push(crate::Op::I32Const(0));
+        cx.ops.push(crate::Op::LocalSet(i_slot));
+        cx.ops.push(crate::Op::Block);
+        cx.ops.push(crate::Op::Loop);
+        cx.ops.push(crate::Op::LocalGet(i_slot));
+        cx.ops.push(crate::Op::I32Const(instances));
+        cx.ops.push(crate::Op::I32LtS);
+        cx.ops.push(crate::Op::I32Eqz);
+        cx.ops.push(crate::Op::BrIf(1));
+        cx.ops.push(crate::Op::LocalGet(base));
+        cx.ops.push(crate::Op::LocalGet(i_slot));
+        cx.ops.push(crate::Op::I32Const(stride));
+        cx.ops.push(crate::Op::I32Mul);
+        cx.ops.push(crate::Op::I32Add);
+        cx.ops.push(crate::Op::LocalSet(addr_slot));
+
+        cx.scan_stack.push((region, addr_slot));
+
+        let has_pred = self.try_keyword("where");
+        if has_pred {
+            if self.lower_expr(cx, None)? != Wty::I32 {
+                cx.scan_stack.pop();
+                return None;
+            }
+            cx.ops.push(crate::Op::If);
+        }
+
+        let ok = (|| {
+            if !self.try_str("->") || !self.try_char('|') {
+                return None;
+            }
+            let ename = self.parse_ident();
+            if ename.is_empty() || !self.try_char('|') || !self.try_char('{') {
+                return None;
+            }
+            cx.handles.insert(ename, (region, addr_slot));
+            self.lower_block(cx, results)?;
+            Some(())
+        })();
+        cx.scan_stack.pop();
+        ok?;
+
+        if has_pred {
+            cx.ops.push(crate::Op::End);
+        }
+        cx.ops.push(crate::Op::LocalGet(i_slot));
+        cx.ops.push(crate::Op::I32Const(1));
+        cx.ops.push(crate::Op::I32Add);
+        cx.ops.push(crate::Op::LocalSet(i_slot));
+        cx.ops.push(crate::Op::Br(0));
+        cx.ops.push(crate::Op::End);
+        cx.ops.push(crate::Op::End);
+        Some(())
+    }
+
+    // --- Typed expression lowering (precedence: cmp > band > add > mul > primary).
     // `expected` seeds literal typing; operands of a binary op must agree
     // exactly (no implicit promotion — a mismatch bails the whole body).
 
     fn lower_expr(&mut self, cx: &mut Lower, expected: Option<Wty>) -> Option<Wty> {
-        let lty = self.lower_add(cx, expected)?;
+        let lty = self.lower_band(cx, expected)?;
         self.skip_whitespace();
         let cmp = if self.try_str("==") {
             "=="
@@ -1753,7 +1854,7 @@ impl<'a> Parser<'a> {
         } else {
             return Some(lty);
         };
-        if self.lower_add(cx, Some(lty))? != lty {
+        if self.lower_band(cx, Some(lty))? != lty {
             return None;
         }
         use crate::Op::*;
@@ -1781,13 +1882,39 @@ impl<'a> Parser<'a> {
         Some(Wty::I32)
     }
 
+    /// Bitwise tier (`&`, `|`) between comparison and additive — i32
+    /// only. The examples parenthesise (`(flags & 1) == 1`), so the
+    /// C-precedence footgun does not arise.
+    fn lower_band(&mut self, cx: &mut Lower, expected: Option<Wty>) -> Option<Wty> {
+        let ty = self.lower_add(cx, expected)?;
+        loop {
+            self.skip_whitespace();
+            let and = match self.src.as_bytes().get(self.pos) {
+                Some(&b'&') if self.src.as_bytes().get(self.pos + 1) != Some(&b'&') => true,
+                Some(&b'|') if self.src.as_bytes().get(self.pos + 1) != Some(&b'|') => false,
+                _ => return Some(ty),
+            };
+            if ty != Wty::I32 {
+                return None;
+            }
+            self.pos += 1;
+            if self.lower_add(cx, Some(Wty::I32))? != Wty::I32 {
+                return None;
+            }
+            cx.ops.push(if and { crate::Op::I32And } else { crate::Op::I32Or });
+        }
+    }
+
     fn lower_add(&mut self, cx: &mut Lower, expected: Option<Wty>) -> Option<Wty> {
         let ty = self.lower_mul(cx, expected)?;
         loop {
             self.skip_whitespace();
             let op = match self.src.as_bytes().get(self.pos) {
                 Some(&b'+') => crate::Op::I32Add,
-                Some(&b'-') => crate::Op::I32Sub,
+                // `->` is the scan/get arrow, never subtraction.
+                Some(&b'-') if self.src.as_bytes().get(self.pos + 1) != Some(&b'>') => {
+                    crate::Op::I32Sub
+                }
                 _ => return Some(ty),
             };
             let plus = matches!(op, crate::Op::I32Add);
@@ -1839,6 +1966,15 @@ impl<'a> Parser<'a> {
     fn lower_primary(&mut self, cx: &mut Lower, expected: Option<Wty>) -> Option<Wty> {
         self.skip_whitespace();
         let &b = self.src.as_bytes().get(self.pos)?;
+        if b == b'!' && self.src.as_bytes().get(self.pos + 1) != Some(&b'=') {
+            // Logical not on an i32 truth value.
+            self.pos += 1;
+            if self.lower_primary(cx, Some(Wty::I32))? != Wty::I32 {
+                return None;
+            }
+            cx.ops.push(crate::Op::I32Eqz);
+            return Some(Wty::I32);
+        }
         if b == b'(' {
             self.pos += 1;
             let ty = self.lower_expr(cx, expected)?;
@@ -1864,6 +2000,20 @@ impl<'a> Parser<'a> {
                 cx.ops.push(crate::Op::I32Const(0));
                 return Some(Wty::I32);
             }
+            "is_null" => {
+                // v0 null representation: 0 — matching wasm's null
+                // pointer convention (`opt<T>` carries `Nullability` in
+                // the regions carrier; a richer representation is an L4
+                // follow-up). `is_null(x)` = `x == 0`.
+                if !self.try_char('(') {
+                    return None;
+                }
+                if self.lower_expr(cx, Some(Wty::I32))? != Wty::I32 || !self.try_char(')') {
+                    return None;
+                }
+                cx.ops.push(crate::Op::I32Eqz);
+                return Some(Wty::I32);
+            }
             "cast" => {
                 if !self.try_char('<') {
                     return None;
@@ -1892,8 +2042,23 @@ impl<'a> Parser<'a> {
             cx.ops.push(crate::Op::LocalGet(slot));
             return Some(wty);
         }
+        // Inside a `region.scan`, a bare ident is a field of the scanned
+        // region — a typed load off the current-instance address.
+        if let Some(&(region, addr)) = cx.scan_stack.last() {
+            if let Some((field_idx, offset, scalar)) = self.resolve_field(region, &ident) {
+                cx.ops.push(crate::Op::LocalGet(addr));
+                let load_at = cx.ops.len();
+                cx.ops.push(scalar_load_op(&scalar, offset as u64));
+                cx.accesses.push(crate::AccessSite {
+                    region,
+                    field: field_idx,
+                    instr_index: Some(load_at),
+                });
+                return Some(wty_from_scalar(&scalar));
+            }
+        }
         self.pos = save;
-        None // unknown ident: is_null / sizeof / handle-as-value / call
+        None // unknown ident: sizeof / handle-as-value / call
     }
 
     /// Numeric literal, typed by `expected` (default: `.`-bearing → F32,
diff --git a/crates/typed-wasm-codegen/tests/scan_lowering.rs b/crates/typed-wasm-codegen/tests/scan_lowering.rs
new file mode 100644
index 0000000..ed75561
--- /dev/null
+++ b/crates/typed-wasm-codegen/tests/scan_lowering.rs
@@ -0,0 +1,134 @@
+// SPDX-License-Identifier: MPL-2.0
+// Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
+//
+// `region.scan` closure lowering + `is_null` (v0 null = 0) — the last
+// statement forms that previously fell back to representative stubs.
+// Scans lower to a bounded loop over the region's declared instances
+// with the predicate's bare field idents resolving to typed loads off
+// the current-instance address; the closure handle `$e` binds that
+// address for the body.
+
+use typed_wasm_codegen::{emit, parser};
+use typed_wasm_verify::{
+    verify_access_sites_from_module, verify_access_typing_from_module, verify_from_module,
+};
+use wasmi::{Engine, Linker, Module as WasmiModule, Store, TypedFunc};
+
+const EX01: &str = include_str!("../../../examples/01-single-module.twasm");
+const EX02: &str = include_str!("../../../examples/02-multi-module.twasm");
+
+/// The scan-shaped bodies across examples 01/02 lower for real.
+#[test]
+fn scan_bodies_lower_for_real() {
+    let ex01 = parser::parse_module(EX01).expect("ex01 parses");
+    let ex02 = parser::parse_module(EX02).expect("ex02 parses");
+    for (module, name, min_sites) in [
+        (&ex01, "count_active_enemies", 1), // pred load: is_active
+        (&ex02, "physics_step", 7),         // pred + 6 gets + 3 sets
+        (&ex02, "collect_visible", 1),      // pred load: flags
+        (&ex02, "ai_decision", 8),          // opt unwrap + indexed get/set
+    ] {
+        let f = module
+            .funcs
+            .iter()
+            .find(|f| f.name == name)
+            .unwrap_or_else(|| panic!("{name} must be parsed"));
+        assert!(
+            f.accesses.len() >= min_sites,
+            "{name}: expected >={min_sites} sites, got {}",
+            f.accesses.len()
+        );
+        assert!(
+            f.accesses.iter().all(|a| a.instr_index.is_some()),
+            "{name}: every site must be pinned"
+        );
+    }
+}
+
+/// Full verifier stack over the scan-lowered examples.
+#[test]
+fn scan_lowered_examples_pass_all_verifier_passes() {
+    for (name, src) in [("01", EX01), ("02", EX02)] {
+        let module = parser::parse_module(src).unwrap_or_else(|e| panic!("ex{name}: {e}"));
+        let bytes = emit(&module);
+        wasmparser::Validator::new()
+            .validate_all(&bytes)
+            .unwrap_or_else(|e| panic!("ex{name} must validate: {e}"));
+        verify_from_module(&bytes).unwrap_or_else(|e| panic!("ex{name} L7/L10: {e}"));
+        let bounds = verify_access_sites_from_module(&bytes).unwrap();
+        assert!(bounds.is_empty(), "ex{name} L2 bounds: {bounds:?}");
+        let typing = verify_access_typing_from_module(&bytes).unwrap();
+        assert!(typing.errors.is_empty(), "ex{name} L2 typing: {:?}", typing.errors);
+    }
+}
+
+/// Execution gate: a where-predicated scan must visit exactly the
+/// matching instances, and `is_null` must implement the v0 null = 0
+/// convention.
+const CELLS: &str = r#"
+    region Cell[8] {
+        flag: u32;
+        v: i32;
+    }
+    memory mem { initial: 1; }
+
+    fn set_cell(p: &mut region<Cell>, i: i32, flag: u32, v: i32) {
+        region.set $p[i] .flag, flag;
+        region.set $p[i] .v, v;
+    }
+
+    fn sum_flagged(p: &region<Cell>) -> i32 {
+        let mut acc: i32 = 0;
+        region.scan $p where (flag & 1) == 1 -> |c| {
+            region.get $c .v -> x;
+            acc = acc + x;
+        }
+        return acc;
+    }
+
+    fn non_null(x: i32) -> i32 {
+        if !is_null(x) {
+            return 1;
+        }
+        return 0;
+    }
+"#;
+
+#[test]
+fn scan_and_is_null_execute_with_correct_semantics() {
+    let module_ir = parser::parse_module(CELLS).expect("Cells fixture parses");
+    for name in ["set_cell", "sum_flagged", "non_null"] {
+        let f = module_ir.funcs.iter().find(|f| f.name == name).unwrap();
+        assert!(
+            name == "non_null" || !f.accesses.is_empty(),
+            "{name} must lower for real (stub has no access sites)"
+        );
+    }
+
+    let bytes = emit(&module_ir);
+    let engine = Engine::default();
+    let module = WasmiModule::new(&engine, &bytes[..]).expect("loads into wasmi");
+    let mut store = Store::new(&engine, ());
+    let instance = <Linker<()>>::new(&engine)
+        .instantiate_and_start(&mut store, &module)
+        .expect("instantiate");
+
+    let set_cell: TypedFunc<(i32, i32, i32, i32), ()> =
+        instance.get_typed_func(&store, "set_cell").unwrap();
+    let sum_flagged: TypedFunc<(i32,), i32> =
+        instance.get_typed_func(&store, "sum_flagged").unwrap();
+    let non_null: TypedFunc<(i32,), i32> =
+        instance.get_typed_func(&store, "non_null").unwrap();
+
+    const BASE: i32 = 0;
+    // Instances 0..3 populated; 4..7 stay zero (flag 0 -> skipped).
+    set_cell.call(&mut store, (BASE, 0, 1, 10)).unwrap(); // flagged
+    set_cell.call(&mut store, (BASE, 1, 2, 20)).unwrap(); // even flag: skipped
+    set_cell.call(&mut store, (BASE, 2, 3, 30)).unwrap(); // flagged (3 & 1 == 1)
+    set_cell.call(&mut store, (BASE, 3, 0, 40)).unwrap(); // unflagged: skipped
+
+    assert_eq!(sum_flagged.call(&mut store, (BASE,)).unwrap(), 40, "10 + 30");
+
+    assert_eq!(non_null.call(&mut store, (0,)).unwrap(), 0, "0 is null");
+    assert_eq!(non_null.call(&mut store, (7,)).unwrap(), 1, "non-zero is not null");
+}
