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

  1. Break the cycle: remove one direction of reference and move the shared predicate into a third table that both filters reference without back-edges.
  2. Denormalize the authorization fact into a single table so only one RLS rule needs the join.
  3. 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

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


AI-assisted analysis of clockworklabs/SpacetimeDB@6dee26c6ef (2026-08-20). Data as JSON: /api/errors/998dfdbb7e7ba65e. Report an issue: GitHub.