diff --git a/Cargo.lock b/Cargo.lock
index 6d8ede2..60aa66e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -208,6 +208,16 @@ dependencies = [
  "wasmprinter",
 ]
 
+[[package]]
+name = "typed-wasm-gate"
+version = "0.1.0"
+dependencies = [
+ "thiserror",
+ "typed-wasm-codegen",
+ "typed-wasm-verify",
+ "wasmi",
+]
+
 [[package]]
 name = "typed-wasm-verify"
 version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index 28cd4e4..a3bfd4f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,4 +4,5 @@ resolver = "2"
 members = [
     "crates/typed-wasm-verify",
     "crates/typed-wasm-codegen",
+    "crates/typed-wasm-gate",
 ]
diff --git a/crates/typed-wasm-gate/Cargo.toml b/crates/typed-wasm-gate/Cargo.toml
new file mode 100644
index 0000000..3500748
--- /dev/null
+++ b/crates/typed-wasm-gate/Cargo.toml
@@ -0,0 +1,25 @@
+# SPDX-License-Identifier: MPL-2.0
+[package]
+name = "typed-wasm-gate"
+version = "0.1.0"
+edition = "2021"
+license = "MPL-2.0"
+description = "Load-time enforcement gate: refuse to instantiate wasm that fails typed-wasm verification (Phase 3 slice)"
+repository = "https://github.com/hyperpolymath/typed-wasm"
+readme = "README.md"
+
+[features]
+default = ["wasmi-runtime"]
+# In-process instantiation adapter backed by wasmi (pure Rust — runs in
+# CI without a system runtime). A wasmtime adapter is the documented
+# follow-up; the gate API is runtime-agnostic by construction.
+wasmi-runtime = ["dep:wasmi"]
+
+[dependencies]
+typed-wasm-verify = { path = "../typed-wasm-verify", features = ["unstable-l2", "unstable-l13-imports"] }
+thiserror = "2"
+wasmi = { version = "1.1.0", optional = true }
+
+[dev-dependencies]
+typed-wasm-codegen = { path = "../typed-wasm-codegen" }
+wasmi = "1.1.0"
diff --git a/crates/typed-wasm-gate/README.md b/crates/typed-wasm-gate/README.md
new file mode 100644
index 0000000..85e1c1b
--- /dev/null
+++ b/crates/typed-wasm-gate/README.md
@@ -0,0 +1,26 @@
+<!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->
+<!-- Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> -->
+# typed-wasm-gate
+
+Load-time enforcement for typed-wasm — the Phase 3 slice (runtime-side
+enforcement). Build-time verification trusts whoever ran the build; the
+gate moves the trust boundary to the **loader**:
+
+```rust
+let verified = typed_wasm_gate::gate_module(&bytes)?;        // full verifier stack
+let instance = wasmi_runtime::instantiate_verified(&engine, &mut store, &linker, &verified)?;
+```
+
+`VerifiedModule` is a witness type: its only constructors are
+`gate_module` / `gate_link_graph`, which run structural validation,
+L7/L10 ownership/linearity, L2 carrier bounds + access typing, and L13
+region-import consistency (plus cross-module `SchemaSub` certification
+for graphs, ADR-0007). The instantiation adapters accept only
+`&VerifiedModule` — a violating module cannot reach a runtime through
+this API because its witness never exists.
+
+The gate itself is runtime-agnostic (bytes in, witness + `GateReport`
+out). The `wasmi-runtime` feature (default) ships a pure-Rust in-process
+adapter that CI executes end-to-end; a **wasmtime adapter** is the
+intended follow-up and needs nothing beyond what the wasmi one uses —
+compile the module from `VerifiedModule::bytes()` and instantiate.
diff --git a/crates/typed-wasm-gate/src/lib.rs b/crates/typed-wasm-gate/src/lib.rs
new file mode 100644
index 0000000..b2f8b0b
--- /dev/null
+++ b/crates/typed-wasm-gate/src/lib.rs
@@ -0,0 +1,163 @@
+// SPDX-License-Identifier: MPL-2.0
+// Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
+//
+//! Load-time enforcement gate — the Phase 3 slice (runtime-side
+//! enforcement, issue #51).
+//!
+//! Build-time verification (`tw build` self-verify, `tw link`) trusts
+//! whoever ran the build. This crate moves the trust boundary to the
+//! LOADER: a [`VerifiedModule`] is a witness type whose only
+//! constructors are [`gate_module`] / [`gate_link_graph`], which run
+//! the full typed-wasm verifier stack over the raw bytes — structural
+//! validation, L7/L10 ownership/linearity, L2 carrier bounds + access
+//! typing, L13 region-import consistency, and (for graphs) cross-module
+//! `SchemaSub` certification. Instantiation adapters accept only
+//! `&VerifiedModule`, so an unverified or violating module cannot reach
+//! a runtime through this crate's API at all.
+//!
+//! The gate is runtime-agnostic: it consumes bytes and returns a
+//! witness + [`GateReport`]. The `wasmi-runtime` feature provides an
+//! in-process adapter (pure Rust, CI-testable); a wasmtime adapter is
+//! the documented follow-up and needs nothing from the gate beyond
+//! what `wasmi::instantiate_verified` already uses.
+
+use thiserror::Error;
+use typed_wasm_verify::{
+    verify_access_sites_from_module, verify_access_typing_from_module, verify_from_module,
+    verify_link_graph, verify_region_imports_from_module, AccessSiteError, CompatCertificate,
+    RegionImportsError, VerifyError,
+};
+
+/// Why the gate refused a module (first failing layer reported).
+#[derive(Debug, Error)]
+pub enum GateError {
+    /// Not decodable as wasm at all, or an L7/L10/L13-negative
+    /// ownership violation (see the inner error).
+    #[error("ownership/linearity verification failed: {0}")]
+    Ownership(#[from] VerifyError),
+
+    /// L2 carrier bounds violations (`typedwasm.regions` /
+    /// `typedwasm.access-sites` internal consistency).
+    #[error("L2 access-site bounds violations: {0:?}")]
+    AccessSites(Vec<AccessSiteError>),
+
+    /// L2 access-typing violations: a pinned instruction is not the
+    /// right memory op at the right offset for its claimed field.
+    #[error("L2 access-typing violations: {0:?}")]
+    AccessTyping(Vec<String>),
+
+    /// L13 region-import violations — module-local inconsistency, or
+    /// (in a link graph) unresolved producers / schema disagreement.
+    #[error("L13 region-import violations: {0:?}")]
+    RegionImports(Vec<RegionImportsError>),
+}
+
+/// What the gate established about a module it passed.
+#[derive(Debug, Clone, Default)]
+pub struct GateReport {
+    /// Pinned access sites whose instruction-level typing was checked.
+    pub typed_sites_checked: u32,
+    /// Declared-only (unpinned) access sites — counted, not checked.
+    pub declared_only_sites: u32,
+    /// Cross-module certificates this module participates in as the
+    /// consumer (link-graph gate only; empty for single-module gates).
+    pub certificates: Vec<CompatCertificate>,
+}
+
+/// Witness that `bytes` passed the gate. The field is private and the
+/// only constructors run the verifier stack — possession of a
+/// `VerifiedModule` IS the proof of verification.
+#[derive(Debug, Clone)]
+pub struct VerifiedModule {
+    bytes: Vec<u8>,
+    report: GateReport,
+}
+
+impl VerifiedModule {
+    pub fn bytes(&self) -> &[u8] {
+        &self.bytes
+    }
+    pub fn report(&self) -> &GateReport {
+        &self.report
+    }
+}
+
+/// Run the single-module verifier stack; a `VerifiedModule` comes back
+/// only if every layer passes.
+pub fn gate_module(bytes: &[u8]) -> Result<VerifiedModule, GateError> {
+    // L7 / L10 / L13-negative (also rejects undecodable bytes).
+    verify_from_module(bytes)?;
+
+    // L2 carrier bounds.
+    let bounds = verify_access_sites_from_module(bytes)?;
+    if !bounds.is_empty() {
+        return Err(GateError::AccessSites(bounds));
+    }
+
+    // L2 access typing on pinned sites.
+    let typing = verify_access_typing_from_module(bytes)?;
+    if !typing.errors.is_empty() {
+        return Err(GateError::AccessTyping(
+            typing.errors.iter().map(|e| e.to_string()).collect(),
+        ));
+    }
+
+    // L13 module-local import-table consistency.
+    let import_errs = verify_region_imports_from_module(bytes)?;
+    if !import_errs.is_empty() {
+        return Err(GateError::RegionImports(import_errs));
+    }
+
+    Ok(VerifiedModule {
+        bytes: bytes.to_vec(),
+        report: GateReport {
+            typed_sites_checked: typing.type_verified,
+            declared_only_sites: typing.declared_only,
+            certificates: Vec::new(),
+        },
+    })
+}
+
+/// Gate a whole link graph: every module passes the single-module gate
+/// AND cross-module schema agreement holds (`verify_link_graph`,
+/// ADR-0007). Returns the witnesses in input order, each consumer's
+/// certificates attached to its report.
+pub fn gate_link_graph(
+    named: &[(&str, &[u8])],
+) -> Result<Vec<(String, VerifiedModule)>, GateError> {
+    let mut gated: Vec<(String, VerifiedModule)> = Vec::new();
+    for (name, bytes) in named {
+        gated.push((name.to_string(), gate_module(bytes)?));
+    }
+    let report = verify_link_graph(named)?;
+    if !report.errors.is_empty() {
+        return Err(GateError::RegionImports(report.errors));
+    }
+    for cert in report.certificates {
+        if let Some((_, module)) = gated.iter_mut().find(|(n, _)| *n == cert.consumer) {
+            module.report.certificates.push(cert);
+        }
+    }
+    Ok(gated)
+}
+
+/// In-process instantiation adapter backed by wasmi. Accepts only the
+/// gate's witness type — this is the enforcement point.
+#[cfg(feature = "wasmi-runtime")]
+pub mod wasmi_runtime {
+    use super::VerifiedModule;
+
+    /// Load + instantiate a verified module in a wasmi store. All
+    /// wasmi-level errors pass through untouched; what this adapter
+    /// adds is the TYPE-LEVEL guarantee that `module` went through the
+    /// gate.
+    pub fn instantiate_verified(
+        engine: &wasmi::Engine,
+        store: &mut wasmi::Store<()>,
+        linker: &wasmi::Linker<()>,
+        module: &VerifiedModule,
+    ) -> Result<wasmi::Instance, wasmi::Error> {
+        let compiled = wasmi::Module::new(engine, module.bytes())?;
+        linker.instantiate_and_start(store, &compiled)
+    }
+}
diff --git a/crates/typed-wasm-gate/tests/gate.rs b/crates/typed-wasm-gate/tests/gate.rs
new file mode 100644
index 0000000..806ddce
--- /dev/null
+++ b/crates/typed-wasm-gate/tests/gate.rs
@@ -0,0 +1,106 @@
+// SPDX-License-Identifier: MPL-2.0
+// Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
+//
+// Load-time enforcement: honest modules gate + instantiate + RUN;
+// violating modules are refused BEFORE any runtime sees them. The
+// producer inputs come from the real front-end (parse .twasm source →
+// emit), so this is the whole pipeline: source → bytes → gate →
+// instantiate → execute.
+
+#![cfg(feature = "wasmi-runtime")]
+
+use typed_wasm_codegen::{emit, parser, Op};
+use typed_wasm_gate::{gate_link_graph, gate_module, wasmi_runtime::instantiate_verified, GateError};
+use wasmi::{Engine, Linker, Store, TypedFunc};
+
+const EX03: &str = include_str!("../../../examples/03-ownership-linearity.twasm");
+const GAME: &str = include_str!("../../typed-wasm-codegen/tests/fixtures/multimodule/game.twasm");
+const ZIG_DOUBLE: &[u8] =
+    include_bytes!("../../typed-wasm-verify/tests/fixtures/zig_producer/zig_double_use.wasm");
+
+/// The honest example-03 module passes the gate and actually runs.
+#[test]
+fn honest_module_gates_and_executes() {
+    let module_ir = parser::parse_module(EX03).expect("ex03 parses");
+    let bytes = emit(&module_ir);
+
+    let verified = gate_module(&bytes).expect("honest module must pass the gate");
+    assert!(verified.report().typed_sites_checked > 0, "gate checked real pinned sites");
+
+    let engine = Engine::default();
+    let mut store = Store::new(&engine, ());
+    let linker = <Linker<()>>::new(&engine);
+    let instance =
+        instantiate_verified(&engine, &mut store, &linker, &verified).expect("instantiates");
+
+    // Run a lowered body end-to-end: read_particle_pos on zeroed memory.
+    let read_pos: TypedFunc<(i32,), f32> =
+        instance.get_typed_func(&store, "read_particle_pos").unwrap();
+    assert_eq!(read_pos.call(&mut store, (0,)).unwrap(), 0.0);
+}
+
+/// A double-free mutant is refused at the gate — there is no way to
+/// hand it to `instantiate_verified` at all (the witness type never
+/// exists), which is the enforcement property.
+#[test]
+fn double_free_mutant_is_refused_at_the_gate() {
+    let mut module_ir = parser::parse_module(EX03).expect("ex03 parses");
+    let despawn = module_ir
+        .funcs
+        .iter()
+        .position(|f| f.name == "despawn_particle")
+        .unwrap();
+    module_ir.funcs[despawn].body =
+        vec![Op::LocalGet(0), Op::Drop, Op::LocalGet(0), Op::Drop];
+    module_ir.funcs[despawn].locals.clear();
+    module_ir.funcs[despawn].accesses.clear();
+    let bytes = emit(&module_ir);
+
+    match gate_module(&bytes) {
+        Err(GateError::Ownership(_)) => {}
+        other => panic!("double-free must be refused at the gate: {other:?}"),
+    }
+}
+
+/// A foreign producer's violating module (the Zig double-use fixture)
+/// is refused the same way — the gate is producer-neutral.
+#[test]
+fn foreign_producer_violation_is_refused() {
+    assert!(matches!(
+        gate_module(ZIG_DOUBLE),
+        Err(GateError::Ownership(_))
+    ));
+}
+
+/// Whole-graph gating: the split game modules pass with certificates
+/// attached to the consumers; a schema-mutant graph is refused.
+#[test]
+fn link_graph_gates_with_certificates_and_refuses_mutants() {
+    let modules = parser::parse_modules(GAME).expect("game.twasm parses");
+    let built: Vec<(String, Vec<u8>)> =
+        modules.iter().map(|(n, m)| (n.clone(), emit(m))).collect();
+    let graph: Vec<(&str, &[u8])> =
+        built.iter().map(|(n, b)| (n.as_str(), b.as_slice())).collect();
+
+    let gated = gate_link_graph(&graph).expect("clean graph passes");
+    let ai = &gated.iter().find(|(n, _)| n == "ai").unwrap().1;
+    assert_eq!(ai.report().certificates.len(), 1);
+    assert_eq!(ai.report().certificates[0].producer, "physics");
+
+    // Mutant: ai expects a type the producer does not export.
+    let mut mutant = parser::parse_modules(GAME).unwrap();
+    mutant[1].1.region_imports[0]
+        .expected_fields
+        .iter_mut()
+        .find(|f| f.name == "flags")
+        .unwrap()
+        .wasm_ty = typed_wasm_verify::WasmTy::F64;
+    let built: Vec<(String, Vec<u8>)> =
+        mutant.iter().map(|(n, m)| (n.clone(), emit(m))).collect();
+    let graph: Vec<(&str, &[u8])> =
+        built.iter().map(|(n, b)| (n.as_str(), b.as_slice())).collect();
+    assert!(matches!(
+        gate_link_graph(&graph),
+        Err(GateError::RegionImports(_))
+    ));
+}
