@@ -67,11 +67,12 @@ pub struct CompiledValue {
6767 /// The inferred type of the compiled program's body (for the caller's
6868 /// `Maybe a` unification check). `None` if it couldn't be determined.
6969 pub ty: Option<String>,
70− /// Relations the snippet declares (`*name : T` in a `with` block), for the
71− /// caller's host-relation check: a snippet may only use relations the host
72− /// program defined. The JIT never runs the generated `main`'s source-init
73− /// (its entry is `knot_user_main`), so these are surfaced statically from
74− /// the snippet's type env rather than observed at runtime.
70+ /// Relations the snippet declares (`*name : T` in a `with` block), as
71+ /// `name\tdescriptor` pairs, for the caller's host-relation check: a
72+ /// snippet may only use relations the host program defined, with a matching
73+ /// shape. The JIT never runs the generated `main`'s source-init (its entry
74+ /// is `knot_user_main`), so these are surfaced statically from the snippet's
75+ /// type env rather than observed at runtime.
7576 pub relations: Vec<String>,
7677}
7778
@@ -196,8 +197,47 @@ pub fn compile_and_run(
196197 // subsumption already ran on real `Ty`s inside `check`.
197198 let body_ty = file_body_type;
198199
199− // Relations the snippet declares (for the host-relation check).
200− let relations: Vec<String> = type_env.source_schemas.keys().cloned().collect();
200+ // Relations the snippet declares, as `name\tdescriptor` pairs (for the
201+ // host's relation check). The descriptor is the canonical structural schema
202+ // string, so the runtime can compare the snippet's declared shape against
203+ // the host's registered schema — not just the name.
204+ let relations: Vec<String> = type_env
205+ .source_schemas
206+ .iter()
207+ .map(|(name, desc)| format!("{name}\t{desc}"))
208+ .collect();
209+
210+ // Validate every declared relation against the host BEFORE running the
211+ // snippet: the snippet's `main` executes effects, so a foreign or
212+ // shape-drifted relation would otherwise panic at the SQL layer mid-run.
213+ // The callback resolves the runtime's registered schema for the name.
214+ unsafe extern "C" {
215+ fn knot_compile_host_rel_matches(
216+ name_ptr: *const u8,
217+ name_len: usize,
218+ desc_ptr: *const u8,
219+ desc_len: usize,
220+ ) -> i32;
221+ }
222+ for (name, desc) in &type_env.source_schemas {
223+ let verdict = unsafe {
224+ knot_compile_host_rel_matches(name.as_ptr(), name.len(), desc.as_ptr(), desc.len())
225+ };
226+ match verdict {
227+ 0 => {}
228+ 1 => {
229+ return Err(CompileError(format!(
230+ "snippet's schema for relation `{name}` doesn't match the host's \
231+ (expected a different shape than the host's `{name}`)"
232+ )))
233+ }
234+ _ => {
235+ return Err(CompileError(format!(
236+ "snippet declares a relation `{name}` the host program doesn't have"
237+ )))
238+ }
239+ }
240+ }
201241
202242 // ── Codegen into a JITModule, on a grown stack ──
203243 // JITModule is not Send, so it can't cross stack::grow's thread boundary.