Current section

Files

Jump to
threadline guides integration-contracts.md
Raw

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.Sigra` owns the supported soft-loaded reference
  adapter.
- `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.

## Adoption lanes and integration seams

Threadline adoption maps to four lane IDs in canonical order. Each lane names
which seams from this guide you need:

1. **`capture-only`** — `Threadline.Plug` and core APIs only; stop after the
   request-path section below.
2. **`phoenix-surface`** — optional Phoenix deps plus
   `threadline_operator_surface/2`; the operator-surface mount is required.
3. **`phx-gen-auth-reference`** — generated session auth (`mix phx.gen.auth`)
   wired into `Threadline.Plug`; proof path in
   `guides/integrations/phx-gen-auth.md`.
4. **`sigra-reference`** — `Threadline.Integrations.Sigra` composed into
   `Threadline.Plug`; proof path in the example app and
   `guides/integrations/sigra.md`.

For claim types, proof commands, version lifecycle, and the full matrix, see
[`guides/upgrade-path.md`](upgrade-path.md) — do not duplicate that table
here.

## 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 on the **`capture-only`** lane 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"`. Integer IDs become
  strings; existing strings and `nil` retain their values.
- `context_opts/2` accepts only `Threadline.record_action/2` options plus
  `:correlation_id` and `:job_id` overrides. Integer overrides are converted
  to strings. Unsupported keys, malformed IDs, non-keyword extras, and values
  outside the supported option types raise `ArgumentError`.
- Any broader worker-framework integration belongs in an adapter module if the
  pattern repeats; it is not standardized in core today.

## Audited write path via `Threadline.Audit`

`Threadline.Audit.transaction/3` is the recommended helper for domain writes that need
capture and optional semantic action linkage in one database transaction.

`Threadline.Plug` and `Threadline.Job` remain edge context producers — call the helper
from Phoenix context modules (or workers) after resolving `%Threadline.Semantics.AuditContext{}`
and `%Threadline.Semantics.ActorRef{}`.

```elixir
Threadline.Audit.transaction(
  Repo,
  [
    audit_context: audit_context,
    action: :post_created_via_api,
    transaction_meta: %{"organization_id" => org_id}
  ],
  fn ->
    Repo.insert!(Post.changeset(%Post{}, attrs))
  end
)
```

Contract bullets:

- **`:action` present → correlation-ready** — the helper calls `Threadline.record_action/2`
  and links `audit_transactions.action_id` so strict `:correlation_id` filters on
  `Threadline.timeline/2` match.
- **`:action` absent → capture-only** — row capture and `actor_ref` on
  `audit_transactions` still work; strict `:correlation_id` timeline/export filters
  **will not match** (document this at code review).
- **`actor_ref` required** unless `allow_missing_actor: true` on capture-only paths
  (non-recommended for multi-tenant SaaS).
- **Callback must not** call `set_config` for `threadline.actor_ref`, `Threadline.record_action/2`,
  or nested `Repo.transaction/1` — the helper owns GUC, action recording, and linkage.

## Reference integration via `Threadline.Integrations.Sigra` (`sigra-reference` lane)

`Threadline.Integrations.Sigra` is the reference adapter for the
**`sigra-reference`** lane. It translates 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
```

This module is a reference adapter, not a framework ownership claim. It adapts
host state into `Threadline.Plug`; it does not redefine the request or job
contracts.

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

The operator surface is required for the **`phoenix-surface`** lane and for
reference lanes that mount `threadline_operator_surface/2`. It is one breadth
contract with two transport faces:

- LiveView mount/auth via `authorize_fn`
- scoped investigation queries via optional `scope_query_fn`
- mounted evidence access via optional `evidence_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.

That same boundary applies to the evidence plane. Evidence writes are
host-owned via `Threadline.Evidence` `record_*`; Threadline does not
auto-populate attestations from retention, health, or export paths. The
evidence plane captures governance and support-scope posture the host chooses
to record, but it does not introduce a Threadline-owned RBAC system, tenancy
DSL, approval workflow, legal-hold flow, or vendor-reporting suite.

When a host returns `{:ok, scope}` from `authorize_fn`, keep that scope
host-owned and pair it with `scope_query_fn` — a returned scope requires a
`scope_query_fn`, by tenant, organization, or another local concept; without
one, every scoped read raises `ArgumentError` rather than silently falling
back to an unscoped result. `scope_query_fn` is the
query seam for timeline, actor, transaction, export, or any future surface
your host explicitly proves. Threadline carries the scope through; it does
not invent a policy DSL around it.

### Scope surfaces and fail-closed rules

Every `scope_query_fn` sees a `context.surface` naming the binding shape it
must match against:

| Surface | Binding | Used by |
|---------|---------|---------|
| `:timeline` | `[ac, at]` | `Threadline.timeline/2`, `Threadline.timeline_page/2` |
| `:transaction` | `[ac, at]` | `Threadline.audit_changes_for_transaction/2`, the changes read inside `transaction_context/2` and `incident_bundle/2` |
| `:export` | `[ac, at]` | `Threadline.export_csv/2`, `Threadline.export_json/2` |
| `:row_history` | `[ac, at]` | `Threadline.row_history/3` (bounded and the deprecated unbounded read) |
| `:actor_history` | `[at]` | `Threadline.actor_history/2` |
| `:transaction_header` | `[at]` | the transaction row read inside `Threadline.audit_transaction/2`, `transaction_context/2`, and `incident_bundle/2` |

`:transaction_header` is easy to miss: it is a single-binding `[at]` query
over the bare `audit_transactions` row, not the `[ac, at]` shape the other
transaction-adjacent surfaces use. A `scope_query_fn` that only handles
`:timeline`, `:transaction`, `:export`, and `:row_history` and falls through
to a catch-all clause that returns the query unchanged will hand back
**another tenant's transaction row** the moment that fn is asked to scope
`:transaction_header`. End your `scope_query_fn` with a deny-all clause
instead:

```elixir
def scope_operator_query(query, _scope, _context), do: where(query, false)
```

Threadline applies these fail-closed rules to every surface above:

- no `:scope` and no `:scope_query_fn` stays unscoped — scoping is opt-in.
- `:scope` set to `nil` with a `scope_query_fn` configured still stays
  unscoped, and the function is never called. This is the host's explicit
  unscoped authorization — for example, an `authorize_fn` that returns `:ok`
  for admins while the mount still configures `scope_query_fn` for the
  tenant-scoped case.
- a non-nil `:scope` with no `scope_query_fn`, or with a `scope_query_fn`
  that is not a 3-arity function, raises `ArgumentError`. The message never
  echoes the scope value — it may hold tenant identifiers.

The two row-history reads share `:row_history`, and a `scope_query_fn` cannot
tell them apart.

Apply that same host-owned rule to `evidence_authorize_fn`: it gates the mounted
evidence capability, but it does not define a Threadline role model, tenant
policy, or blanket permission vocabulary.

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

When that mount also receives `actor_fn`, the public router installs the
actor-to-session bridge before the LiveView routes. That keeps actor-owned saved
views and similar UI features on the same `ActorRef` contract as request-path
capture without exposing internal plug wiring to the host application.

### 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,
  scope_query_fn: &MyApp.Audit.scope_operator_query/3
```

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

If both session actor data and a scope fallback actor are present, session actor
wins. Scope fallback stays compatibility-only, and mismatches emit observable
telemetry rather than silently replacing the session-owned actor identity. In
other words: session actor wins, scope fallback does not silently override 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.

For background exports, keep one actor-owned Threadline download route keyed by
the export job ID. Local storage resolves to `send_file` behind that boundary;
adapter-backed storage resolves `download_url/2` only after authorization. This
preserves one operator action while letting the host keep ownership of Oban
supervision and external storage infrastructure.

## Canonical references

- Request capture and additive overrides: `Threadline.Plug`
- Serialized background-job context: `Threadline.Job`
- Audited database writes: `Threadline.Audit`
- Soft-loaded Sigra composition: `Threadline.Integrations.Sigra`
- Secure operator-surface mount boundary: `Threadline.OperatorSurface.Router`
- LiveView authorization result handling: `Threadline.OperatorSurface.Auth`

## Next steps

- Return to [Getting started](getting-started-saas.md) for the Adopt lane.
- Mount and authorize the screens with the [Operator surface guide](operator-surface.md).
- If Sigra owns request authentication in your host, continue with the
  [Sigra reference integration](integrations/sigra.md).