# `Nous.Session.Invariant`
[🔗](https://github.com/nyo16/nous/blob/v0.17.1/lib/nous/session/invariant.ex#L1)

Every model-visible request must be reconstructable from the session log.

`Nous.Session.Log` is the source of truth and `ctx.messages` is a view over it
(see `Nous.Agent.Context`). That only buys fork, rewind, replay, non-destructive
compaction and audit if the list actually handed to a provider is the list the
fold produces — otherwise the log records a conversation the model never had,
and every feature built on replaying it is quietly wrong. This module is that
assertion, checked on the agent loop's request path.

## The rule, exactly

`check/2` walks the request and `Nous.Session.Log.derive_indexed/1` in lockstep
and requires, position for position, the same `role`, `content`, `tool_calls`,
`tool_call_id` and `name`. There are exactly two exemptions, and both are
assembly-time system-prompt state that is deliberately not in the log:

  1. **The overlay slot.** `Nous.Agent.Context.put_system_prompt_overlay/2`
     holds the fragment `Nous.AgentRunner.PromptAssembly` re-derives from agent
     config, plugins and skills on every run. `Context`'s `apply_overlay/2`
     either concatenates it onto the transcript's leading system message or,
     when the fold has no leading system message, **prepends a system message
     that no event produced**. So a leading `:system` message in the request
     facing a non-`:system` head in the fold is skipped, and that is the only
     length difference tolerated. `Nous.Agents.BasicAgent.build_messages/2`
     prepends `ctx.system_prompt` the same way when the transcript carries no
     system message at all; the same exemption covers it.
  2. **System content.** Where both sides hold a `:system` message, its content
     is not compared — only the role and the position. The overlay is
     concatenated into that content, and a behaviour may substitute its own
     assembled prompt wholesale (`Nous.Agents.ReActAgent` and
     `Nous.Agents.KnowledgeBaseAgent` reject the transcript's system messages
     and prepend a freshly built one). System text is re-derived per request, so
     the log cannot say what it should be; asserting on it would be asserting on
     agent config.

Everything else is exact. A user, assistant or tool message no event produced,
an event-derived message the request dropped, and the same messages in a
different order are all violations — including a *mid-transcript* system
message the request omitted, which is a genuine loss of model-visible history
(a `Nous.Plugins.Summarization` summary is a system message, and it stands in
for every event it shadowed).

`metadata` and `created_at` are not compared. They are not part of the request
ordering the log has to reproduce, and the request's messages are the fold's
own terms in the common case, so the fields that matter are compared by
pointer.

## The orphaned tool result

Checked on the request alone: a `:tool` message whose `tool_call_id` appears in
no **preceding** assistant message's `tool_calls`. This is the provider-400
class the whole boundary-balancing machinery exists to prevent, and it survives
a perfectly reconstructable request — a positional `{:replace, start, stop}`
over an unbalanced range shadows an assistant message and leaves its result
standing, so the *fold itself* is unbalanced. Any caller that picks its own
range can do this; `Nous.Plugins.Summarization` avoids it only because it runs
`Nous.Transcript.balance_tool_call_boundary/2` first.

Reconstruction is reported before pairing: if the request does not match the
fold, the pairing diagnosis is about a list we already know is wrong.

## What this actually catches

A **request builder** that invents or drops messages at assembly time — a
behaviour's `build_messages/2`, or anything else between the transcript and the
provider — plus the orphaned tool result above.

Notably *not* the legacy `%{ctx | messages: ...}` write D2 keeps alive:
`Nous.Agent.Context`'s `sync/1` reseeds the log from whatever the caller
assembled on the very next append, and the loop appends a `:step_start` before
every request, so by dispatch the fold agrees with the request again. That
write rewrites history rather than diverging from it, which is a different
(documented, transitional) sin and not one a request-time check can see.

## Modes

`config :nous, :session_invariant, :warn | :strict | :off` — default `:warn`.

  * `:warn` — emit `[:nous, :session, :invariant, :violation]` with the details
    as metadata and `Logger.warning/1`. **Never raises**, and that is the point:
    a user-supplied behaviour or plugin is entitled to assemble a request the
    transcript does not literally contain (dropping a mid-transcript system
    message is one of the shipped behaviours doing exactly that), and D2 keeps
    the legacy `%{ctx | messages: ...}` writers alive for at least one release.
    A checker that raised would take down a live, paid-for run over a
    bookkeeping discrepancy — wrong by construction.
  * `:strict` — telemetry, then raise `Nous.Session.Invariant.Violation`. This
    is for **our own test suite**, which is allowed to treat a divergence as a
    bug because it owns every writer. Do not turn it on in production.
  * `:off` — skip the check entirely, before any work. For hot paths that have
    measured the cost and decided against it.

## Cost

This runs on every request, so `check/2` is one O(n) pass over the message list
plus one O(n) pass for tool pairing, with no intermediate lists: the fold comes
from `derive_indexed/1`, which is memoized in the log, and the per-message
comparison hits `==` on terms the request and the fold physically share. Only a
violation allocates, and what it reports is bounded (see `t:details/0`).

## What is not checked

`Nous.Plugins.Summarization` asks the model to write a summary through
`Nous.ModelDispatcher` directly. That request is a slice of the transcript plus
an instruction, not the session's own request, and it is correctly invisible
here. `Nous.AgentRunner.RequestDispatch` documents the one other request path
that has no context in scope.

# `details`

```elixir
@type details() :: %{:kind =&gt; kind(), optional(atom()) =&gt; term()}
```

Telemetry metadata and `Violation` payload.

Reconstruction violations carry `:index` (the first diverging position),
`:request_length`, `:log_length` and `:extra`/`:missing`. The lengths and the
index are all measured after the overlay slot is skipped, so they line up with
each other. `:extra`/`:missing` are bounded lists of message *sketches*, not
messages, so a violation cannot dump a multimodal transcript into a log line.

`:orphaned_tool_result` carries the offending `:tool_call_id`.

# `kind`

```elixir
@type kind() :: :extra_message | :missing_message | :order | :orphaned_tool_result
```

What went wrong.

  * `:extra_message` — the request carries a message no event produced
  * `:missing_message` — the fold produced a message the request dropped
  * `:order` — the same messages, in a different order
  * `:orphaned_tool_result` — a `:tool` message with no preceding tool call

# `mode`

```elixir
@type mode() :: :off | :warn | :strict
```

`:warn` logs and emits telemetry, `:strict` raises, `:off` skips the check.

# `check`

```elixir
@spec check(Nous.Agent.Context.t(), [Nous.Message.t()]) ::
  :ok | {:violation, details()}
```

Compare a request against the fold of `ctx.log`. Pure.

Reads no configuration, logs nothing, emits nothing: `verify/2` is the side
effect. See the moduledoc for the exact rule and for what the two lists are
allowed to differ by.

## Examples

    iex> ctx = Nous.Agent.Context.new() |> Nous.Agent.Context.add_message(Nous.Message.user("hi"))
    iex> Nous.Session.Invariant.check(ctx, ctx.messages)
    :ok

    iex> ctx = Nous.Agent.Context.new() |> Nous.Agent.Context.add_message(Nous.Message.user("hi"))
    iex> {:violation, details} = Nous.Session.Invariant.check(ctx, [])
    iex> details.kind
    :missing_message

# `mode`

```elixir
@spec mode() :: mode()
```

The configured mode: `config :nous, :session_invariant`, `:warn` when unset.

A value that is neither a mode nor its string form falls back to the default
and warns once per VM, rather than raising: a typo in one config line must not
take down every run.

## Examples

    iex> Nous.Session.Invariant.mode() in Nous.Session.Invariant.modes()
    true

# `modes`

```elixir
@spec modes() :: [mode()]
```

The three valid modes.

# `verify`

```elixir
@spec verify(Nous.Agent.Context.t() | nil, [Nous.Message.t()]) :: :ok
```

Check the request, then act on the configured mode.

Returns `:ok` in every mode except `:strict`, where a violation raises
`Nous.Session.Invariant.Violation`. `:off` short-circuits before any work, and
a `nil` context (a request path with no session in scope) is a no-op.

## Examples

    iex> ctx = Nous.Agent.Context.new() |> Nous.Agent.Context.add_message(Nous.Message.user("hi"))
    iex> Nous.Session.Invariant.verify(ctx, ctx.messages)
    :ok

# `violation_event`

```elixir
@spec violation_event() :: [atom()]
```

The telemetry event a violation emits: `[:nous, :session, :invariant, :violation]`.

Measurements are `%{count: 1}`; metadata is `t:details/0` plus `:agent_name`
and `:mode`.

---

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