# Attribution Snapshot Contract

Per-turn runtime attribution records which context fed into each assistant
response. The snapshot is built by `services/attribution.py`, persisted to
`messages.metadata["attribution"]`, and emitted as an `attribution` SSE
event after every turn.

## Section overview

| Section | Authoritative source | Populated by |
|---|---|---|
| `turns` | `prompt_meta["context_turns"]` | `cli/repl.py` `_build_context_turns` · `routers/chat.py` history walk |
| `memory` | `prompt_meta["memory_recall_items"]` | `services/memory_recall.py` → interface layer |
| `sources` | `prompt_meta["rag_sources"]` | `services/rag.py` → interface layer |
| `tools` | `turn_tool_calls` kwarg (authoritative) or stored message `tool_calls` (fallback) | `renderer._current_turn_tools` (CLI) · `_turn_tool_calls` accumulator (web) |
| `packs` | intersection of `pack_list` × `prompt_meta["pack_attachments"]` | `services/packs.list_packs` + `services/pack_attachments.list_attachments` |
| `instructions` | `prompt_meta["instruction_files"]` | `cli/repl.py` `_load_instructions_with_trust` · `routers/chat.py` `_build_chat_system_prompt` |

## Truncation and canonical counts (#1473)

Each section is capped at `SECTION_CAP = 20` items. When a section exceeds
the cap, the list ends with a single sentinel `{"truncated": N}` so the
total serialised snapshot stays under ~8 KB.

Before #1473 all three renderers derived display counts from `len(section_list)`.
With a 30-item section the list has 21 entries (20 real + 1 sentinel) — so the
footer displayed "21 tools" when the real count was 30.

Since #1473 the serialised snapshot carries canonical integer count fields:

| Count field | Section | Value |
|---|---|---|
| `turns_count` | `turns` | real item count before truncation |
| `memory_count` | `memory` | real item count before truncation |
| `sources_count` | `sources` | real item count before truncation |
| `tools_count` | `tools` | real item count before truncation |
| `packs_count` | `packs` | real item count before truncation |
| `instructions_count` | `instructions` | real item count before truncation |

These are computed by `_real_count(capped_list)` inside `build_attribution`
and included in the `asdict` output of `serialize_attribution`.

## Renderer call sites

| Renderer | File | Count source |
|---|---|---|
| CLI turn footer | `cli/renderer.py` `render_attribution_footer` | `cli/attribution_counts._attribution_count` |
| CLI `/attribution` detail header | `cli/attribution_render.py` `render_attribution_detail` | `cli/attribution_counts._attribution_count` |
| Web UI footer summary | `static/js/chat.js` `_addAttributionFooter` | `_attrCount(snapshot, section)` |

All three prefer the canonical `<section>_count` field and fall back to the
truncation-aware computation for older persisted snapshots that pre-date #1473.

## Fallback for old snapshots

The fallback in `_attribution_count` (Python) and `_attrCount` (JavaScript):

```
if section_count field present → use it
else:
    items = snapshot[section] or []
    last = items[-1]
    truncated_n = last["truncated"] if sentinel else 0
    count = len(items) - (1 if truncated_n else 0) + truncated_n
```

This means pre-#1473 snapshots that have no count fields but *do* have a
truncation sentinel still display the correct total. Snapshots without a
sentinel display `len(items)` which is also correct (no truncation occurred).

## Parity between CLI and web

Both interfaces populate `prompt_meta` with the same keys before calling
`build_attribution`. Key differences:

- **Tools section**: the CLI uses `renderer._current_turn_tools` (accumulated
  across every LLM iteration); the web uses `_turn_tool_calls` (a local list
  in `_stream_chat_events` also accumulated across iterations). Both pass the
  complete per-turn list via the `turn_tool_calls` kwarg (#1472).
- **Instructions section**: the CLI passes `instruction_files` via
  `_load_instructions_with_trust` (trust-gated); the web passes them via
  `_build_chat_system_prompt` (no interactive trust gate — tracked separately).
- **Pack attachments**: both call `pack_attachments.list_attachments` with the
  space/project scope appropriate to the current session.

Any key present in one interface's `prompt_meta` and absent from the other's
is a parity gap. Known gaps should be tracked as follow-up issues labelled
`enhancement`.
