gatehouse 0.3.0

An in-process authorization engine for Rust with composable policies and request-scoped fact loading.
Documentation
# Migrating from 0.2 to 0.3

Gatehouse 0.3 is a breaking release. The main shift is that relationship data is now loaded through request-scoped `EvaluationSession` instances and typed `FactSource`s instead of policy-owned `RelationshipResolver`s.

Three sentence-level changes apply across the codebase before you touch any specific section:

- **`Policy::evaluate_access` is renamed to `Policy::evaluate`** and takes a single `EvalCtx<'_, S, R, A, C>` reference instead of four positional borrows. Every existing `impl Policy` will need this rewrite.
- **`Subject`, `Resource`, `Action`, and `Context` must be `Sync`.** Batch evaluation borrows these across `.await` points. Types that hold `Rc`, `RefCell`, raw pointers, or other `!Sync` interior state will need to be reworked or wrapped (`Arc<Mutex<_>>` / `Arc<RwLock<_>>` if interior mutability is genuinely required).
- **Construct `PolicyEvalResult` through the new constructors**, not struct literals. The `Granted` and `Denied` variants gained a `provenance: Vec<FactProvenance>` field, so any direct `PolicyEvalResult::Granted { … }` / `PolicyEvalResult::Denied { … }` literal stops compiling. Use `PolicyEvalResult::granted(name, Some(reason))` and `PolicyEvalResult::denied(name, reason)` (or the `_with_facts` variants when recording provenance) — or, in policy bodies, prefer `ctx.grant(reason)` / `ctx.deny(reason)`.

## Policy Implementations

The `Policy` trait now receives an evaluation context rather than separate arguments. `policy_type` returns `Cow<'static, str>` so static names stay zero-allocation, and policies build results through the `ctx.grant` / `ctx.deny` shortcuts rather than struct literals.

```rust
// 0.2
#[async_trait]
impl Policy<User, Document, Read, RequestContext> for OwnerPolicy {
    async fn evaluate_access(
        &self,
        user: &User,
        _action: &Read,
        document: &Document,
        _context: &RequestContext,
    ) -> PolicyEvalResult {
        if user.id == document.owner_id {
            PolicyEvalResult::Granted {
                policy_type: "OwnerPolicy".to_string(),
                reason: Some("owner".to_string()),
            }
        } else {
            PolicyEvalResult::Denied {
                policy_type: "OwnerPolicy".to_string(),
                reason: "not owner".to_string(),
            }
        }
    }

    fn policy_type(&self) -> String {
        "OwnerPolicy".to_string()
    }
}

// 0.3
use std::borrow::Cow;

#[async_trait]
impl Policy<User, Document, Read, RequestContext> for OwnerPolicy {
    async fn evaluate(
        &self,
        ctx: &EvalCtx<'_, User, Document, Read, RequestContext>,
    ) -> PolicyEvalResult {
        if ctx.subject.id == ctx.resource.owner_id {
            ctx.grant("owner")
        } else {
            ctx.deny("not owner")
        }
    }

    fn policy_type(&self) -> Cow<'static, str> {
        Cow::Borrowed("OwnerPolicy")
    }
}
```

`ctx.grant` / `ctx.deny` tag the resulting `PolicyEvalResult` with the policy's name (captured once by the checker into `ctx.policy_type`), so the body never re-passes `self.policy_type()`.

For dynamic policy names, store an owned name on the policy and return `Cow::Owned` from `policy_type`. The shortcuts still work; the result will carry the dynamic name. Dynamic names pay extra allocations per evaluation (see `EvalCtx::policy_type` rustdoc) — prefer a `'static` name when you can.

```rust
struct NamedPolicy {
    name: String,
}

impl NamedPolicy {
    fn new(name: impl Into<String>) -> Self {
        Self { name: name.into() }
    }
}

#[async_trait]
impl Policy<User, Document, Read, RequestContext> for NamedPolicy {
    async fn evaluate(
        &self,
        ctx: &EvalCtx<'_, User, Document, Read, RequestContext>,
    ) -> PolicyEvalResult {
        ctx.deny("not implemented")
    }

    fn policy_type(&self) -> Cow<'static, str> {
        Cow::Owned(self.name.clone())
    }
}
```

A note on `PolicyBuilder`: `PolicyBuilder::new(name)` stores the name as an owned `String`, so every builder-built policy is a dynamic-name policy under the trace-path allocation accounting (one allocation per evaluation, plus another on the `ctx.grant`/`ctx.deny` helper path). If you're migrating a policy whose name is a `'static` string literal and you want the zero-allocation path, switch from `PolicyBuilder` to a small hand-written `impl Policy<…>` that returns `Cow::Borrowed("MyPolicy")`. The builder is the right tool for ergonomics and dynamic names; hand-written impls are the right tool for hot paths with fixed names.

## Checker Calls

All checker evaluation now takes an explicit session. For RBAC/ABAC-only paths, the simplest migration is `PermissionChecker::check`, which wraps `evaluate_in_session(EvaluationSession::shared_empty(), …)`:

```rust
// 0.2
let decision = checker
    .evaluate_access(&user, &Read, &document, &request_context)
    .await;

// 0.3 (RBAC/ABAC only — no fact-backed policies)
let decision = checker
    .check(&user, &Read, &document, &request_context)
    .await;
```

For checkers with any fact-backed policy (ReBAC, lookup, or any policy that reads from an `EvaluationSession`), build a session per request and call `evaluate_in_session`:

```rust
// 0.3 (fact-backed)
let session = EvaluationSession::builder()
    .with_arc::<RelationshipQuery<Uuid, Uuid, Relation>>(Arc::clone(&relationships))
    .build();
let decision = checker
    .evaluate_in_session(&session, &user, &Read, &document, &request_context)
    .await;
```

## ReBAC

`RelationshipResolver` has been removed. Use `RelationshipQuery` as the fact key, implement `FactSource`, and register the source in the request session.

The `RebacPolicy::new` signature changes accordingly:

- **The `resolver` first argument is gone.** The relationship source is now registered on the `EvaluationSession`, not on the policy.
- **The relation argument shifts from `String` (or `impl Into<String>`) to a typed value** — typically a `Copy` enum like `Relation::Viewer`. The `FactSource` implementation is responsible for converting the typed value to whatever its backend uses (a SQL `text` column, a string-keyed lookup table, etc.).

So a 0.2 call like `RebacPolicy::new(resolver, sid_fn, rid_fn, "viewer".to_string())` becomes a 0.3 call like `RebacPolicy::new(sid_fn, rid_fn, Relation::Viewer)`, plus a session-side `with_arc::<RelationshipQuery<Sid, Rid, Relation>>(source)` registration.

```rust
// 0.2
let policy = RebacPolicy::new(
    resolver,
    |user: &User| user.id,
    |document: &Document| document.id,
    "viewer".to_string(),
);
checker.add_policy(policy);

// 0.3
#[async_trait]
impl FactSource<RelationshipQuery<Uuid, Uuid, Relation>> for RelationshipStore {
    async fn load_many(
        &self,
        keys: &[RelationshipQuery<Uuid, Uuid, Relation>],
    ) -> Vec<FactLoadResult<bool>> {
        // Load all keys in one backend call and return one result per key.
        todo!()
    }
}

let relationships: Arc<dyn FactSource<RelationshipQuery<Uuid, Uuid, Relation>>> =
    Arc::new(RelationshipStore::new(pool));
let session = EvaluationSession::builder()
    .with_arc::<RelationshipQuery<Uuid, Uuid, Relation>>(Arc::clone(&relationships))
    .build();

checker.add_policy(RebacPolicy::new(
    |user: &User| user.id,
    |document: &Document| document.id,
    Relation::Viewer,
));

let decision = checker
    .evaluate_in_session(&session, &user, &Read, &document, &request_context)
    .await;
```

`FactSource::load_many` receives unique keys and must return exactly one result per key in the same order. Missing sources, backend errors, wrong result counts, and cancelled leader loads fail closed.

## Batch List Endpoints

Use the batch helpers for list endpoints instead of looping manually. The shared-context-per-page entrypoint is `filter_authorized_in_session_by_resource` (renamed from the 0.3-alpha `filter_authorized_with_context_in_session_by`):

```rust
let visible = checker
    .filter_authorized_in_session_by_resource(
        &session,
        &user,
        &Read,
        documents,
        &request_context,
        |document| document,
    )
    .await;
```

For per-item `(R, C)` pairs (each item carries its own context), use `filter_authorized_in_session_by`. For full per-item evaluations (item + decision pairs returned for both grants and denials), use `evaluate_batch_in_session_by_resource` or `evaluate_batch_in_session_by` respectively.

`PermissionChecker` still evaluates policies in order with `OR` semantics. Policies such as `RebacPolicy` can collapse backend work inside `Policy::evaluate_batch`.

## Session Setup and Cancellation

Prefer `EvaluationSession::builder()` so all request-scoped fact sources are declared together. `register` and `register_arc` still panic on duplicate source registration; use `try_register` or `try_register_arc` if setup should return an error. Use `replace`, `replace_arc`, `try_replace`, or `try_replace_arc` only when overwriting a source is intentional.

If a leader task for an in-flight fact load is cancelled or panics, the session caches `FactLoadError::LoaderCancelled` for the affected keys. This poisons those keys for the rest of that request-scoped session so authorization fails closed and waiters do not hang. Retry with a fresh session.