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.Sigraowns the supported soft-loaded reference adapter.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.
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:
capture-only—Threadline.Plugand core APIs only; stop after the request-path section below.phoenix-surface— optional Phoenix deps plusthreadline_operator_surface/2; the operator-surface mount is required.phx-gen-auth-reference— generated session auth (mix phx.gen.auth) wired intoThreadline.Plug; proof path inguides/integrations/phx-gen-auth.md.sigra-reference—Threadline.Integrations.Sigracomposed intoThreadline.Plug; proof path in the example app andguides/integrations/sigra.md.
For claim types, proof commands, version lifecycle, and the full matrix, see
guides/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].
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 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.
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". Integer IDs become strings; existing strings andnilretain their values.context_opts/2accepts onlyThreadline.record_action/2options plus:correlation_idand:job_idoverrides. Integer overrides are converted to strings. Unsupported keys, malformed IDs, non-keyword extras, and values outside the supported option types raiseArgumentError.- 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{}.
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:
:actionpresent → correlation-ready — the helper callsThreadline.record_action/2and linksaudit_transactions.action_idso strict:correlation_idfilters onThreadline.timeline/2match.:actionabsent → capture-only — row capture andactor_refonaudit_transactionsstill work; strict:correlation_idtimeline/export filters will not match (document this at code review).actor_refrequired unlessallow_missing_actor: trueon capture-only paths (non-recommended for multi-tenant SaaS).- Callback must not call
set_configforthreadline.actor_ref,Threadline.record_action/2, or nestedRepo.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, andactor_fn/0
plug Threadline.Plug,
actor_fn: Threadline.Integrations.Sigra.actor_fn(),
context_overrides_fn: &Threadline.Integrations.Sigra.audit_context_overrides_from_conn/1This 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:
def scope_operator_query(query, _scope, _context), do: where(query, false)Threadline applies these fail-closed rules to every surface above:
- no
:scopeand no:scope_query_fnstays unscoped — scoping is opt-in. :scopeset tonilwith ascope_query_fnconfigured still stays unscoped, and the function is never called. This is the host's explicit unscoped authorization — for example, anauthorize_fnthat returns:okfor admins while the mount still configuresscope_query_fnfor the tenant-scoped case.- a non-nil
:scopewith noscope_query_fn, or with ascope_query_fnthat is not a 3-arity function, raisesArgumentError. 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.
threadline_operator_surface "/audit",
repo: MyApp.Repo,
authorize_fn: &MyApp.Audit.authorize_operator/1,
scope_query_fn: &MyApp.Audit.scope_operator_query/3def 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.
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:
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.
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 for the Adopt lane.
- Mount and authorize the screens with the Operator surface guide.
- If Sigra owns request authentication in your host, continue with the Sigra reference integration.