clockworklabs/SpacetimeDB · error
module SQL views cannot read a module-restricted table
Error message
module SQL views cannot read a module-restricted table
What it means
SQL views executed from inside module code are compiled into a plan; `run_query_for_view` checks every table in the plan (including non-returned join inputs) against `is_module_restricted_table` before execution. Module-restricted tables are internal tables that module SQL must not read — only the checked env accessor path may reach them. This prevents module SQL views from bypassing access restrictions on internal/system tables.
Solutions
- Rewrite the module SQL/view so it does not reference any module-restricted table; use the checked environment accessor API instead for that data.
- Inspect the view's plan dependencies: remove joins/subqueries that touch restricted tables even if they don't appear in the returned columns.
- Restructure the query so restricted data is exposed only through the module's typed accessor (client-visible table API), then join the results in application code.
- If the data must be queryable, expose it via a normal (non-restricted) table maintained by the module.
Example fix
// before: view joins a module-restricted table let plan = "SELECT u.name, r.internal_data FROM users u JOIN restricted_tbl r ON u.id = r.id"; // after: read restricted data via the checked env accessor, join in module code let restricted = env_accessor.restricted_lookup(id); let plan = "SELECT name FROM users WHERE id = ?"; // then combine in code
Defensive patterns
Strategy: validation
Validate before calling
// before adding a view, verify its sources
// (module-side pseudo-check against plan table ids)
// use spacetimedb_datastore::system_tables::is_module_restricted_table;
// for table in view_source_tables() {
// assert!(!is_module_restricted_table(table), "view reads restricted table");
// } Try / catch
match run_query_for_view(...) {
Ok(rows) => rows,
Err(e) if e.to_string().contains("module-restricted table") => {
// fall back to the checked env accessor and rewrite the query
rewrite_view_without_restricted_tables();
}
Err(e) => return Err(e.into()),
} Prevention
- Never join internal/system tables into module SQL views
- Trace transitive view dependencies — a view over another view can pull in restricted tables
- Expose restricted data only via the checked environment accessor API
When it happens
Trigger: Executing a module SQL view (via `run_query_for_view`) whose plan reads any module-restricted table — directly in the returned rows, in a JOIN input, or through a subquery/view that touches such a table.
Common situations: Writing a SQL view inside a module that joins a regular table with an internal/restricted one; a view referencing another view that transitively reads a restricted table; refactors that silently widen a view to include system tables.
Understand the failure class
Background: Permission denied / not authorized / 403 Forbidden: access-control rejections when the caller lacks the required role, grant, or ownership — this error's family across 18 libraries.
Related errors
- Cannot define RLS rule on private table
- Failed to read value from the
- Invalid system variable
- refusing to connect to private or special-purpose addresses
- a row was a sequence trigger but there was no generated…
AI-assisted analysis of clockworklabs/SpacetimeDB@eddf9f5014 (2026-09-20).
Data as JSON: /api/errors/de30062645c53a04.
Report an issue: GitHub.
Appendix: source
Thrown at crates/core/src/host/wasm_common/module_host_actor.rs:200
let auth = AuthCtx::for_current(database_identity);
let schema_view = SchemaViewer::new(&*tx, &auth);
// Compile to subscription plans.
let (plans, has_params) = SubscriptionPlan::compile(the_query, &schema_view, &auth)?;
ensure!(
!has_params,
"parameterized SQL is not supported for view materialization yet"
);
// Validate shape and disallow views-on-views.
for plan in &plans {
// This SQL originates in module code, not an authenticated external
// query. Check every source, including non-returned join inputs, before
// any plan executes. The checked env accessor is the only module path.
ensure!(
!plan
.table_ids()
.any(spacetimedb_datastore::system_tables::is_module_restricted_table),
"module SQL views cannot read a module-restricted table"
);
let Some(source_schema) = plan.return_table() else {
bail!("query does not return plain table rows");
};
if plan.reads_from_view(true) || plan.reads_from_view(false) {
bail!("view definition cannot read from other views");
}
if source_schema.row_type != *expected_row_type {
bail!(
"query returns `{}` but view expects `{}`",
fmt_algebraic_type(&AlgebraicType::Product(source_schema.row_type.clone())),
fmt_algebraic_type(&AlgebraicType::Product(expected_row_type.clone())),
);
}
}
let op = FuncCallType::View(call_info.clone());View on GitHub (pinned to eddf9f5014)