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.Plugowns request-path capture context.Threadline.Jobowns serialized job-path context.Threadline.Integrations.*owns soft-loaded reference adapters.threadline_operator_surface/2owns the operator-surface mount boundary, withauthorize_fnand optionalexport_authorize_fncovering 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/1The request-path contract is:
actor_fnis the only actor-authority callback. It decidesaudit_context.actor_refand may return anActorRefornil.context_overrides_fnis additive-only. It may return a map containing only:request_idand:correlation_id.Threadline.Plugextractsrequest_id,correlation_id, andremote_ipfirst, then fills only missingrequest_id/correlation_idfields fromcontext_overrides_fn.- Unknown override keys and non-map returns fail closed with
ArgumentError. - Proxy-aware IP normalization stays host-owned. Normalize
conn.remote_ipbeforeThreadline.Plugruns 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)
endThe job-path contract is:
"actor_ref"storesThreadline.Semantics.ActorRef.to_map/1output.Threadline.Job.actor_ref_from_args/1reads that serialized map back into anActorRef.Threadline.Job.context_opts/2extracts 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, andactor_fn/0
plug Threadline.Plug,
actor_fn: Threadline.Integrations.Sigra.actor_fn(),
context_overrides_fn: &Threadline.Integrations.Sigra.audit_context_overrides_from_conn/1These 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/1def 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
endThreadline.OperatorSurface.Auth treats these results as the public contract:
:okortruegrants access{:ok, scope}grants access and storesscopein: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/1When 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.