guides/integration-contracts.md

# Integration Contracts

Threadline exposes a small set of concrete integration seams. This guide is the
canonical breadth contract for those seams.

The contract is intentionally code-shaped:

- `Threadline.Plug` owns request-path capture context.
- `Threadline.Job` owns serialized job-path context.
- `Threadline.Integrations.*` owns soft-loaded reference adapters.
- `threadline_operator_surface/2` owns the operator-surface mount boundary, with
  `authorize_fn` and optional `export_authorize_fn` covering the LiveView and
  HTTP export faces.

Threadline does not introduce a separate adapter behaviour or umbrella protocol
here. These are the existing supported seams.

## Request path via `Threadline.Plug`

`Threadline.Plug` attaches `%Threadline.Semantics.AuditContext{}` to
`conn.assigns[:audit_context]`.

```elixir
plug Threadline.Plug,
  actor_fn: &MyApp.Audit.actor_ref_from_conn/1,
  context_overrides_fn: &MyApp.Audit.audit_context_overrides/1
```

The request-path contract is:

- `actor_fn` is the only actor-authority callback. It decides
  `audit_context.actor_ref` and may return an `ActorRef` or `nil`.
- `context_overrides_fn` is additive-only. It may return a map containing only
  `:request_id` and `:correlation_id`.
- `Threadline.Plug` extracts `request_id`, `correlation_id`, and `remote_ip`
  first, then fills only missing `request_id` / `correlation_id` fields from
  `context_overrides_fn`.
- Unknown override keys and non-map returns fail closed with `ArgumentError`.
- Proxy-aware IP normalization stays host-owned. Normalize `conn.remote_ip`
  before `Threadline.Plug` runs if your deployment needs it.

This seam does not provide a second actor channel. Additive metadata cannot
replace actor identity or `remote_ip`.

Capture-only adopters can stop here. `Threadline.Plug` plus the core APIs are
the strongest supported lane, and `mix verify.compile_no_optional` proves that
surface without optional Phoenix UI dependencies.

## Job path via `Threadline.Job`

`Threadline.Job` keeps background-job propagation explicit and serializable. It
is not a callback mini-framework and does not couple Threadline to any
particular job runner.

```elixir
args = %{
  "actor_ref" => Threadline.Semantics.ActorRef.to_map(actor_ref),
  "correlation_id" => correlation_id,
  "job_id" => job.id
}

with {:ok, actor_ref} <- Threadline.Job.actor_ref_from_args(args) do
  opts = Threadline.Job.context_opts(args)
  Threadline.record_action(:member_synced, [actor: actor_ref, repo: Repo] ++ opts)
end
```

The job-path contract is:

- `"actor_ref"` stores `Threadline.Semantics.ActorRef.to_map/1` output.
- `Threadline.Job.actor_ref_from_args/1` reads that serialized map back into an
  `ActorRef`.
- `Threadline.Job.context_opts/2` extracts stable context keys from the args
  map, currently `"correlation_id"` and `"job_id"`.
- Any broader worker-framework integration belongs in an adapter module if the
  pattern repeats; it is not standardized in core today.

## Reference integrations via `Threadline.Integrations.*`

`Threadline.Integrations.*` modules are reference adapters. They translate host
or framework state into the existing Threadline-native seams above.

`Threadline.Integrations.Sigra` is the current model:

- soft dependency gating stays inside the integration module via
  `Code.ensure_loaded?`
- absent host dependencies return neutral defaults rather than forcing hard
  coupling into core
- helpers stay direct and composable, such as `actor_ref_from_conn/1`,
  `audit_context_overrides_from_conn/1`, and `actor_fn/0`

```elixir
plug Threadline.Plug,
  actor_fn: Threadline.Integrations.Sigra.actor_fn(),
  context_overrides_fn: &Threadline.Integrations.Sigra.audit_context_overrides_from_conn/1
```

These modules are reference adapters, not framework ownership claims. A
`Threadline.Integrations.*` module should adapt host state into `Threadline.Plug`
or `Threadline.Job`; it should not redefine those contracts.

## Operator-surface composition via `authorize_fn` and `export_authorize_fn`

The operator surface is one breadth contract with two transport faces:

- LiveView mount/auth via `authorize_fn`
- HTTP export auth via optional `export_authorize_fn`

The host still owns authentication and authorization semantics. Threadline
standardizes where those hooks plug in; it does not define who the user is,
which roles exist, or how tenancy is modeled.

### Secure-by-default mount boundary

`threadline_operator_surface/2` is the supported mount boundary. It requires one
of these conditions at compile time:

- mount inside a router scope that already has `pipe_through`
- provide `:authorize_fn`
- explicitly acknowledge unauthenticated mounting

Anything outside that boundary is outside the supported surface story.

### Shared authorization vocabulary

`authorize_fn` is the canonical operator-surface callback. It is invoked
directly as a 1-arity function. The recommended callback shape is one shared
host-owned function that accepts `%{assigns: assigns}` so it can authorize both
the LiveView socket and the export fallback mirror without transport-specific
function heads.

```elixir
threadline_operator_surface "/audit",
  repo: MyApp.Repo,
  authorize_fn: &MyApp.Audit.authorize_operator/1
```

```elixir
def authorize_operator(%{assigns: assigns}) do
  case assigns[:current_user] do
    %{role: :admin} ->
      :ok

    %{role: :support, organization_id: org_id} ->
      {:ok, %{access: :support_read_only, organization_id: org_id}}

    _ ->
      {:error, :unauthorized}
  end
end
```

`Threadline.OperatorSurface.Auth` treats these results as the public contract:

- `:ok` or `true` grants access
- `{:ok, scope}` grants access and stores `scope` in `:threadline_scope`
- any other result denies access
- raised errors deny access

That `scope` is opaque and host-owned. Threadline carries it as data; it does
not define a role enum, permissions DSL, tenancy DSL, or page-level
authorization language around it.

`export_authorize_fn` is optional and should stay an advanced override. When
present, it is called with `conn` directly for export requests:

```elixir
threadline_operator_surface "/audit",
  repo: MyApp.Repo,
  authorize_fn: &MyApp.Audit.authorize_operator/1,
  export_authorize_fn: &MyApp.Audit.authorize_operator_export/1
```

When `export_authorize_fn` is absent, export auth deliberately falls back to
`authorize_fn` through a synthetic mirror:

```elixir
mirror = %{assigns: conn.assigns}
authorize_fn.(mirror)
```

That fallback is part of the public contract. If you want one host-owned
authorization function to cover both transport faces, write it against
`%{assigns: assigns}` rather than LiveView-only helpers. If you need a stricter
export posture than the mounted surface, provide `export_authorize_fn` as a
deliberate override rather than teaching two primary authorization vocabularies.

Both transport faces share the same telemetry event
`[:threadline, :operator_surface, :authorize]`, the same granted/denied/error
result vocabulary, and the same `:threadline_scope` assign semantics.

## Canonical references

- Request path and additive override validation: `lib/threadline/plug.ex`
- Job-path serialized context helpers: `lib/threadline/job.ex`
- Soft-loaded reference adapter model: `lib/threadline/integrations/sigra.ex`
- Secure operator-surface mount boundary: `lib/threadline/operator_surface/router.ex`
- LiveView auth hook: `lib/threadline/operator_surface/auth.ex`
- HTTP export auth parity and fallback mirror: `lib/threadline/operator_surface/export_auth_plug.ex`

Use this guide when you need the host/framework breadth contract. Use
`guides/operator-surface.md` for the screen-level mount walkthrough and
`guides/integrations/sigra.md` for the current first-party reference adapter.