# 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.