Skip to content

feat(macros): scoped repositories — compile-enforced scope argument on reads - #166

Merged
bodymindarts merged 1 commit into
mainfrom
feat/scoped-repositories
Jul 30, 2026
Merged

feat(macros): scoped repositories — compile-enforced scope argument on reads#166
bodymindarts merged 1 commit into
mainfrom
feat/scoped-repositories

Conversation

@bodymindarts

@bodymindarts bodymindarts commented Jul 30, 2026

Copy link
Copy Markdown
Member

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 marked scope makes 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:

Scope guards every path that turns an id or query into entity data. Mutations of an already-held entity are guarded by custody — the entity could only have been obtained through a scoped read — and keep today's signatures.

Declaration

#[derive(EsRepo)]
#[es_repo(
    entity = "Customer",
    columns(
        partner_id(ty = "PartnerId", scope),   // ← marks the scope column
        email(ty = "String"),
    )
)]
pub struct Customers { pool: PgPool }

Generates an entity-named scope enum + conversions:

pub enum CustomerScope { All, Only(PartnerId) }
// From<PartnerId> / From<&PartnerId> => Only.
// Deliberately NO From<Option<T>> — None => All would be the leak class as a conversion.

What changed

  • Reads take scopefind_by_* / maybe_find_by_* (+_in_op, _include_deleted), find_all, list_by_*, list_for_*, list_for_filters* gain scope: impl Into<{Entity}Scope>. Each fn dispatches at runtime between two static, compile-time-checked, sargable es_query! arms (the perf: emit sargable per-state SQL for list queries #162 pattern, riding its assemble_select conditions seam):
    • All → byte-identical to today's SQL
    • Only(v) → additional scope_col = $n equality conjunct; every cursor state / filter combination / fallback arm gets its scoped twin (parameter indices shift by one via the existing offset machinery)
  • Writes stay unscoped (create*, update*, delete, forget) — custody principle above.
  • Under Only, missing and not-yours look identical: find_byNotFound, maybe_find_byNone, find_all silently drops foreign ids, lists never contain foreign rows.
  • Cursors carry no filter authority: every page's SQL carries the conjunct; cursor values are compared, never dereferenced — a foreign/tampered cursor can only reposition within the caller's own scoped set.
  • Validations (darling errors): exactly one scope column; must be non-nullable (Option<T>/nullable rejected — nullable scopes are a future feature); not Forgettable<T>; no find_by/list_by/list_for on the scope column (every read is already filtered by it); rejected on nested repos (children are custody-guarded via parent).
  • Backward compatible: no scope marker → 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

  • 104 macro unit tests (incl. scoped-derive smoke test + 7 validation tests)
  • 140 integration tests green against live PG, incl. new tests/scoped_repo.rs: scoped point reads (own/foreign/All + Into ergonomics), _in_op variants, find_all foreign-id dropping, multi-page scoped pagination, list_for/list_for_filters proxy dispatch under scope, foreign-cursor non-leak, and an EXPLAIN check that the scope conjunct becomes an index qual (no seq scan)
  • cargo clippy --workspace --all-features -- -D warnings clean; nix flake check passes; book builds
  • New book chapter: declaration, semantics, custody principle, cursor property, validation rules, and scope-led composite index guidance

Consumer notes

  • Opt-in: cala/obix/job/drua unaffected until they mark a column scope
  • lana adoption: add scope to partner_id columns, then follow compile errors; scope-led composite indexes per the book chapter
  • Deferred by design (see decisions doc): nullable scope columns, multi-dimension scoping, .scoped(s) binder sugar, scope-stamped cursors, RLS defense-in-depth, witness-gated All

🤖 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 scope and the EsRepo macro requires a leading scope: 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 (All vs Only(value)), with From for the scope value but not From<Option<T>> so accidental None cannot widen to all tenants. At runtime reads dispatch between unchanged SQL for All and static es_query! literals that add a sargable scope_col = $n conjunct for Only, 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/Forgettable scope columns, query accessors on the scope column, and scope on nested repos; find_by on 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, EXPLAIN index qual).

Reviewed by Cursor Bugbot for commit a8960a9. Bugbot is set up for automated code reviews on this repo. Configure here.

…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>
@bodymindarts
bodymindarts merged commit d7aacfe into main Jul 30, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant