# `Mailglass.Tenancy`
[🔗](https://github.com/szTheory/mailglass/blob/v0.1.0/lib/mailglass/tenancy.ex#L1)

Tenancy behaviour + process-dict helpers.

Adopters implement `@behaviour Mailglass.Tenancy` and configure it via:

    config :mailglass, tenancy: MyApp.Tenancy

The behaviour exposes ONE callback: `scope/2`. Non-callback helpers
live on this module for tenant-context plumbing.

## Default resolver

`Mailglass.Tenancy.SingleTenant` is the shipped default — a no-op
`scope/2` and a `"default"` literal tenant_id from `current/0` when
no stamping has occurred.

## Process-dict convention (D-30)

`put_current/1` writes `tenant_id :: String.t()` under the
namespaced key `:mailglass_tenant_id`. `current/0` reads it. The
`with_tenant/2` block form wraps + restores. `tenant_id!/0` raises
`Mailglass.TenancyError` when the key is unset — the fail-loud
variant for callers that assert they hold context.

## Phoenix 1.8 `%Scope{}` interop (D-32)

Core does NOT pattern-match `%Phoenix.Scope{}`. Adopters write a
two-line Plug / on_mount callback:

    def on_mount(_name, _params, _session, socket) do
      scope = socket.assigns.current_scope
      Mailglass.Tenancy.put_current(scope.organization.id)
      {:cont, socket}
    end

Documented in `guides/multi-tenancy.md` (Phase 7 DOCS-02).

# `resolve_webhook_tenant`
*optional* 

```elixir
@callback resolve_webhook_tenant(
  context :: %{
    provider: atom(),
    conn: Plug.Conn.t(),
    raw_body: binary(),
    headers: [{String.t(), String.t()}],
    path_params: map(),
    verified_payload: map() | nil
  }
) :: {:ok, String.t()} | {:error, term()}
```

Optional: resolve the tenant from a verified webhook context (D-12).

Called by `Mailglass.Webhook.Plug` AFTER `Provider.verify!/3` returns
`:ok` — D-13's "verify-first, tenant-second" ordering closes the
Stripe-Connect chicken-and-egg trap (a forged request cannot spoof its
way into a tenant's suppression list because the signature gate runs
on the global key material before any tenant-scoped work).

Adopters returning `{:ok, tenant_id}` stamp tenant context for the
rest of the ingest pipeline (normalize + persist + broadcast).
`{:error, reason}` causes `Mailglass.Webhook.Plug` to raise
`%Mailglass.TenancyError{type: :webhook_tenant_unresolved}` and
return HTTP 422.

## Context map fields

  * `:provider` — `:postmark | :sendgrid` (the atom the router
    dispatched against)
  * `:conn` — the `Plug.Conn` for header / IP / path-param
    introspection
  * `:raw_body` — verified raw bytes (signature passed before this
    callback fires)
  * `:headers` — `[{name, value}]` list as produced by the verifier
  * `:path_params` — adopter route's path params
    (e.g. `%{"tenant_id" => "..."}`)
  * `:verified_payload` — `nil` at v0.1 (the plug has not yet decoded
    the JSON payload at callback time). v0.5 may set this to
    `Jason.decode!/1` of `:raw_body` to support Stripe-Connect-style
    strategies that inspect the provider event's `account` field
    post-verify.

## Examples

    # SingleTenant default — everything is "default"
    def resolve_webhook_tenant(_), do: {:ok, "default"}

    # Per-shop Shopify-style header lookup
    def resolve_webhook_tenant(%{headers: headers}) do
      case List.keyfind(headers, "x-shopify-shop-domain", 0) do
        {_, shop_domain} -> {:ok, shop_domain}
        nil -> {:error, :missing_shop_domain}
      end
    end

Adopters not implementing this callback get SingleTenant default
behaviour (`{:ok, "default"}`) via the dispatcher's
`function_exported?/3` fallback. `Mailglass.Tenancy.ResolveFromPath`
ships as opt-in sugar for URL-prefix tenant resolution.

# `scope`

```elixir
@callback scope(queryable :: Ecto.Queryable.t(), context :: term()) :: Ecto.Queryable.t()
```

# `tracking_host`
*optional* 

```elixir
@callback tracking_host(context :: term()) :: {:ok, String.t()} | :default
```

Optional: return a per-tenant tracking host override (D-32).

Default adopter resolution: `:default` (use the global
`config :mailglass, :tracking, host:` value). Adopters returning
`{:ok, host}` get per-tenant subdomains (`track.tenant-a.example.com`)
for strict cookie/origin isolation.

# `assert_stamped!`
*since 0.1.0* 

```elixir
@spec assert_stamped!() :: :ok
```

Raises `%Mailglass.TenancyError{type: :unstamped}` if no tenant is
stamped in the current process. Returns `:ok` otherwise.

Unlike `current/0`, does NOT fall back to the `SingleTenant` default.
This is the SEND-01 precondition (D-18) — ensures
`Events.append_multi/3` auto-capture via `Tenancy.current/0` does not
silently default to `"default"` in a multi-tenant adopter.

# `audit_unscoped_bypass`
*since 0.1.0* 

```elixir
@spec audit_unscoped_bypass(keyword() | map()) :: :ok
```

Emits an audit breadcrumb when a call intentionally opts into
`scope: :unscoped` access.

This keeps the bypass path explicit and machine-searchable for TENANT-03
reviews.

# `clear`
*since 0.1.0* 

```elixir
@spec clear() :: :ok
```

Clear any tenant scope set in the current process dictionary.

Returns `:ok`. Primarily used by test `on_exit` cleanup — encapsulates
the internal process-dict key so tests don't need to know the exact
atom (`:mailglass_tenant_id`). Production code should rely on
`with_tenant/2` block scoping (Pitfall 7), which auto-restores prior
scope even on raise.

# `current`
*since 0.1.0* 

```elixir
@spec current() :: String.t() | nil
```

Returns the current tenant_id or the configured resolver's default.

Reads the process-dict first; falls back to the configured resolver's
default when nothing has been stamped. With the default
`Mailglass.Tenancy.SingleTenant` resolver active and no explicit
stamping, returns the literal `"default"`. With an adopter resolver
configured, returns `nil` unless the adopter's own fallback is wired
in via `put_current/1`.

# `put_current`
*since 0.1.0* 

```elixir
@spec put_current(String.t() | nil) :: :ok
```

Stamps the current tenant in the process dictionary.

Subsequent `current/0` calls return this value (until the process
exits or `put_current/1` is called again). Passing `nil` deletes the
stamp; `current/0` then falls back to the resolver's default.

# `resolve_webhook_tenant`
*since 0.1.0* 

```elixir
@spec resolve_webhook_tenant(map()) :: {:ok, String.t()} | {:error, term()}
```

Dispatch to the configured tenancy module's `resolve_webhook_tenant/1`
callback (Phase 4 D-12 — the optional callback Plan 05 formally declares).

Returns `{:ok, tenant_id}` on success or `{:error, reason}` when the
adopter's tenancy module cannot map the verified webhook context to a
known tenant. `Mailglass.Webhook.Plug` rescues the latter as a 422 via
`%Mailglass.TenancyError{type: :webhook_tenant_unresolved}`.

The `context` map shape is documented in CONTEXT D-12:

    %{
      provider: :postmark | :sendgrid,
      conn: Plug.Conn.t(),
      raw_body: binary(),
      headers: [{String.t(), String.t()}],
      path_params: map(),
      verified_payload: map() | nil
    }

## Fallback behaviour

`Mailglass.Tenancy.SingleTenant` ships a concrete
`resolve_webhook_tenant/1` impl that returns `{:ok, "default"}` — the
zero-config single-tenant default. Adopter tenancy modules that do
not implement the optional callback also fall through to
`{:ok, "default"}` via the dispatcher's `function_exported?/3`
check; multi-tenant adopters MUST implement the callback to get
meaningful tenant routing.

# `scope`
*since 0.1.0* 

```elixir
@spec scope(Ecto.Queryable.t(), term()) :: Ecto.Queryable.t()
```

Scopes `queryable` to the current (or supplied) tenant context via
the configured resolver.

With `Mailglass.Tenancy.SingleTenant`, this is a no-op. With an
adopter resolver, this injects a `WHERE tenant_id = ?` clause (or
equivalent) into the query.

# `tenant_id!`
*since 0.1.0* 

```elixir
@spec tenant_id!() :: String.t()
```

Returns the current tenant_id or raises `Mailglass.TenancyError`.

Unlike `current/0`, this does NOT fall back to the SingleTenant
default. Use this when the caller is certain it holds tenant context
(e.g. inside an Oban worker after the middleware has run) and wants
to fail loud on the "forgot to stamp" programmer error.

# `with_tenant`
*since 0.1.0* 

```elixir
@spec with_tenant(String.t(), (-&gt; any())) :: any()
```

Runs `fun` with `tenant_id` stamped as the current tenant, then
restores whatever was stamped before (nil if nothing).

Useful for tests and for Oban middleware serializing context across
job boundaries (see `Mailglass.Oban.TenancyMiddleware`). The prior
value is restored even when `fun` raises.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
