For AI agents (and the people driving them)

Increasingly the “reader” of authorization docs is an AI coding agent. These prompts encode the foot-guns this documentation already knows about, so an agent gets the integration right on the first try. Copy, adjust the names, paste.

There is also an llms.txt index of this site, and every page here links to its canonical in-repo source — point your agent at those for depth.

Install CurrentScope in this app

Add the current_scope gem to this Rails app (Rails >= 8.1, < 9 required).

1. Add `gem "current_scope"` to the Gemfile, then run:
   bin/rails generate current_scope:install
   bin/rails current_scope:install:migrations && bin/rails db:migrate
2. In ApplicationController, include CurrentScope::Context and
   CurrentScope::Guard (in that order). Authentication must be wired
   BEFORE these concerns run — Context reads current_user when its
   callback fires, so an auth callback registered after these includes
   means the gate denies before authentication happens.
3. The gate is fail-closed and covers EVERY action. You MUST add
   `skip_before_action :current_scope_check!` to controllers where
   authorization does not apply — sign-in/sessions (or nobody can log in),
   webhooks, health checks. A skipped controller is unprotected by the
   permission gate: keep or add the app's own auth there. If the app has
   act-as/impersonation, ALSO add
   `skip_before_action :current_scope_mutation_guard!` on sign-in,
   sign-out, and the stop-impersonation action — that guard is a separate
   callback that survives the gate skip, and without its skip an
   impersonating admin cannot sign out or stop impersonating.
4. If this app already has users and traffic, set
   `config.enforcement = :report` in the initializer BEFORE deploying,
   run the test suite, then run `bin/rails current_scope:report` and seed
   the roles it names. Flip back to :enforce only when re-exercising the
   app adds no NEW access.would_deny rows (the report reads the
   append-only ledger — historical rows never clear) and any
   access.sod_blind_spot rows are resolved. Report mode must not be the
   final state.
   If config.sod_actions is set, ALSO clear any access.sod_initiator_missing
   rows before flipping. Those are not denials — they are 500s. An SoD action
   reaching a model with no current_scope_initiator RAISES
   CurrentScope::ConfigurationError, in :report mode exactly as in :enforce,
   so granting will not clear them. Best done before enabling SoD at all:
   the engine logs a preflight warning naming the ones it can see (at boot in
   production; on the first request in development, whose routes are lazy),
   `bin/rails current_scope:report` lists them, and
   `CurrentScope::SodPreflight.scan` returns them programmatically: a Result
   with .rows ([permission, model_class] pairs), .inspected, .in_scope and
   .skipped. An EMPTY .rows is NOT an all-clear on its own — check
   `result.blind?`, which is true when a check failed (.degraded?) or when
   nothing was read at all (.inspected == 0), because the scan only sees
   controllers declaring current_scope_model.
5. Bootstrap the first admin (the management UI only admits full-access
   subjects): `bin/rails current_scope:grant SUBJECT_ID=<id>` — or, in
   seeds, `CurrentScope.grant!(user)` (it creates the default
   Owner/Member roles if missing; Member starts with zero permissions).
6. Declare record hooks as PRIVATE controller methods. For member actions:
   `def current_scope_record = (set_thing if request.path_parameters[:id])`
   — key off request.path_parameters, never params, so a ?id= query string
   cannot smuggle a record into collection actions. Use the route's actual
   member key: a route declared with a custom param (e.g. :slug) must
   check request.path_parameters[:slug], or the hook returns nil and the
   SoD veto is silently skipped on that action.
7. Run the full test suite. Expect controller tests to 403 until grants are
   seeded — use the test helpers (grant_role!/grant_scoped_role!) from
   current_scope/test_helpers, not stubs.

## Carry role definitions between environments

```text
Export and diff are safe for an agent to run. Import and rollback are not
autonomous in production.

1. Export live roles:
   bin/rails current_scope:definitions:export FILE=config/current_scope/roles.yml
2. Commit the YAML. Diff against another environment:
   bin/rails current_scope:definitions:diff FILE=config/current_scope/roles.yml
3. A human reviews the printed diff. The document is desired state for role
   names, descriptions, full_access, and permission keys. It does not carry
   assignments.
4. A human applies, never an agent on its own:
   bin/rails current_scope:definitions:import FILE=config/current_scope/roles.yml CONFIRM=1 ACTOR_ID=<id>
   Production always needs CONFIRM=1. A populated non-production database
   does too. Do not set CONFIRM=1 unless a human asked in that turn.
5. Rollback reads the snapshot written beside the document, not FILE=:
   bin/rails current_scope:definitions:rollback SNAPSHOT=config/current_scope/roles.yml.pre.yml CONFIRM=1 ACTOR_ID=<id>

Before finishing, read docs/SECURITY-CHECKLIST.md in the gem repo (https://github.com/davidteren/current_scope/blob/main/docs/SECURITY-CHECKLIST.md) and verify each item that applies.


## Declare subject identity and attach Owner

```text
Declare how a subject is identified so grants can be found across
environments. This is config.subject_identity, NOT config.subject_label.
Label is display-only and fail-soft. Identity is load-bearing: duplicate
keys raise, and resolve never inserts a subject.

1. Pick the identity:
   - one column: config.subject_identity = :email
   - composite: config.subject_identity = [:name, :email]
   - split across tables: bin/rails generate current_scope:identity
     then set config.subject_identity = CurrentScopeSubjectIdentity.new
     and fill identify / resolve.
2. Check uniqueness against live rows:
   bin/rails current_scope:identity:check
   The task exits non-zero and names the colliding keys; it does not raise.
   A collision raises ConfigurationError at BOOT, and identity:setup stops
   before it writes. Do not first-row-win.
3. Dry-run a grant (writes nothing):
   bin/rails current_scope:identity:setup SUBJECT=you@example.com
   SUBJECT is whatever your identify returns. For a composite it is a YAML
   sequence: SUBJECT='[Ada, you@example.com]'.
   ROLE= defaults to Owner (full_access). Admin, if you create it, is not
   full_access unless you set that yourself.
   IDENTITY=email is an OVERRIDE for one run, for the case where
   config.subject_identity is not written yet. Do not add it once step 1
   is in the initializer: it replaces the configured identity, so under a
   composite or an object identity it audits and grants by the wrong key,
   and it removes the create_placeholder! factory step 5 needs.
4. Write only after the dry-run looks right:
   bin/rails current_scope:identity:setup SUBJECT=you@example.com WRITE=1
   That calls CurrentScope.grant! (ledger source: bootstrap). WRITE=1 also
   creates the Role row if it does not exist, and seeds the default Owner
   and Member roles when ROLE is unset. The dry-run plan names both.
5. Missing subject: never invent one in production. Outside production,
   PLACEHOLDER=1 needs a create_placeholder! factory on the identity
   object from `bin/rails generate current_scope:identity`. With one, a
   dry-run prints the would-create line; without one the task stops and
   says PLACEHOLDER=1 has no factory. Create the marked row with
   PLACEHOLDER=1 WRITE=1. Production + PLACEHOLDER=1 is refused.

Hard stop: never invent a production subject.

Enable separation of duties on an approve flow

Enable CurrentScope separation of duties so an initiator can never approve
their own record. SoD is OFF by default — config.sod_actions = [] means the
veto never runs.

1. In config/initializers/current_scope.rb set
   `config.sod_actions = %w[approve]` (action NAMES, not full keys).
2. On the model, define `def current_scope_initiator = <initiator assoc>`.
   If an SoD action reaches a record whose class lacks this hook, the
   resolver raises ConfigurationError — that is intended (fail loud).
3. CRITICAL: the controller's `current_scope_record` hook MUST return the
   record on the SoD member action. If it returns nil there, the veto is
   silently SKIPPED and the initiator can approve. This is the load-bearing
   control — verify it, don't assume it.
4. Write the verification test and run it:
   - grant the initiator a role that ticks approve
   - POST the approve action as the initiator
   - assert response :forbidden AND
     response.headers["X-Current-Scope-Reason"] == "sod_veto"
   If the reason is "no_grant" instead, SoD is not examining this action.
5. Do NOT add bypass logic unless explicitly asked. If a conditional
   self-approval is a real requirement, use the engine's break-glass
   (allow_sod_bypass) — never a hand-rolled branch, because break-glass
   records the sod.bypassed audit event a hand-rolled branch forgets.
   That recording requires config.audit enabled (the default); if an
   unaudited bypass must be impossible, set config.audit = :strict.

Debug a CurrentScope 403

A request is being denied. Diagnose it from the engine's own signals; do not
guess or bypass the gate.

1. Read the X-Current-Scope-Reason response header (or the INFO log line
   "[CurrentScope] denied <key> (<reason>) → 403"). Map the reason:
   - no_grant          → nothing grants this controller#action key; check
                         the role grid / seed the grant
   - sod_veto          → the subject initiated this record; that denial is
                         the anti-fraud control working — do not "fix" it
   - model_undeclared  → the controller needs `def current_scope_model = <Model>`
   - model_invalid     → current_scope_model returned something that is not
                         a concrete ActiveRecord class
   - impersonation_gate→ non-GET/HEAD while impersonating; sessions are
                         read-only by design
   - not_full_access   → the management UI; only full-access subjects enter
2. In development/test, check the log for the engine's nudges (nil SoD
   record, inert scoped grant, cross-controller key derivation) — each
   names its one-line fix.
3. If there is no header and no log line, a host rescue_from may have
   replaced the denial handler, or the controller never included Guard —
   run `bin/rails current_scope:ungated` to check the gated surface.

Migration tooling (shipped)

Migrating from Pundit, CanCanCan, or Action Policy? The current-scope-migrate Claude Code skill is complete (#45): deterministic rule inventories for all three systems, decision report, parity harness (per-system old-answer replay), reviewable role-backfill migrations (enum column or rolify), and safe mechanical call-site rewrites behind an explicit --write. The manual path remains the adoption guide.

Planned agent surfaces

These are tracked but not shipped — do not prompt an agent to use them yet:

  • Exposing the subject’s abilities to a separate JS front-end (React/Next): #96, Inertia props: #97.