---
title: Architecture
description: Source, decoders, unified model and sinks
---

# Architecture

```
/opt/agent-sessions/<namespace>/**/*.jsonl
        │
        ▼
internal/source        discovery, tailing, per-file byte-offset checkpointing
        │
        ├── internal/decode/claude   ──┐
        └── internal/decode/codex    ──┤   raw records -> decoder events
                                       ▼
                             internal/reduce      events -> unified model
                                       │
                             internal/model       Session / Turn / Item   (THE SEAM)
                                       │
                             internal/redact      bounding, structural handling
                                       │
        ┌──────────────────────────────┼──────────────────────────────┐
        ▼                              ▼                              ▼
  sink/otlpmetric                sink/otlplog                   sink/otlptrace
        └──────────────────────────────┼──────────────────────────────┘
                                       ▼
                        localhost:4318  (camden Alloy)  ->  Grafana Cloud
```

`internal/attr` sits beside all three sinks and owns every name and every field's destination.
`internal/selfobs`, `internal/profile` (drift) and `internal/health` observe the pipeline itself.

## internal/source

Walks the namespace roots, tracks each file by identity and size, and reads forward from a
checkpointed byte offset.

- **Files grow.** A client checkpoints a transcript at most once every five minutes and on session
  end, so a file is appended to for the life of a session. Re-read from the stored offset, never
  from the start.
- **A file's absence proves nothing.** Hot retention prunes at 90 days after the NFS copy succeeds.
- **Never write into the tree.** No touch, no rename, no lock file. The checkpoint lives in
  `/var/lib/codingagent2otel/`.
- **Identify by content prefix, not by name.** A name can be reused; codexlb2otel learned this the
  hard way when an archive path was recreated as an unrelated capture.
- A partial trailing line is not an error. Stop at the last complete line and resume there.

## internal/decode

Two decoders, no shared code beyond the event type. Each turns raw records into ordered decoder
events and reports anything it does not recognise as `unhandled_record_types` rather than dropping
it silently.

Claude: one record per message, `parentUuid` chain, no native timing.
Codex: nested `type` + `payload.type`, native timing, duplicated item representations that must be
deduplicated on item id.

See [transcript formats](../transcript-formats.md).

## internal/model - the frozen seam

Both decoders produce this and all three sinks consume it. Freeze it before any parallel work
starts; changing it later invalidates every lane at once.

Three entities: `Session`, `Turn`, `Item`.

Two rules that are the whole point of the model:

1. **Provenance travels with the value.** Every duration and every timing carries a source
   (`native`, `derived`, `absent`). Every optional measurement is a pointer, so a measured zero and
   an absent measurement stay distinguishable.
2. **Two sources describing one idea stay two fields.** Never collapse them and never let one
   overwrite the other.

## internal/attr

Every metric name, every attribute key and every field's destination. Adding an instrument means
adding a constant here first. The dashboard generator validates against this package, so a metric
that is not declared here cannot be graphed.

## Sinks

Three independent sinks over one shared OTLP connection. A sink failure is counted and does not
stall the pipeline or the other sinks.

- `otlpmetric` reads only `DimMetric` fields.
- `otlplog` emits one record per content kind, with `AttrRecord` fields as log attributes and
  `Content` as the body.
- `otlptrace` builds the span tree, using Codex's native trace id where present.

## Drift

`internal/profile` induces a schema signature from a scan and diffs it against the committed
baseline. The baseline is content-free by construction and a test proves it. Findings are reported
by severity; breaking drift fails `ca2probe`, and the running daemon reports findings as a gauge
rather than exiting.

Codex record shapes vary by CLI version and a whole record type can disappear between versions
without any error. **An absent record type is a finding.**

## codex-lb enrichment (optional, off by default)

A Codex `token_usage_record.response_id` matches the `resp_...` id in codex-lb's conversation
archive, verified live against same-hour data. When enabled, the enricher adds engine, proxy, cost
and account context to a Codex turn.

It is optional on purpose. codex-lb is being retired, `model_provider` already varies between
`codex-lb` and `openai` within a namespace, and the archive retains only recent hours while the
transcripts go back 90 days hot and forever cold. Every signal must be useful with this disabled.
