What's happening
Base44 RLS rules drift out of sync with code after AI builder edits because the builder treats schema, queries, and row-level security rules as three separate concerns and only updates whichever surfaces appear in the prompt. Adding a tenant_id field to a table will update the schema and most read paths, but the existing Read rule that compared org_id is left untouched. The bug is silent — a denied list() or filter() returns an empty result, not an error — so monitoring catches nothing and the first symptom is usually a user seeing another tenant's data weeks later. Fix it by recording every entity's security rules, building a coverage matrix that diffs the fields each rule compares against every base44.entities call site, writing a per-role smoke test that asserts allowed-record counts and denied-record counts, and gating the suite as a deploy blocker on every AI-builder commit.
A user logs in. They see a dashboard with records belonging to another tenant. Or the opposite — they see an empty list when they should see fifty rows. There is no error in your logs. The SDK call returned successfully. The data is in the database. Something between the database and the user is filtering wrongly.
This is the silent-failure mode unique to AI-builder platforms: the AI builder edited your schema, your queries, and your forms, but it did not edit the row-level security rule that gates the data. The rule and the code are now out of sync. Every read goes through a stale rule, and the rule either lets too much through or too little. Either way, no error fires.
This is a new class of bug. Traditional teams using hand-written SQL migrations have reviewers who catch policy drift in pull requests. Teams using AI builders have no review surface — the agent shipped the change directly. Base44's AI builder can add a tenant_id field to a table, rewrite every read query to filter on it, generate a new admin form, and leave the existing Read rule comparing the now-deprecated org_id field. The schema migrates. The queries migrate. The rule does not. The result is a permission bug that lives in production until a human notices.
Because nothing errors, drift can go unnoticed through several AI-builder turns, and each turn compounds it.
Why AI builders create RLS drift
The AI builder operates on the surfaces it sees in your prompt. When you ask it to "add multi-tenant support to the orders table," it interprets that as a schema-and-code task. It reads the table definition, adds a field, finds every read in the codebase, rewrites the filters, regenerates the form, and reports success. The regeneration is impressive. It is also incomplete.
The security rules live in a different surface from the code that queries the data: each table's Permissions page in the dashboard, backed by the rls block of the entity schema (Managing data permissions and entity security, both read 17 Oct 2026). The agent does not always pull that surface into context. Even when it does, nothing in the edit runs the rule as a test user to verify it is correct. There is no execution feedback loop for RLS the way there is for compilation errors or failed tests. The agent emits whatever rule looks plausible from the prompt and moves on.
Three structural reasons compound the problem.
First, RLS evaluation is silent. A failed type-check produces an error the agent can read and correct. A denied read produces an empty result the agent has no way to interpret. Without a signal, there is no correction.
Second, RLS lives outside the code surface. The AI builder's training data is dominated by application code where the security check is colocated with the query (decorators, middleware, ORM hooks). Base44's model separates them: the rule belongs to the entity, not to the call. The agent's instinct from training is "the security check is right there in the function" — but in Base44 the check is in the entity's security rules, which the agent may not be inspecting on this turn.
Third, AI builders optimize for visible progress. The user prompted for "multi-tenant support" and the agent has already produced a schema migration, a form, three new queries, and a passing build. From the agent's reward signal, the task looks done. Surfacing "by the way, you also need to update the Read rule on the Order table, here is the new rule" is not in its training distribution.
The net effect: any AI-builder feature addition that touches a new table or a new tenant boundary can leave the security rules behind. The drift is silent. The bug is real. And every additional AI-builder turn makes the divergence harder to trace.
The 6 silent-failure patterns to check
Each pattern below has a specific signature, a specific cause, and a specific way to detect it on Base44.
Pattern 1: New reference field, the rule still compares the old one.
- Symptom: Zero rows returned to a user who should see many. Admin view shows the rows exist.
- Cause: AI builder added
team_idand migrated reads, but the table's Read rule still comparesdata.org_idwith{{user.data.org_id}}. - Detect: List every field on the table that holds a tenant, team or owner reference, then list every field the table's rules compare. Any reference field not in any rule is a candidate orphan.
Pattern 2: New table, left on All Users.
- Symptom: Cross-tenant data leakage. Users see other users' records.
- Cause: AI builder created the table with its Read rule on All Users, which Base44 defines as "Anyone can perform this action, even without signing in" (Managing data permissions, read 17 Oct 2026), and never came back to lock it down.
- Detect: On the Data page, open each table's Permissions page and list every table whose Read rule is All Users. Check what each one holds; any that holds personal data is your unguarded surface. A Permission risks detected banner on the table, or a security scan from the Security tab, can flag the same tables.
Pattern 3: The rule compares a user field that is no longer populated.
- Symptom: Some users see their records and others see none, for example accounts created after the AI edit.
- Cause: The rule compares
data.tenant_idwith{{user.data.tenant_id}}, but the AI builder moved tenancy to a new User field (org_id) and only populates that one. - Detect: List every
{{user.data.*}}field your rules reference and check, on real records in the User table, that each one is populated. Test what a user with an empty field sees rather than assume.
Pattern 4: Write path bypasses the Read rule.
- Symptom: Users can write records they cannot subsequently read. Records appear orphaned.
- Cause: The Create rule is more permissive than the Read rule. AI builder relaxed Create to ship a new form and did not touch Read, or the new form does not write the field the Read rule compares.
- Detect: For every table, compare the Create rule with the Read rule. Asymmetry is a flag, not always a bug, but worth checking.
Pattern 5: A field was renamed in the schema but not in the rule.
- Symptom: All queries to the table return zero rows. Admin view works.
- Cause: Schema rename succeeded; the rule still names the old field, so it no longer matches the records your code writes, and a denied read returns an empty result rather than an error.
- Detect: Check every field name in the rules against the table's current fields (Dashboard, then the table under Data). Base44's docs say a rule's Entity field must be "a field that already exists on this data table", typed "exactly as it appears" (Managing data permissions, read 17 Oct 2026). Then confirm with Test Permissions in preview.
Pattern 6: Role expansion not reflected in the rules.
- Symptom: New role (e.g., "billing") gets blocked from records they should access.
- Cause: AI builder added a new role value, but every existing User Property Check rule names the allowed roles and does not include the new one. Base44's built-in User Role is Admin or User; anything finer is a custom User field such as
access_level, checked with User Property Check (Managing data permissions, read 17 Oct 2026). - Detect: List the values your role field takes in the User table. For each value, find the rules that name it. Values missing from the rules are likely under-permissioned.
The pattern → cause → detect comparison fits in one table:
| Symptom | Likely RLS pattern | How to detect |
|---|---|---|
| Zero rows where many expected | Rule compares a stale field | Diff reference fields vs rule fields |
| Cross-tenant leakage | New table left on All Users | List tables whose Read rule is All Users |
| Some users see nothing, others see their records | Rule reads an unpopulated user field | Check each {{user.data.*}} field on real User records |
| Write succeeds, read returns nothing | Create/Read asymmetry | Compare the Create and Read rules |
| All reads zero | Renamed field not updated in the rule | Check rule fields against the table's fields, then Test Permissions in preview |
| New role blocked | Role value missing from rules | Diff role values vs roles named in rules |
The verification audit — 7 steps
This is the audit we run when we are called in. It works whether you have one drift or twelve. Allow a full day for a mid-size app the first time through; subsequent runs are faster once the matrix is built.
Step 1: Record every entity's security rules
For each table, click Dashboard in the app editor, click the table under Data, then click Permissions, and copy the Generated JSON (Preview) section at the bottom of the page. Base44's docs say it "shows how your rules translate into the technical format Base44 uses behind the scenes" (Managing data permissions, read 17 Oct 2026). In a CLI project, read the rls block of each entity schema file instead: "Security rules are part of your entity schema" (entity security, read 17 Oct 2026). Capture each table's rule for every action (Create, Read, Update, Delete) and every entity field and user property it compares. Save it as JSON — you will diff it later.
Step 2: Enumerate every SDK call site
Grep the codebase for entities., which matches both base44.entities. and base44.asServiceRole.entities. calls. For each match, record the entity name, the method (list/filter/get/create/update/delete), the fields it filters on or writes, and the file:line. Flag every asServiceRole call: the service role "bypasses both RLS and FLS entirely" (entity security, read 17 Oct 2026), so only your own checks guard it. This is your code-side surface.
Step 3: Build the coverage matrix
Cross-product the two exports. Rows are tables. For each table, columns are: rules-on-this-table, rule-fields, query-fields-from-code, actions-covered. Drift shows up as a row where the rule fields and the query fields disagree.
Step 4: Identify orphan rules
A rule is orphaned if it compares a field no live query filters on or writes (server-set fields such as created_by excepted). Either the field was renamed and the rule lags, or the rule is dead weight from a previous schema. Both cases need investigation. Mark every orphan with the suspected cause.
Step 5: Identify unguarded queries
A query is unguarded if the table's rule for that action is All Users, or if it runs through asServiceRole. Base44 "automatically sets up data permissions as you build your app" (Managing data permissions, read 17 Oct 2026), so never assume those rules still fit after an AI edit; check. Every unguarded query on private data is a leak candidate.
Step 6: Write a smoke test per role
For every role in your app, write a test that logs in as a real user with that role and runs every protected query. Assert row counts and content. Then run a query the role should not be able to do and assert zero rows.
To check by hand first, click Test Permissions in preview, next to Edit on a table's Permissions page: it opens the app's preview as a person you pick. Base44 warns that "Changes you make while testing are saved to that person's data" (Managing data permissions, read 17 Oct 2026), so use sample users.
// tests/rls/orders.role-smoke.test.ts
import { createBase44Client } from "@/lib/base44";
import { signInAs } from "./helpers";
describe("orders RLS — per-role smoke", () => {
test.each([
{ role: "admin", expectMin: 100, expectOtherTenant: true },
{ role: "member", expectMin: 1, expectOtherTenant: false },
{ role: "billing", expectMin: 0, expectOtherTenant: false },
])("$role sees only its own tenant", async ({ role, expectMin, expectOtherTenant }) => {
const session = await signInAs(role);
const client = createBase44Client(session.token);
const own = await client.entities.Order.filter({
tenant_id: session.tenantId,
});
expect(own.length).toBeGreaterThanOrEqual(expectMin);
const other = await client.entities.Order.filter({
tenant_id: "TENANT_NOT_MINE",
});
if (expectOtherTenant) {
expect(other.length).toBeGreaterThan(0);
} else {
// Negative assertion — RLS must filter this out.
expect(other.length).toBe(0);
}
});
});
The negative assertion (expect(other.length).toBe(0)) is the load-bearing line. A denied filter() returns "an empty result, the same as if nothing matched" (entity security, read 17 Oct 2026), so this is the only place the bug surfaces.
Step 7: Automate the audit on every AI-builder commit
Wire the matrix-builder and the smoke suite into a pre-deploy check. Use a script outline like this:
// scripts/audit-rls.ts
// Rules come from the repo: the entity schema files in a CLI project,
// or the Generated JSON you saved in step 1.
const rules = await loadSecurityRules("./base44/entities");
const queries = await scanCodebaseForSdkCalls("./src");
const drift = diffRulesVsQueries(rules, queries);
if (drift.length > 0) {
console.error("RLS drift detected on tables:", drift.map(d => d.table));
process.exit(1);
}
const smoke = await runRoleSmokeSuite();
if (smoke.failures.length > 0) {
console.error("Role smoke failures:", smoke.failures);
process.exit(1);
}
The diff function compares the two surfaces:
function diffRulesVsQueries(rules: SecurityRule[], queries: SdkCall[]) {
return rules
.map(rule => {
const queriesOnTable = queries.filter(q => q.table === rule.table);
const queryFields = new Set(queriesOnTable.flatMap(q => q.fields));
const ruleFields = new Set(rule.referencedFields);
const orphan = [...ruleFields].filter(f => !queryFields.has(f));
const unguarded = [...queryFields].filter(f => !ruleFields.has(f));
return { table: rule.table, orphan, unguarded };
})
.filter(d => d.orphan.length > 0 || d.unguarded.length > 0);
}
Run it on every commit that touches src/ or the saved rules. Fail the deploy on drift. Once this gate exists, the AI builder cannot ship a silent regression — it shows up as a red CI run instead.
What this audit looks for
This is a checklist of what to look for, mapped to the six patterns above. It is not a tally of past audits.
- Orphaned rules (Patterns 1 and 5). A rule that compares a field the code no longer filters on or writes, for example after a rename or a new tenant field.
- Tables left on All Users (Pattern 2). Check what each one holds. Email addresses, phone numbers, payment or health data on an open table are a disclosure, not just drift.
- Create/Read asymmetry (Pattern 4). Users can create records they cannot read back, which surfaces as ghost-record support tickets.
- Empty user fields behind a rule (Pattern 3). A
{{user.data.*}}field the rules depend on that some accounts never get. - Role values missing from rules (Pattern 6). A role the AI builder added that no existing rule names.
- Service-role calls without their own checks. Every
asServiceRolecall skips the rules (Step 2), so each one needs an authorization check inside the function. - No per-role smoke test. If the app has never run one, its rules may never have been checked as a real user.
If the app handles regulated data, treat any open table that holds it as a disclosure risk and close it before the next AI-builder edit ships.
Why this is the silent killer of Base44 production launches
A Base44 production failure does not need a missing feature or bad UX. An invisible permission bug introduced by an AI-builder edit nobody reviewed is enough. The features ship. The build passes. Then, long after the change shipped, a customer notices and the incident is already in production telemetry, billing systems, and (in regulated cases) audit logs.
This deserves to be its own discipline. Traditional secure-coding reviews assume a human wrote the code and a human reviewed the diff. AI-builder edits violate both assumptions. The edit is large, the diff is sprawling, and the reviewer (if any) reads a diff rather than testing the rules as a real user. The result is a category of bug that is structurally invisible to the workflows most teams already have.
We call this discipline AI-Builder Verification. It is not pen-testing. It is specifically: take the agent's output, treat it as untrusted, and verify the rules and the code agree. The verification is mechanical (the seven steps above), but judgment about what the rules should be still requires a human who understands the data model and can design role-based permissions that hold across future AI-builder edits. That combination — mechanical drift detection plus human policy review — is what we deliver in our Base44 AI Builder Audit engagement.
If your app is in production on Base44 and you have not run this audit, you do not know whether your rules still match your code. If you operate under regulated data, you cannot DIY this — get an external review before the next AI-builder edit ships. Scope an audit through /base44-debugging-help, or read the related fixes for AI-induced regressions broadly, SSO and auth bypass, and silent data loss on return — all descend from the same AI-builder verification gap.