About three weeks ago I finished organizing something I had been avoiding for years. Two hundred and twenty-six files across my local drive, Google Drive, and a decade's worth of downloads. North of 1.2 million words with no index, no structure, and no way to find anything unless I remembered exactly what I named it.
I turned it into a queryable knowledge base, connected it to a Supabase project, and wired it to the content pipeline I use to run my apps and books. The system I built to write about this actually used the system it was describing to find source material. That part worked exactly as intended.
What almost didn't work was the security gate.
The problem with filters
When you give an AI agent access to a personal archive, you have two options for what it can see: you can filter, or you can wall.
Filtering means the query returns only certain rows. Walling means the agent structurally cannot reach the rows you want hidden. They look identical from the outside. The difference shows up when something breaks.
Here's the setup. I have a moments table in Supabase holding personal writing fragments, scenes, drafts, and notes. Some of it is clearly public and canonical. A lot of it is private, early, or structurally wrong for any agent to touch. I wanted AI content agents to pull only from verified public material.
The obvious solution: create a view.
CREATE VIEW public_seeds AS
SELECT * FROM moments
WHERE visibility = 'public'
AND is_canonical = true;
This looks right. It returns only rows where both conditions are true. But there's a problem that isn't obvious until you've read enough Postgres documentation at 11pm: by default, a Postgres view runs as the view owner, not the calling role.
That means row-level security doesn't apply. The view owner (usually your service role) has full table access, and the view inherits it. Your RLS policy exists, but the view bypasses it entirely, running as the owner. You've written a filter, not a wall.
"This shouldn't happen" and "this cannot happen" are not the same sentence.
The fix: security_invoker
PostgreSQL 15 added a security_invoker option for views. When you set it to true, the view runs as the calling role instead of the owner. RLS applies normally. The view stops being a filter and becomes a structural gate.
The correct version:
CREATE TABLE moments (
id TEXT PRIMARY KEY,
title TEXT,
content TEXT,
visibility TEXT CHECK (visibility IN ('public', 'private', 'restricted')),
is_canonical BOOLEAN,
source_file TEXT,
themes TEXT[]
);
-- Without security_invoker = true, this view runs as the VIEW OWNER.
-- The owner has full table access. RLS does not apply.
-- security_invoker = true switches to the CALLING role, so RLS applies normally.
CREATE VIEW public_seeds
WITH (security_invoker = true)
AS
SELECT * FROM moments
WHERE visibility = 'public'
AND is_canonical = true;
ALTER TABLE moments ENABLE ROW LEVEL SECURITY;
CREATE POLICY "public_only" ON moments
FOR SELECT
USING (visibility = 'public' AND is_canonical = true);
Now the wall is structural. A content agent calling public_seeds cannot reach private rows even if it tries to work around the view, even if the query is malformed in an interesting way, even if a future developer adds a join without thinking about it. The RLS policy enforces at the table level. The view is just a convenience wrapper on top of something that already holds.
Why this matters more when agents are involved
With a human querying a database, a filter is usually fine. Humans read error messages. They notice when results look wrong. They ask questions.
AI agents don't do any of those things. An agent that gets back more rows than it should doesn't know it got more rows than it should. It works with what it received. If your filter fails silently, the agent's behavior changes without any visible signal that something went wrong.
This is the core reason "this shouldn't happen" is architecturally insufficient when agents are in the loop. "Shouldn't" relies on correctness. "Cannot" relies on structure. Structure holds when correctness fails, when a query is written differently than expected, when a dependency changes, when a future developer adds a new code path that doesn't know about the filter.
For my pipeline specifically, the public_seeds view is the only surface area agents are allowed to touch. Private drafts, unpublished scenes, personal notes, things I wrote during a difficult year that I'm not ready to do anything with yet: none of that is reachable. Not because a query filters it out. Because the architecture prevents it from being returned at all.
What I'd do differently
The security_invoker flag is a PostgreSQL 15 feature. If you're running an older version or you haven't checked your Supabase project's Postgres version recently, you may not have it. The alternative is to manage access through the role system directly: create a dedicated read-only role for agents, grant it explicit SELECT on only the rows you want exposed, and never connect agents using the service role.
The service role bypasses RLS by design. If your agents are connecting with the service role key, no amount of view filtering will protect you. That's the first thing to check.
The second thing to check is whether any existing views in your schema were created before you thought carefully about security. Views created without security_invoker predate any RLS policy you add later. The policy won't retroactively apply to them.
The third thing I'd do differently: name the gate something explicit. public_seeds is clear enough, but I've started prefixing agent-facing views with agent_ so it's obvious at a glance what was designed for machine consumption versus human use. Small thing. Saves confusion later.
The knowledge base is running. The archive has a few hundred public moments indexed and retrievable. The content that came out of it is genuinely better for having source material to work from, which is the whole argument for building the thing in the first place.
The part that almost derailed it was a 13-word clause in a CREATE VIEW statement. Worth knowing before you wire anything up.








