# Gate

Async runtime feature gate. Composes [30-license](30-license.md), [33-lifecycle](33-lifecycle.md), and a caller-supplied local consumption hook into a single `check(feature)` / `require(feature)` API.

```rust
use akira_billing::{Gate, GateOptions, GateError, GateDenied, FeatureAccess};
```

## Type aliases

```rust
pub type LicenseLoader = Arc<
    dyn Fn() -> BoxFuture<'static, Result<Option<(SignedLicense, LicenseSnapshotPayload)>, Error>>
        + Send + Sync,
>;

pub type LocalConsumption =
    Arc<dyn Fn(String) -> BoxFuture<'static, Result<u64, Error>> + Send + Sync>;

pub type NowFn = Arc<dyn Fn() -> DateTime<Utc> + Send + Sync>;
```

All closures are `Send + Sync` so the gate is itself `Send + Sync`. The `BoxFuture` is `'static` — the closures own everything they need (use `move` and `Arc` for shared state).

## GateOptions

```rust
pub struct GateOptions {
    pub loader: Option<LicenseLoader>,
    pub local_consumption: Option<LocalConsumption>,
    pub grace_window: Duration,
    pub now: Option<NowFn>,
}

impl Default for GateOptions {
    fn default() -> Self { /* loader: None, grace_window: zero, ... */ }
}
```

| Field | Behaviour |
|-------|-----------|
| `loader` | When `None`, every `check` returns an access with `reason = "no_loader"`. Always pass one in production. |
| `local_consumption` | Defaults to `|_| 0u64`. Returns the in-memory tally since the snapshot was issued. |
| `grace_window` | `Duration` (chrono). Convert from `payload.offline_grace_days * Duration::days(1)`. |
| `now` | Defaults to `Utc::now`. Inject for tests. |

## Construction

```rust
let gate = Gate::new(GateOptions {
    loader: Some(Arc::new(move || {
        let store = store.clone();
        Box::pin(async move {
            let raw = store.read("license.json").await?;
            let Some(signed) = raw else { return Ok(None); };
            let decoded = decode_license(&signed)?;
            Ok(Some((signed, decoded.payload)))
        })
    })),

    local_consumption: Some(Arc::new(move |feature: String| {
        let counter = counter.clone();
        Box::pin(async move { counter.peek(&feature).await })
    })),

    grace_window: Duration::days(7),
    now: None,
});
```

The clones inside the closure are essential — every invocation builds a fresh future.

## FeatureAccess

```rust
pub struct FeatureAccess {
    pub feature: String,
    pub allowed: bool,
    pub has_feature: bool,
    pub unlimited: bool,
    pub remaining: u64,
    pub reason: String,
    pub plan: String,
    pub state: LicenseState,
}
```

Codes (`reason`) when `allowed == false`:

| Code | Cause |
|------|-------|
| `no_loader` | `GateOptions::loader` was `None` |
| `no_license` | Loader returned `Ok(None)` |
| `license_invalid` | `compute_state` returned `LicenseState::Invalid` |
| `license_expired` | `compute_state` returned `LicenseState::Expired` |
| `feature_disabled` | `payload.features[feature] == false` |
| `feature_missing` | Feature absent from both `features` and `usage` |
| `limit_reached` | Counter quota exhausted |
| `verify_failed` | Loader returned `Err` — the underlying `Error` is propagated |
| `local_consumption_failed` | `local_consumption` returned `Err` — propagated |

## check

```rust
pub async fn check(&self, feature: &str) -> Result<FeatureAccess, Error>;
```

Always returns `Ok(access)` except when the loader or `local_consumption` propagates a real `Error` (network failure, deserialization). Branch on `access.allowed`.

```rust
let access = gate.check("agent_run").await?;
if !access.allowed {
    toast(&access.reason);
    return Ok(());
}
run_agent().await?;
counter.bump("agent_run", 1).await?;
```

## require

```rust
pub async fn require(&self, feature: &str) -> Result<FeatureAccess, GateError>;
```

Returns `Err(GateError::Denied(GateDenied(access)))` when the gate denies. `GateError::Other(Error)` for upstream propagation.

```rust
match gate.require("agent_run").await {
    Ok(_)                                 => run_agent().await?,
    Err(GateError::Denied(d)) => show_paywall(&d.0.reason, d.0.remaining),
    Err(GateError::Other(err))            => return Err(err.into()),
}
```

## Concurrency

`Gate` holds an internal `tokio::sync::Mutex`. Concurrent calls to `check` serialize through it — safe to call from multiple tasks without race conditions on the loader. If the loader does its own caching, the mutex is essentially free.

The gate is `Send + Sync`; wrap in `Arc<Gate>` and clone the `Arc` across tasks.

## Feature presence rules

Resolution mirrors the JS SDK:

1. Look up `payload.features[feature]`:
   - `false` → `feature_disabled`.
   - `true` → mark `has_feature: true`, continue.
   - absent → leave `has_feature: false`, continue.
2. Compute `compute_remaining(payload, feature, consumed_local)`:
   - `None` + `has_feature: true` → unlimited (allowed).
   - `None` + `has_feature: false` → `feature_missing`.
   - `Some(Unlimited)` → unlimited (allowed, sets `has_feature: true`).
   - `Some(Finite(n))` with `n > 0` → allowed, `remaining = n`.
   - `Some(Finite(0))` → `limit_reached`.

A boolean-only feature (`payload.features` flag, no `usage` entry) is admitted when the flag is `true` and denied when it's `false`.

## Testing pattern

Inject `now` and a synthetic loader — no network mocks needed.

```rust
use chrono::TimeZone;

let payload = LicenseSnapshotPayload { /* … */ };

let gate = Gate::new(GateOptions {
    loader: Some(Arc::new(move || {
        let payload = payload.clone();
        let signed = signed.clone();
        Box::pin(async move { Ok(Some((signed, payload))) })
    })),
    local_consumption: Some(Arc::new(|_| Box::pin(async { Ok(5u64) }))),
    grace_window: Duration::days(7),
    now: Some(Arc::new(|| Utc.with_ymd_and_hms(2026, 5, 19, 10, 0, 0).unwrap())),
});

let access = gate.check("agent_run").await?;
assert!(access.allowed);
assert_eq!(access.remaining, 2);
```

---

Navigation: [← License](30-license.md) · **Gate** · [Usage →](32-usage.md)
