Integration Contracts

Copy Markdown View Source

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

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.

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:

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

threadline_operator_surface "/audit",
  repo: MyApp.Repo,
  authorize_fn: &MyApp.Audit.authorize_operator/1
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:

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:

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.