ecto_context: The Context Module, Secured for Agents
elixir

ecto_context: The Context Module, Secured for Agents

ecto_context an elixir library that makes authorization mandatory by design — every data access function is scoped, and there's no way around it.

Security problems are about options, not bugs. The moment an insecure path exists, an agent will eventually take it — making it structurally unavailable is the only fix that holds.

Why

Phoenix context generators are convenient until they aren't. Every context I generated looked roughly the same: list_users, get_user!, create_user, update_user, delete_user. The functions were identical in structure, only the schema name changed.

The problem wasn't the repetition. It was the noise. When 95% of every context file is boilerplate, spotting the 5% that actually matters becomes hard. A custom query buried in twenty generated functions. An authorization check that shouldn't be there. A missing preload. The signal was invisible.

I tried generating less. I tried comments. Neither worked. The file was still full of code I didn't write intentionally. This only got worse in the age of AI.

Ask an agent to add a feature and it will helpfully generate every CRUD function it can think of — list_users, list_active_users, list_users_by_role, and three variants of get — all unscoped, all reaching directly for Repo, all "just in case you need it later." The context grows to cover every possible future need. Authorization is something it will add in a follow-up if you remind it. Probably.

What

ecto_context starts from a different premise: declare what you need, nothing else gets generated.

defmodule MyApp.Articles do
import EctoContext
ecto_context schema: Article, scope: &__MODULE__.scope/2 do
list()
get!()
create()
update()
delete()
end
def scope(query, %Scope{admin: true}), do: query
def scope(query, %Scope{user_id: uid}), do: where(query, user_id: ^uid)
end

Five lines in the block. Five generated functions. Every function that exists was declared on purpose. The scope/2 callback is the only code in the file that wasn't generated — and it's the only thing that matters. The signal/noise ratio flipped. The file now shows what's different, not what's the same.

Notice the function names: list, not list_articles. The module already supplies the noun — Articles.list(scope) reads clearly without it. This matters more than it seems.

A single Content context that grows to cover an entire domain becomes a god module: a thousand lines, no clear boundary, and naming as the permanent unsolved problem. You can't tell from the file structure what the application can do. You can't tell where a specific piece of logic lives. Forming the right abstraction boundary upfront is hard — and refactoring it later, once the context has grown into everything, is harder. The longer you wait, the more it costs.

The solution isn't better naming — it's narrower contexts. Use Content.Articles, Content.Projects, Content.Tags instead. The directory structure becomes a map of what the application can do. The function names stay simple because each module only does one thing: list, get, create, delete. The same names, every time.

This transfers well. Even for awkward schema names — a Vocabulary context with VocabularyEntry records — Vocabulary.list(scope) is unambiguous. No pluralization edge cases, no guessing. The LLM already knows the pattern from the last context it touched.

When you need something beyond the standard set, you write it explicitly:

def delete_with_attachment(scope, article) do
Repo.transact(fn ->
delete(scope, article)
Attachments.delete_for(scope, article)
end)
end

That function stands out immediately — not because it's marked, but because everything around it was generated and this wasn't. The custom logic is visible by definition.

Every generated function takes scope as its first argument. Not optional. Not with a fallback. There is no list_articles() — only list_articles(scope).

This wasn't originally about security. It was about consistency. But the consequence is significant: there is no path through the data layer that skips authorization. scope/2 always runs. permission/3 always runs on writes. No escape hatch — not even for "simple" cases.

How

For writes, permission/3 decides both whether the operation is allowed and which changeset applies:

def permission(:update, _article, %Scope{role: :admin}), do: {:ok, :admin_changeset}
def permission(:update, article, %Scope{user_id: uid}) when article.user_id == uid, do: {:ok, :user_changeset}
def permission(_, _, _), do: false

Rails' strong parameters decided which fields were permitted in the controller. Cancan decided who could act in a separate ability file. ecto_context collapses both decisions into a single function in the context module — the only place that should ever know.

A custom Credo check enforces the boundary at the compiler level:

Do not call Repo.insert directly in an ecto_context module —
scope is never enforced on direct Repo calls.
Use an ecto_context-generated function instead.

exit_status: 1. The build stops. The error message isn't just a no — it lists exactly what to do instead: custom queries go in the :query opt, preloads go in :preload, admin access uses an admin Scope struct — still scoped, just with broader permissions.

A second check blocks Repo calls from LiveViews and controllers entirely. The web layer has no path to the database except through a context module that runs scope.

The bigger picture

I built this to fix a developer experience problem. Then I started using LLMs to write application code.

LLMs take the path of least resistance. If an unscoped list_articles() exists, an LLM will use it — not because it's careless, but because it's shorter and it compiles. If Repo.insert is available in a context module, an LLM will reach for it when a generated function feels like extra steps.

The same noise problem reappears at a different level. Not "I can't see what matters in this file" but "the model can't see what's correct to call."

Making scope mandatory solved this without additional instruction. The LLM calls Articles.list(scope) because that's the only function that exists. The Credo check stops it if it tries to go around. The error message tells it exactly what to do instead.

This isn't a prompt engineering problem. It's a structural one. Better instructions don't fix it — removing the wrong option does.

The path of least resistance became the secure path. Not by documenting the right way. By making the wrong way structurally unavailable.