# `Sigra.Audit`
[🔗](https://github.com/sztheory/sigra/blob/v1.20.0/lib/sigra/audit.ex#L1)

Structured audit logging for Sigra.

See `.planning/phases/09-audit-logging/09-CONTEXT.md` for the 28 decisions
that shape this module. Summary:

- Direct `Ecto.Multi` writes (D-01) — **not** telemetry subscribers (D-02)
- Public API enforces reserved prefixes (D-17..D-18); internal
  `__log_internal__/3` bypasses this check for library-owned events
- Metadata size cap 8KB default (D-20); forbidden keys rejected (D-23)
- Cursor pagination, no offset, or-expanded tiebreak (D-13)
- Telemetry passthrough `[:sigra, :audit, :log]` on successful commit (D-24)

## Telemetry responsibility for `log_multi/3` and `log_multi_safe/3`

Standalone `log/3` fires telemetry automatically from its `{:ok, _}` branch.

Callers of `log_multi/3` / `log_multi_safe/3` / `__log_internal__/3` own the
enclosing `repo.transaction/1` and must invoke
`Sigra.Audit.emit_telemetry_from_changes/2` from the `{:ok, changes}` branch.
This guarantees telemetry NEVER fires when the enclosing transaction rolls
back. See Plan 03 integration sites for examples.

By default `emit_telemetry_from_changes/2` looks for the `:audit` step only
(the default `Ecto.Multi` operation name used when `:audit_multi_step` is
omitted). When you append **multiple** internal audit inserts, pass a distinct
`:audit_multi_step` atom per `log_multi_safe/3` / `__log_internal__/3` call,
then pass the same atoms as the second argument to `emit_telemetry_from_changes/2`
so each committed audit row emits `[:sigra, :audit, :log]` exactly once.

# `opts`

```elixir
@type opts() :: keyword()
```

# `cleanup`

```elixir
@spec cleanup(keyword()) :: :ok
```

Deletes audit events older than the configured retention window.

# `count`

```elixir
@spec count(keyword(), keyword()) :: non_neg_integer()
```

Returns the number of rows matching the given filters.

# `emit_telemetry_from_changes`

```elixir
@spec emit_telemetry_from_changes(map(), [atom()]) :: :ok
```

Emits `[:sigra, :audit, :log]` telemetry for committed audit rows in an
`Ecto.Multi` success `changes` map.

`audit_steps` is the ordered list of `Ecto.Multi` operation names used with
`:audit_multi_step` (defaulting to a single `:audit` insert). For each step
that resolves to a struct in `changes`, emits one telemetry event.

Callers must invoke this only from the `{:ok, changes}` branch of their own
`repo.transaction/1` so telemetry never fires on rollback.

# `list`

```elixir
@spec list(keyword(), keyword()) :: %{
  entries: [struct()],
  next_cursor: String.t() | nil
}
```

Returns a cursor-paginated result map.

Options:
  * `:repo` (required)
  * `:limit` — default 50, capped at 500
  * `:cursor` — opaque Base64URL cursor from a previous result

# `log`

```elixir
@spec log(String.t(), opts()) :: {:ok, struct()} | {:error, Ecto.Changeset.t()}
```

Writes a single audit event in its own transaction/insert.

Returns `{:ok, event}` on success, `{:error, changeset}` on validation
failure. Fires `[:sigra, :audit, :log]` telemetry exactly once on success,
never on failure.

# `log_multi`

```elixir
@spec log_multi(Ecto.Multi.t(), String.t(), opts()) :: Ecto.Multi.t()
```

Appends an `:audit` step to an existing `Ecto.Multi`.

Raises `ArgumentError` at composition time if `action` uses a reserved
Sigra prefix — developers must not log library-owned events. Internal
callers must use `__log_internal__/3` instead.

Callers own the enclosing `repo.transaction/1` call. To emit telemetry on
successful commit, invoke `emit_telemetry_from_changes/1` in the
`{:ok, changes}` branch after the transaction returns. Telemetry MUST NOT
fire when the enclosing transaction rolls back.

# `log_multi_safe`

```elixir
@spec log_multi_safe(Ecto.Multi.t(), String.t(), opts()) :: Ecto.Multi.t()
```

Safe Multi-append for library-internal integration sites.

Returns the multi unchanged when `:audit_schema` is nil or absent.
Otherwise appends an audit insert step via `__log_internal__/3`.

## Options

- `:audit_multi_step` — `Ecto.Multi` operation name for this insert (default
  `:audit`). Use distinct atoms when composing **multiple** audit rows in one
  transaction (for example `mfa.verify.success` + `mfa.backup_code_used`).

      multi =
        Ecto.Multi.new()
        |> Sigra.Audit.log_multi_safe(
          "mfa.verify.success",
          Keyword.merge(opts, audit_multi_step: :audit_mfa_verify)
        )
        |> Sigra.Audit.log_multi_safe(
          "mfa.backup_code_used",
          Keyword.merge(opts, audit_multi_step: :audit_mfa_backup)
        )

      {:ok, changes} = repo.transaction(multi)
      Sigra.Audit.emit_telemetry_from_changes(changes, [:audit_mfa_verify, :audit_mfa_backup])

- `:target_resolver` — arity-1 callback receiving the Multi `changes`
  map (same as `:actor_resolver`) to populate `target_id` when it cannot be
  known at composition time.
- `:organization_id_resolver` / `:effective_user_id_resolver` — same arity-1
  `changes` callbacks for scope-derived columns when the subject row is only
  known after an earlier Multi step (e.g. email-change confirm).

# `log_safe`

```elixir
@spec log_safe(String.t(), opts()) :: :ok
```

Safe standalone log for library-internal integration sites.

No-ops (returns `:ok`) when `:audit_schema` is nil or absent. This lets
integration call sites (Plan 03) add audit writes without breaking host
apps that have not configured an audit schema. Always bypasses the
reserved-prefix check (library-owned events).

Returns `:ok` in all cases (successful insert, disabled, or insert error)
because integration call sites must not change their return shape on
audit failure. Errors are logged via telemetry metadata on the separate
`[:sigra, :audit, :log_safe_error]` event for observability.

# `log_safe`

```elixir
@spec log_safe(String.t(), scope :: term() | nil, opts()) :: :ok
```

Library-internal safe audit emission with optional scope.

`scope` is the second positional argument — mirrors the Phoenix 1.8
scopes idiom. Pass `nil` explicitly for pre-authentication or truly
anonymous call sites.

Scope is duck-typed on `%{user, active_organization, impersonating_from}` —
it does NOT pattern-match on `%Sigra.Scope{}` because that struct is
generated into the host app, not defined in the library.

`effective_user_id` is the authenticated principal (or v1.2 impersonation
target); `target_id` is the subject of the event. They diverge for
anonymous-actor events (failed login, magic link request) and for admin
actions on other users (v1.2).

Caller-supplied `:organization_id` / `:effective_user_id` / `:actor_id`
in `opts` always win over scope-derived values (D-06 caller-wins merge).

# `query`

```elixir
@spec query(keyword()) :: Ecto.Query.t()
```

Returns a composable `Ecto.Query` filtered by the given keyword filters.

Required key: `:audit_schema`. Supported filters are listed in
`Sigra.Audit.Query`.

# `stream`

```elixir
@spec stream(keyword(), keyword()) :: Enumerable.t()
```

Returns an `Enumerable.t()` suitable for use inside the caller's
`repo.transaction/1` block.

The repo must implement `stream/1`. Raises `ArgumentError` otherwise so
that large audit tables can never be loaded entirely into memory via a
silent `repo.all/1` fallback. Callers without a streaming repo should use
`list/2` (cursor-paginated) instead.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
