feat(macros): scoped repositories — compile-enforced scope argument on reads - #166
Merged
Conversation
…n reads
A repo column marked `scope` makes every generated read fn require a
leading `scope: impl Into<{Entity}Scope>` argument — the unscoped call
does not exist, so "forgot to scope the lookup" is a compile error.
- generated per-repo enum `{Entity}Scope { All, Only(T) }` with
`From<T>`/`From<&T>` => Only (deliberately no `From<Option<T>>`)
- reads (`find_by_*`, `find_all`, `list_by_*`, `list_for_*`,
`list_for_filters*`) dispatch at runtime between two static, sargable
es_query! arms: All = today's SQL byte-identical, Only(v) = an extra
`scope_col = $n` conjunct (rides the #162 assemble_select seam)
- writes (`create*`, `update*`, `delete`, `forget`) stay unscoped:
mutations operate on entities only obtainable through a scoped read
- validations: exactly one scope column; non-nullable; not Forgettable;
no find_by/list_by/list_for on the scope column; rejected on nested
repos
- unscoped repos generate byte-identical output (all pre-existing
macro fixtures pass unchanged)
Tests: scoped Contact entity + migration; live-PG suite covering point
reads, find_all id-dropping, scoped pagination, list_for/filters proxy
dispatch, foreign-cursor non-leak, and an EXPLAIN sargability check.
Book: new "Scoped Repositories" chapter incl. index guidance.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jul 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements the scoped-repositories requirement (drua
es-entity-dev/scoped-repositories-requirement.md): lana's multi-tenant authorization needs data access that is impossible to misuse. A repo column markedscopemakes every generated read fn require a leading scope argument — the unscoped call does not exist, so "forgot to scope the lookup" is a compile error, for humans and LLMs equally.Design was ratified with Justin in the working session recorded at drua
es-entity-dev/scoped-repositories-decisions.md; core principle:Declaration
Generates an entity-named scope enum + conversions:
What changed
find_by_*/maybe_find_by_*(+_in_op,_include_deleted),find_all,list_by_*,list_for_*,list_for_filters*gainscope: impl Into<{Entity}Scope>. Each fn dispatches at runtime between two static, compile-time-checked, sargablees_query!arms (the perf: emit sargable per-state SQL for list queries #162 pattern, riding itsassemble_selectconditions seam):All→ byte-identical to today's SQLOnly(v)→ additionalscope_col = $nequality conjunct; every cursor state / filter combination / fallback arm gets its scoped twin (parameter indices shift by one via the existing offset machinery)create*,update*,delete,forget) — custody principle above.Only, missing and not-yours look identical:find_by→NotFound,maybe_find_by→None,find_allsilently drops foreign ids, lists never contain foreign rows.Option<T>/nullablerejected — nullable scopes are a future feature); notForgettable<T>; nofind_by/list_by/list_foron the scope column (every read is already filtered by it); rejected on nested repos (children are custody-guarded via parent).scopemarker → byte-identical output; all pre-existing macro token fixtures pass unchanged. Marking a repo scoped is deliberately source-breaking for that repo's call sites — the compiler enumerates every site needing a scoping decision.Verification
tests/scoped_repo.rs: scoped point reads (own/foreign/All +Intoergonomics),_in_opvariants,find_allforeign-id dropping, multi-page scoped pagination,list_for/list_for_filtersproxy dispatch under scope, foreign-cursor non-leak, and anEXPLAINcheck that the scope conjunct becomes an index qual (no seq scan)cargo clippy --workspace --all-features -- -D warningsclean;nix flake checkpasses; book buildsConsumer notes
scopescopetopartner_idcolumns, then follow compile errors; scope-led composite indexes per the book chapter.scoped(s)binder sugar, scope-stamped cursors, RLS defense-in-depth, witness-gatedAll🤖 Generated with Claude Code
Note
High Risk
Changes macro-generated data-access SQL and signatures for multi-tenant isolation; mistakes would be cross-tenant leaks, though scoped adoption is opt-in and heavily tested.
Overview
Adds scoped repositories: mark one column with
scopeand theEsRepomacro requires a leadingscope: impl Into<{Entity}Scope>on every generated read (find_by_*,find_all,list_by_*,list_for_*,list_for_filters*). Unscoped read calls no longer compile for that repo.The macro emits
{Entity}Scope(AllvsOnly(value)), withFromfor the scope value but notFrom<Option<T>>so accidentalNonecannot widen to all tenants. At runtime reads dispatch between unchanged SQL forAlland statices_query!literals that add a sargablescope_col = $nconjunct forOnly, including every list cursor state and filter specialization arm (parameter offsets shift accordingly). Writes stay unscoped (custody on entities already loaded under scope).Compile-time validation rejects multiple scope columns, nullable/
Forgettablescope columns, query accessors on the scope column, andscopeon nested repos;find_byon the scope column is not generated. Unscoped repos keep identical codegen.Documentation adds a Scoped Repositories book chapter (semantics, custody, cursors, indexes). Integration coverage includes
tests/scoped_repo.rs(cross-tenant behavior, pagination, foreign cursors,EXPLAINindex qual).Reviewed by Cursor Bugbot for commit a8960a9. Bugbot is set up for automated code reviews on this repo. Configure here.