clockworklabs/SpacetimeDB · error · anyhow::Error
Discovered cyclic dependency when resolving RLS rules for ta
Error message
Discovered cyclic dependency when resolving RLS rules for table id `{table_id}` What it means
RLS rules are SQL filters that may reference other tables. The resolver keeps a set of tables currently being resolved; if resolving a table's filter requires a table already on that stack (directly or transitively), a reference cycle exists and resolution aborts with this error.
Source
Thrown at crates/expr/src/rls.rs:237
let mut names = vec![];
view.visit(&mut |expr| match expr {
RelExpr::RelVar(rhs)
| RelExpr::LeftDeepJoin(LeftDeepJoin { rhs, .. })
| RelExpr::EqJoin(LeftDeepJoin { rhs, .. }, ..)
if !is_return_table(rhs) =>
{
names.push((rhs.schema.table_id, rhs.alias.clone()));
}
_ => {}
});
// Are we currently resolving any of them?
if let Some(table_id) = names
.iter()
.map(|(table_id, _)| table_id)
.find(|table_id| resolving.contains(table_id))
{
anyhow::bail!("Discovered cyclic dependency when resolving RLS rules for table id `{table_id}`");
}
let return_name = |expr: &ProjectName| {
expr.return_name()
.map(|name| name.to_owned())
.ok_or_else(|| anyhow::anyhow!("Could not resolve table reference in RLS filter"))
};
let mut view_def_fragments = vec![];
for (table_id, alias) in names {
let mut view_fragments = vec![];
for sql in tx.rls_rules_for_table(table_id)? {
// Parse and type check the RLS filter
let (expr, is_parameterized) = parse_and_type_sub(&sql, tx, auth)?;
// Are any of the RLS rules parameterized?View on GitHub (pinned to 6dee26c6ef)
Solutions
- Break the cycle: remove one direction of reference and move the shared predicate into a third table that both filters reference without back-edges.
- Denormalize the authorization fact into a single table so only one RLS rule needs the join.
- Do the authorization check inside a reducer for one of the tables instead of declaring RLS on it.
Example fix
-- before: mutual RLS cycle (a -> b, b -> a) CREATE TABLE a ... WITH ROW LEVEL SECURITY (SELECT ... FROM b WHERE b.owner = :sender); CREATE TABLE b ... WITH ROW LEVEL SECURITY (SELECT ... FROM a WHERE a.owner = :sender); -- after: single direction, or flatten ownership into one table CREATE TABLE a ... WITH ROW LEVEL SECURITY (owner = :sender); CREATE TABLE b ...; -- authorized via reducer writes
Defensive patterns
Strategy: validation
Validate before calling
-- before enabling RLS, sketch the reference graph of all rules: -- rule on A references B?, rule on B references A?, views chain back? -- reject any rule whose referenced set reaches back to its own table -- (simple check while reviewing the module source)
Try / catch
match publish(&db, &module).await {
Err(e) if e.to_string().contains("cyclic dependency") => {
// remove one direction of the A<->B reference, republish
}
other => other,
} Prevention
- Draw the RLS reference graph before adding a rule that joins another table.
- Keep RLS filters referencing at most one level of helper table, never back-edges.
- Centralize authorization facts in a single table instead of mutual rules.
When it happens
Trigger: Two or more RLS rules that reference each other's tables: table A's filter joins B, and B's filter (directly or through a view chain) joins back to A.
Common situations: Mutual-authorization patterns ('row visible if a linked row in the other table exists') declared as RLS on both tables; RLS filters joining through views that loop back to the origin table.
Related errors
- Cannot define RLS rule on private table: {}. Please make tab
- Could not determine return type during RLS resolution
- Failed to read value from the `{}` column of `{}` for table_
- AddRowLevelSecurity: RLS `{sql_rls}` not found in new module
- `table_id` must not be `TableId::SENTINEL` in `{row_level_se
AI-assisted analysis of clockworklabs/SpacetimeDB@6dee26c6ef (2026-08-20).
Data as JSON: /api/errors/998dfdbb7e7ba65e.
Report an issue: GitHub.