cortexkit-usage-steps 0.2.1

Wire contract, vectors and a last-row-wins helper for the CortexKit live usage step feed (usage.steps.subscribe and usage.steps.read).
Documentation
# Live usage step feed — contract v1

Apps follow prompt-cache diagnostics live through two operations served by the
usage feed provider: a subscription that only says new steps exist, and a paged
read that returns them. Apps talk only to the usage feed provider. It combines
the steps it reads from harness stores with the `step.finished.v1` events
runners publish on the bus. A step a runner announces is in the feed within
seconds; billing never comes from this feed.

This crate holds the shapes (Rust types), the vectors in
`test-vectors/usage-steps-v1/`, and the public `LatestRows` type, the
last-row-wins helper.

## Transport

A request body is `{"method": <operation>, "params": <object>}`. A success is
a Response frame whose body is `{"result": <reply>}`. A refusal is an Error
frame whose body is `{"code": <code>, "message": <text>}`, plus `detail` where
a code below names one. `code` is a closed string from this document;
`message` is for people and is not a stable interface. A request object is
closed: an unknown member is refused `malformed`. An optional request member
also accepts `null`.

## Who may read

The provider decides from the principal the local daemon attested when the
route was bound. It admits exactly these:

- `Principal::Direct`: a client of this computer's daemon running as the same
  user.
- A route whose attested principal is the reserved principal of the
  federation module (the module that relays paired devices) and whose device
  label begins `fed-phone:`.

Every other caller is refused `not_permitted`. That includes the federation
module's routes whose device label begins `fed-sim:` (simulators) or
`fed-computer:` (other computers), or carries any other label.

## Cursors

Cursors are opaque strings. They are ordered by the provider's commits, are
stable across restarts and are never reused. Apps pass them back as `after`
and never parse them or compare their order.

## Order and reconnect rules

1. Subscribe first, and hold the notices that arrive.
2. Read history with `usage.steps.read`: from your last cursor if you hold
   one, otherwise without `after`. Follow `next` until it is null, then read
   again from the reply's `through` whenever a notice arrives.
3. Drop rows already seen: apply rows in the order reads return them and keep
   the last row per identity, so a row seen twice replaces itself.
4. After any reconnect, read once more a few seconds later: a step that has
   just finished can take a few seconds to be committed.

## `usage.steps.subscribe`

Request: `{}`, or `{"harness": "<producer>", "session": "<session>"}` to
follow one session. `harness` and `session` come together or not at all; a
request naming one without the other is refused `invalid_request`. Any member
the lists below do not name is refused `malformed`.

Unfiltered, each notice is one StreamData frame:

```json
{"through": "c0000110"}
```

`through` is the newest committed cursor. Notices are coalesced: at most four
a second, and a notice may cover many steps or none that the caller may see.
A notice carries no rows.

Filtered, a notice is sent only when a row whose `harness` and `session` equal
the request's is committed. Coalescing and the endings below are unchanged: a
`through` is still the newest committed cursor of the whole feed, not of the
session, so a filtered subscriber resumes exactly like an unfiltered one.

| How it ends | Meaning |
|---|---|
| StreamEnd | The provider is stopping or the route closed. Subscribe again. |
| Error `lagged`, detail `{"through": "<last notified cursor>"}` | The subscriber fell behind its queue bound. Subscribe again and read from the last cursor it holds. |
| Caller's Cancel | Ended by the caller. The provider may still send a last Error `cancelled`, which the daemon may drop. A reader treats `cancelled` after its own Cancel as the normal end, never as a failure. |

Opening refusals: `not_permitted`, `subscription_capacity` (more than 16 open
subscriptions), `draining` (the provider is stopping), `malformed`,
`invalid_request` (the request names `harness` without `session`, or `session`
without `harness`).

## `usage.steps.read`

Request: `{"after": "<cursor>", "limit": 200}`. Both members are optional.
Without `after`, the read starts at the oldest row the feed still holds. The
feed holds the rows committed in the last 24 hours. `limit` defaults to 200,
with a maximum of 1,000.

A read may also name `harness` and `session`. They come together or not at
all; a request naming one without the other is refused `invalid_request`.

- Unfiltered (no `harness`), the read returns every row of the feed.
- Filtered, the read returns only the rows whose `harness` and `session` equal
the request's, matched exactly: no normalisation, no prefix matching.

Reply:

```json
{"rows": [<row>, ...], "next": "<cursor>" | null, "through": "<cursor>"}
```

- `rows` are oldest first.
- `next` is the cursor to pass as `after` for the following page, or null
  when the reply reached `through`.
- `through` is the newest committed cursor.

A filtered read keeps both meanings: `limit` bounds the rows returned, and a
filtered page holds at most `limit` matching rows; the provider reads past
non-matching rows to fill it. `through` stays the newest committed cursor of
the whole feed, not the newest matching row, so a filtered reader resumes
exactly like an unfiltered one. A filtered page is therefore never empty with
`next` non-null: an empty `rows` with `next` null means no matching row was
committed up to `through`.

Refusals:

| Code | Condition |
|---|---|
| `malformed` | The request does not decode, or it carries an unknown member. |
| `invalid_request` | `limit` is 0; `after` is not a cursor the provider issued; or the request names `harness` without `session`, or `session` without `harness`. |
| `range_too_large` | `limit` is above 1,000. |
| `cursor_expired` | `after` is older than the feed holds. Detail `{"through": "<newest cursor>"}`. Reload per-session history with `usage.cache.sessions` and `usage.cache.steps`, then read from `through`. |
| `not_permitted` | The caller is not admitted (see [Who may read]#who-may-read). |

The session filter narrows which rows a read returns. It grants nothing: who
may read is decided as before (see [Who may read](#who-may-read)).

### Session filter and older providers

A request object is closed: a provider that predates the session filter does
not know `harness` or `session`, so it refuses a filtered request
`malformed`. There is no version member to ask it first. So a client that
sends `harness` and `session` and gets `malformed` back knows only that the
provider cannot filter yet. It must never fall back to an unfiltered read and
treat the rows it returns as that session's: those rows are every agent's. A
client that meets `malformed` this way shows the session as not available
from this provider, or reads unfiltered and marks the rows unfiltered, never
attributing them to the session.

## The row

A row's identity is `harness` plus `step_ref`. When any field of a step
changes (a runner-announced step later recorded from its log, a finish reason
that arrives late, or a verdict recomputed because an earlier step of its
session arrived late), the whole row is sent again at a new cursor. **The last
row per identity wins; a reader replaces, never appends.**

Rows carry no content: never prompt text, tool names or error text.

Required members:

| Member | Type | Meaning |
|---|---|---|
| `cursor` | string (cursor) | This row's feed position. |
| `harness` | string | The producer: `claude-code`, `codex`, `gemini`, `kimi`, `omp`, `opencode`, `pi`, or a runner's module id. |
| `source` | string | `file` for a step read from a harness store or a runner's durable log; `runner` for a step known so far only from a runner's bus event. |
| `step_ref` | string | The producer's own id for the step. For a step a runner announced, see [Runner steps]#runner-steps. |
| `session` | string | The producer's session. |
| `occurred_at_ms` | integer, Unix ms | When the step happened. Steps without a time are not in the feed. |
| `prompt` | integer | Input plus cache read plus cache write. |
| `verdict` | string | See [Verdict]#verdict. |
| `prompt_dropped` | boolean | See [Verdict]#verdict. |

Optional members, omitted when unknown and never zero for unknown:

| Member | Type | Meaning |
|---|---|---|
| `run_id` | string | Runner steps only. |
| `provider` | string | |
| `model` | string | |
| `input` | integer | Uncached input tokens. |
| `cache_read` | integer | Cached tokens read. |
| `cache_write` | integer | Cache tokens written, the sum of every cache lifetime. |
| `cache_write_5m` | integer | Present only when the source split cache writes by lifetime. |
| `cache_write_1h` | integer | Present only when the source split cache writes by lifetime. |
| `output` | integer | Output tokens, reasoning included. |
| `reasoning` | integer | The part of `output` the provider reports as reasoning; present only when the source reports it. |
| `finish` | string | The producer's finish or stop reason. |
| `context_limit` | integer | The model's context limit, where the producer reports it. |
| `request_body_len` | integer | Request body length, where the producer reports it. |
| `retention` | number | Non-negative; never rounded or clamped. See [Verdict]#verdict. |
| `request_size` | string | `smaller` or `not_smaller`; see [Verdict]#verdict. |

`reasoning` is a breakdown of `output`, never an addition to it: it counts
output the provider reports as reasoning, and that output is already inside
`output`. So `reasoning <= output` whenever both are present. A reader
computing cost charges `output` once and never adds `reasoning` on top. A
missing `reasoning` means not reported, not zero. This is the same rule the
runner contract pins for `reasoning_tokens` and `output_tokens`.

`cache_write` is the sum of every cache lifetime.

Readers still decode a row whose `reasoning` exceeds its `output`; such a row
breaks this rule on the provider's side. `StepRow::check_counts` names it, for
the provider's own tests.

### Runner steps

A step a runner announces on the bus (`step.finished.v1`, runner contract
§14.3) is served live before any durable read records it. Its row must carry
the identity of the row the provider later records from a durable read, or
the last-row-wins rule could not replace it. The identity comes from the
event's §14.3 members:

- **The event carries `harness_step_ref`.** The runner declared
  `step_events.history_key: "harness"`, and the host's own store is the
  durable source. `harness` is the event's `harness` (the hosting program,
  such as `opencode` or `claude-code`) and `step_ref` is the event's
  `harness_step_ref` (the host's own record id). The provider's later read of
  the host store replaces the live row.
- **The event carries no `harness_step_ref`.** `harness` is the event's
  `runner` (the runner's module id) and `step_ref` is the event's
  `attempt_id`: the id the runner gave that provider attempt, unique and never
  reused, which it also writes in its own durable step record (the runner
  contract requires `attempt_id` on every event without `harness_step_ref`).
  The event's `run_id`, `step_id` and `attempt` are not part of the identity,
  because that triple can repeat after a session is restored. The provider
  records one row per attempt actually sent, because a billed retry is spend
  and its cache behaviour differs. An event with neither member has no
  identity, and the provider drops it. For a runner that is its own
  durable source (`history_key: "step_ref"`), that is the identity the
  provider gives the attempt when it reads the runner's step log, so the
  durable row replaces the live one. For a runner keyed by its host (`history_key:
  "harness"`) whose step has no host record, the row stays a separate
  live-only row with `source: "runner"` and is never joined to a host row.

`live_row_identity` in this crate computes the pair from those members.

### Readers and new members

The provider may add optional row members within v1. Any member it adds
carries no content: identifiers, counts, model names, times or flags only,
never prompt text, tool names or error text. Readers ignore members they don't
know, on a row, a reply or a notice. Removing or retyping a member, or
changing a threshold, is a new contract version.

In this crate, `StepRow`, `ReadReply` and `Notice` ignore unknown members when
decoding, and `StepRow::check_closed`, `ReadReply::check_closed` and
`Notice::check_closed` name any member outside the published lists, for the
provider's own tests. Requests stay strict.

## Verdict

`prompt`, `retention`, `verdict`, `prompt_dropped` and `request_size` are
computed over the step's whole session in time order, so a row's verdict does
not depend on the page it arrives in.

Verdict values: `cold_start`, `stable`, `warning`, `bust`, `full_bust`,
`unknown`. `unknown` is a real verdict: the cache's health cannot be judged for
this step. A value a reader does not recognize is a newer verdict; the reader
shows it as received rather than treating it as `unknown` (in this crate,
`Verdict::Other`).

Rules, in order:

- A step without a stored cache read is `unknown`. It is also never the
  baseline the next step is compared with.
- In a session where no step ever read a positive number of cached tokens,
  every step is `unknown`. Codex is the exception.
- The session's first step is `cold_start`.
- Otherwise, the step is compared with the last earlier step that reported its
  cache. When there is none, or that step read zero, the verdict is `stable`
  with no `retention`. Otherwise, `expected` is that step's cache read plus its
  growth, which is its cache write when positive and otherwise its uncached
  input. `retention` is this step's cache read divided by `expected`.
- `retention` is a non-negative number, never rounded or clamped. Because it
  is this step's cache read divided by the expected cache, it can exceed 1
  when a step reads more than expected.
- Codex reports no cache writes, so its `retention` is the cache read divided
  by the cache read plus the uncached input. When that sum is zero, the
  verdict is `unknown`.
- A cache read of zero is `full_bust`. Otherwise, a `retention` of at least
  **0.95** is `stable`, at least **0.80** is `warning`, and anything lower is
  `bust`.
- `prompt_dropped` is true when `prompt` is at least **15,000** tokens smaller
  than that earlier step's prompt.
- On a prompt drop, `request_size` is present when both adjacent steps report
  body lengths: `smaller` when the current body is shorter, otherwise
  `not_smaller`.

The thresholds 0.95 and 0.80 and the 15,000-token prompt drop are part of this
contract's version: changing one is a new contract version.

## Vectors

`test-vectors/usage-steps-v1/` holds the normative bytes, indexed by
`cases.json` and pinned by `SHA256SUMS`; its README describes each case.