# Observability — implementation reference

Companion to the [design log](2026-07-28-observability-journal-design-log.md),
which says *why*. This says *what*: types, signatures, ownership, and the path a
value takes from a handler to the ring.

---

## 1. Component map

| Layer | File | Responsibility |
|---|---|---|
| Roc, platform | `platform-im/CapVal.roc` | The token type. Nominal, crosses the ABI. |
| Roc, platform | `platform-im/ObsFmt.roc` | A `Format` whose "encoding" is appending tokens. |
| Roc, platform | `platform-im/Obs.roc` | `capture` — drives the derived traversal. |
| Roc, platform | `platform-im/main.roc` | `observe` in `requires`; `observe_for_host` export. |
| Roc, app | app's `observe_fn` | Chooses *what* to capture. |
| Glue | `crates/host-im/src/roc_platform_abi.rs` | `CapVal` repr, `roc_im_observe`. Generated. |
| Host | `crates/host-im/src/lib.rs` | `Boundary::observe` — calls, times, hands over. |
| Host | `crates/host-im/src/journal.rs` | Diff, entries, ring, release. |

Off by default. `Journal::on` is set from `--journal` in `argv` at
`Boundary::init`; when false the whole path is one predicate in
`Boundary::observe`.

---

## 2. Roc-side types

### `CapVal` — the token

```roc
CapVal := [
    Str(Str), U64(U64), I64(I64), F64(F64), Bool(Bool),   -- values
    Field(Str), Enter, Exit,                              -- record structure
    ListEnter, Elem, ListExit,                            -- list structure
    Tag(Str),                                             -- tag-union variant
]
```

Nominal because a type module's main type must be, and it must be nameable from
`main.roc`'s `requires`.

A capture is a **flat, self-describing serialization** of the model in traversal
order. A record emits `Enter`, then `Field(name)` + value per field, then `Exit`.
A list emits `ListEnter`, then `Elem` + element per element, then `ListExit`. A
tag emits `Tag(name)` then its payload.

`Elem` carries **no index**. The derived list encoder's element writer has arity
2 (`lower.zig` gates the third argument on `kind == .record`), so the loop index
exists in the lowering and is never passed. The host counts `Elem` markers
instead.

Widening is lossy and deliberate: `U8/U16/U32 → U64`, `I8/I16/I32 → I64`,
`F32 → F64`. `U128`/`I128`/`Dec` have only `to_f64`, so a `Dec` would go through
a binary float — `ObsFmt` does not implement `encode_dec`, so a model containing
one fails to observe rather than mis-reporting.

### `ObsFmt` — the format

Implements the `Format` method set against accumulator type `List(CapVal)`:

```roc
encode_record : List(CapVal), U64, (…writer with a Str name…) -> Try(List(CapVal), [])
encode_list   : List(CapVal), U64, (…writer, no name…)        -> Try(List(CapVal), [])
encode_tag    : List(CapVal), Str, U64, (…writer…)            -> Try(List(CapVal), [])
encode_str    : Str,  List(CapVal) -> Try(List(CapVal), [])
encode_u8..u64, encode_i8..i64, encode_f32/f64, encode_bool
```

**The accumulator must remain a bare `List`, or a record with at most ONE
refcounted collection.** Two refcounted collections in one accumulator record is
quadratic — 320 224 B against 176 B at 10 000 entries — because the record
spread re-reads a borrowed field and copies on every append. This is the
tightest constraint in the subsystem.

### `Obs.capture`

```roc
capture : value -> List(CapVal)
    where [value.encoder_for : ObsFmt -> (value, List(CapVal) -> Try(List(CapVal), []))]
```

Generic over anything with a derived `encoder_for` — which is what lets an app
capture a **projection** rather than its whole model (§9).

### App surface

```roc
observe : Box((model -> List(CapVal)))     -- in the platform's `requires`
```

An app opting out writes `Box.box(|_m| [])`. That still type-checks because `[]`
is polymorphic, so **no app needs to change when this type changes** — only the
ones that call `Obs.capture`.

---

## 3. The ABI boundary

```roc
observe_for_host : Box(Model), Box((Model -> List(CapVal))) -> List(CapVal)
observe_for_host = |boxed_model, reader| {
    f = Box.unbox(reader)
    f(Box.unbox(boxed_model))
}
```

```rust
pub fn roc_im_observe(arg0: RocBox, arg1: RocErasedCallable) -> RocList<CapVal>;
```

### Attribution

`Boundary`'s three event entry points carry what identifies the event:

```rust
pub fn dispatch(&mut self, index: u64, event: Event, label: Option<&str>)
pub fn route(&mut self, route_key: &str, generation: u64, event: Event)
pub fn key(&mut self, name: &str, editing: bool)
```

`route` and `key` already had their identifier as a parameter and simply dropped
it. `dispatch` did not: the boundary holds the handler table but not the tree, so
it cannot resolve an index to anything nameable. The **caller** supplies the
label, because the caller is what hit-tested the node.

The convention for `label` is the element's `key` if it has one, else its text —
`Some("row-3")` beats `Some("[x]")`, and both beat an index that is only
meaningful inside one frame's tree. Gates that do not care pass `None`.

Glue emits `CapVal` as a `#[repr(C)]` payload-union + tag byte:

```rust
#[repr(C)] pub struct CapVal { _payload_alignment: [CapValPayloadAlignment; 0],
                               payload: [u8; 24], tag: CapValTag }
const _: () = assert!(size_of::<CapVal>() == 32);   // 64-bit
const _: () = assert!(align_of::<CapVal>() == 8);
```

Accessors: `payload_str()`, `payload_field()`, `payload_tag()` (returning
`RocStr` **by value**), `payload_u64()`, `payload_i64()`, `payload_f64()`,
`payload_bool()`. Niladic variants have `[u8; 0]` arms.

Precedent for a `List` of tag union crossing: `cmds_of` returns `List(Cmd)`,
which has `RocStr` payloads and ships today. What has *no* stable layout is a
list of **erased closures** (`List(Handler)`), which is a different problem.

**Changing `CapVal` forces a glue regen** (`just im-glue` fingerprints
`platform-im/*.roc`), and the glue toolchain is single-machine and unpinned.

---

## 4. Ownership and lifetimes

The part most likely to be got wrong.

| Value | Owner | Released by |
|---|---|---|
| `self.root` (model) | `Boundary` | transferred through dispatch, never decref'd around it |
| the `observe` callable | `Boundary` | `view()`'s slot-replacement loop |
| a returned capture | `Journal` | `release_capture`, when it stops being `prev` |

**`Boundary::observe` increfs before calling.** `roc_im_observe` *consumes* both
arguments, and the boundary keeps both:

```rust
unsafe { abi::incref_box(self.root, 1); abi::incref_erased_callable(reader, 1); }
let capture = unsafe { abi::roc_im_observe(self.root, reader) };
```

This is a **read**, not a write — so it does not follow the transfer discipline
that `dispatch` uses. `dispatch` transfers the root (no incref, no trailing
decref) because it gets a new root back; `observe` gets a *different* value back
and must keep its own reference to the root.

**Ownership of the capture moves into the journal.** `Journal::observe` takes it
by value and is responsible for releasing whichever capture it stops holding.

**Release is element-wise:**

```rust
fn release_capture(cap: abi::RocList<abi::CapVal>) {
    if cap.has_one_ref() {
        for item in cap.allocation_items() { unsafe { (*item).decref(roc_host()) }; }
    }
    unsafe { cap.decref(roc_host()) };
}
```

`RocList::decref` frees **only the spine, for any `T`**. `CapVal`'s `Str`,
`Field` and `Tag` variants own `RocStr`s. Glue generates `CapVal::decref` with
per-variant arms, but generates no `RocList<CapVal>` helper for a top-level
return — it only emits that idiom for a list that is a *field* of a generated
type. The `has_one_ref()` guard is load-bearing: the elements belong to the
allocation, not to this reference.

Missing this is the leak the platform has had twice (`release_handlers`,
`release_cmds`).

**Borrowing a payload.** `payload_field()` returns the `RocStr` **by value**, and
a small string (≤ 23 bytes) stores its bytes *inside* that struct — so
`as_str()` on a temporary dangles. `field_str(&CapVal) -> &str` borrows from the
token instead, and the path cursor holds `&'a str` tied to the capture's
lifetime.

---

## 5. Host-side types

```rust
pub enum Cause {
    Init,
    /// `index` is the slot `Ui.root` assigned in the tree that was live when
    /// this fired — a within-frame cache, NOT an identity: the tree is rebuilt
    /// every event, so the same number means a different handler once the shape
    /// changes. `label` is what survives: the element's `key` if it has one,
    /// else its text. Supplied by the caller, because the boundary does not have
    /// the tree — the flattened IR lives in `Engine::frame`.
    Dispatch { index: u64, label: Option<String> },
    /// The route key the completion targeted.
    Route(String),
    /// The key name, as the driver reports it.
    Key(String),
    Drain,
}
```

`Cause` is **not `Copy`** — it owns its labels. It is constructed per
observation, which is at most once per event.

```rust

pub enum Val { Str(String), U64(u64), I64(i64), F64(f64), Bool(bool), Absent }

pub struct Change { pub path: String, pub before: Val, pub after: Val }

pub struct Entry {
    pub seq: u64, pub frame: u64, pub cause: Cause,
    pub changes: Vec<Change>,
    pub reshaped: bool,      // a list element appeared or disappeared
    pub truncated: usize,    // changes not materialized (never silent)
    pub leaves: usize, pub traverse_us: f64, pub diff_us: f64,
}

pub struct Journal {
    pub on: bool,
    byte_cap: usize, bytes: usize, dropped: u64, seq: u64,
    prev: Option<RocList<CapVal>>,   // Roc-owned, not cloned
    ring: VecDeque<Entry>,
    obs_count: u64, obs_bytes: u64, traverse_ns: u128, diff_ns: u128,
    pending_traverse_ns: u128, leaves_seen: usize,
}
```

`prev` is `Option`, **not** an empty list standing in for "never observed" — a
model can legitimately have zero leaves, and conflating the two swallowed every
transition out of empty in an earlier build.

Public methods: `new(on)`, `observe(frame, cause, capture) -> bool`,
`needs_baseline()`, `entries()`, `footprint() -> (bytes, dropped)`,
`leaves_tracked()`, `cost_so_far() -> (bytes, count)`,
`charge_traverse(ns)`, `charge_bytes(n)`, `dump()`.

---

## 6. Flow: handler to ring

A click, end to end.

```
winit event
 └─ window.rs  handle_click
     └─ Boundary::dispatch(index, event)
         ├─ incref handler table
         ├─ roc_im_dispatch(root, handlers, index, event)   ← the WRITE (56 B)
         │     app handler: Event, Model -> Model
         └─ Boundary::observe(Cause::Dispatch(index))
             ├─ if !journal.on → return                      (the off cost)
             ├─ incref root + observe callable
             ├─ t0 / a0 snapshots
             ├─ roc_im_observe(root, reader)  ────────────┐
             │     Roc: observe_for_host → app observe_fn │
             │          → Obs.capture → derived traversal │
             │          → ObsFmt appends into List(CapVal)│
             │     ←──────────────── RocList<CapVal> ─────┘
             ├─ journal.charge_traverse(elapsed)
             ├─ Journal::observe(frame, cause, capture)   ← ownership moves
             │   ├─ if !on → release_capture, return
             │   ├─ leaves_seen = count of value tokens
             │   ├─ if prev.is_none() → prev = Some(capture); return false   (baseline)
             │   ├─ diff(prev.as_slice(), capture.as_slice())
             │   ├─ release_capture(prev);  prev = Some(capture)
             │   ├─ if no changes → return false
             │   ├─ build Entry; truncate to byte_cap/MAX_ENTRY_FRACTION
             │   ├─ push to ring; evict oldest while over byte_cap
             │   └─ return true
             └─ journal.charge_bytes(allocs since a0)
     └─ window.request_redraw()          ← frame starts only now
```

**All of it is synchronous on the UI thread.** The redraw is not requested until
the observation completes, so an observation's cost is frame delay. The only
worker thread (`worker.rs`) carries sqlite; `net.rs` carries http.

### Call sites

| `lib.rs` | Cause | Note |
|---|---|---|
| 590 | `Init` | end of the first `view()`, gated on `needs_baseline()` — `observe` is a callable `view` returns, so no earlier point has one |
| 653 | `Dispatch { index, label }` | after `roc_im_dispatch`; `label` comes from the caller |
| 725 | `Route(key)` | after `roc_im_route`; the key is already a parameter |
| 749 | `Key(name)` | after `roc_im_key`; the name is already a parameter |
| 809 | `Drain` | after `roc_im_clear`, **only if `!cmds.is_empty()`** |

---

## 7. The diff

```rust
fn diff(a: &[CapVal], b: &[CapVal]) -> (Vec<Change>, bool /*reshaped*/, usize /*dropped*/)
```

Takes slices, not lists, so it is unit-testable without a Roc runtime.

**Token equality** — `tok_eq` compares tags, then payloads. `str_eq` is
**pointer-first, content-second**: compare the 24 `RocStr` bytes verbatim (which
covers a small string's inline payload *and* a heap string's ptr+len), and fall
back to `as_str()` only on mismatch. Unchanged leaves are the same allocation in
both captures, so they settle on the struct compare.

**Main loop.** Walk both cursors:

1. `tok_eq` → step both.
2. **`same_len` (computed once)** → an in-place value change. If the two streams
   are the same length nothing was inserted or removed, so the search below is
   not merely unnecessary but wrong. This is the common case and makes it a
   linear walk.
3. Advancing both resynchronizes (`run_eq` over `CONFIRM` tokens) → in-place.
4. Otherwise search for an insert or delete shift, preferring one that lands on
   an `Elem`/`ListExit` boundary so a removed row is reported as one row.
5. No resync → report the pair and step both.

**Constants:**

| | value | meaning |
|---|---|---|
| `CONFIRM` | 8 | tokens that must agree before a resync is believed. One is not enough: on homogeneous data every `Elem` matches somewhere. |
| `RESYNC_WINDOW` | 4096 | how far ahead to look. Unbounded would be a full sequence alignment. |
| `SEARCH_BUDGET` | 8 | consecutive failed searches before giving up and walking linearly. On a wholesale replacement there *is* no resync point. |
| `MAX_CHANGES` | 4096 | changes materialized before the sink just counts. |

**`Sink::push(&Path, Option<&CapVal>, Option<&CapVal>)`** takes the path cursor
and the raw tokens, not a formatted path and two values — Rust evaluates
arguments before the cap is consulted, so a `push(path.show(), val_of(a), …)`
signature builds ~50 000 wasted strings on a table load.

**`Path`** holds `Vec<Part<'a>>` where `Part = Field(&'a str) | Index(i64)`,
plus a per-open-list counter stack. **Nothing is formatted until a change is
reported** — `show()` is called once per `Change`, not once per token.

---

## 8. Memory

**Ring:** bounded in **bytes** (`RING_BYTES = 16 MiB`), not entries — a scalar
edit is one small change and a table load is tens of thousands, so a count
bounds nothing. Eviction is oldest-first and `dump()` reports the count.

**Per entry:** capped at `byte_cap / MAX_ENTRY_FRACTION` (2 MiB). Over that,
changes are dropped and counted in `Entry::truncated`. The earlier build kept an
oversized entry whole; under value capture that would let one table reload evict
the entire ring.

**`Entry::bytes()`** = `size_of::<Entry>()` + per change
`size_of::<Change>() + path.len() + before.heap_bytes() + after.heap_bytes()`.

**Resident, outside the ring:** one capture (`prev`). ~32 B per token.

---

## 9. Extension points

**An app scoping what it observes.** `Obs.capture` is generic, so an app can
capture a projection:

```roc
observe_fn = |model| Obs.capture({
    picked: model.picked,
    sql: model.sql,
    result_rows: row_count(model.results),   -- a number, not 25 000 cells
})
```

dbx does this: worst observation 10 000 µs → 9.7 µs, allocation 40 MB → 22.7 KB.
The cost is that anything omitted is invisible to the journal.

**A nominal in the model.** Derivation stops at a nominal. Delegate to a
structurally identical alias:

```roc
Backing : [Quiet, Loud(Str)]
Tone := [Quiet, Loud(Str)].{
    encoder_for = |fmt| |self, state| {
        backing : Backing
        backing = match self { Quiet => Quiet, Loud(s) => Loud(s) }
        sub_encoder(fmt)(backing, state)
    }
}
```

`sub_encoder` is app-side boilerplate, not a builtin. Non-parameterized nominals
only — the parameterized form SIGSEGVs the compiler.

**Adding a `CapVal` variant.** Update `ObsFmt`, regen glue, extend `tok_eq`,
`val_of`, `is_leaf`, `Path::step`. `is_leaf` decides what becomes a reported
change; markers return `true` from `tok_eq`'s catch-all.

---

## 10. Invariants

- The accumulator holds **at most one refcounted collection**.
- The traversal **never carries a stack or scans backward**; alignment is
  host-side.
- **Observation never touches the write path** — it is a read *after* the write.
  `G2-b` gates that the model write stays 56 B with the journal on, subtracting
  the observation's own bytes via `charge_bytes`.
- **A capture is released element-wise** or it leaks its strings.
- The journal **reports what it drops** — evicted entries and truncated changes.

## 11. Known failure modes

| Symptom | Cause |
|---|---|
| A leak of one allocation per event | Spine-only decref of a capture. |
| A retention/leak gate passes with the fix removed | Rig payload under 24 bytes — small strings are inline and refcount nothing. |
| `G2-b` green but observability broken | Stubbed `observe`, or journaling off. The predicate must be *leaves observed*, not observations — a stub still gets called and still counts. |
| Diff cost explodes on a large model | An eagerly-evaluated argument to a capped `push`, or `format!` in the per-token path. |
| A one-row edit reports the whole list | Resync attempted only on tag mismatch, or confirmed on a single token. |
| Compiler SIGSEGV instead of an error | A type error in a tag payload position. Bisect by lifting the subexpression out. |
