# Reporting and alerts

The report, schedule and inbox operations, the documents they answer with, their states, problem codes, limits and retention, and what alerts do not offer yet.

Source: https://tokentriage.com/docs/reference/reporting-and-alerts

---
import Callout from '../../../components/Callout.astro';
import NextSteps from '../../../components/NextSteps.astro';

This page lists the contract behind reports: eight operations, the three documents they answer
with, the states a run moves through, the problem codes, and the limits and retention that apply.
It then lists the same for schedules, which run a report again on a cadence, and for the in-app
inbox they deliver to. Alerts are listed at the end with their current status, which is that they
are not available yet.

Field names and codes below come from the R01 and R02 wire contracts and the
`tokentriage cloud report` commands.
[What a report run keeps, and who can open it](/docs/concepts/report-snapshots-and-access)
explains why a run is frozen and how access is checked; this page does not repeat that.

## Reports

A report is a named definition that points at one saved view at one revision. Each run of it
copies that view's question, resolves the window once, and keeps one bounded answer.

### Operations

All eight share the customer base path `/portal/api/v1/customer/v1`. Collection rows sit under
the workspace; item rows sit under the organization, because a report carries its own workspace.
This mirrors the saved-view operations.

| Operation | Method and path | Success | Python / TypeScript | CLI |
|---|---|---|---|---|
| `customer.reports.list` | `GET /organizations/{org}/workspaces/{ws}/reports` | 200, list of `ReportV1` | `reports.list` | `cloud report list` |
| `customer.reports.create` | `POST /organizations/{org}/workspaces/{ws}/reports` | 202, `{report, run}` | `reports.create` | `cloud report create` |
| `customer.reports.get` | `GET /organizations/{org}/reports/{rp}` | 200, `ReportV1` | `reports.get` | `cloud report get` |
| `customer.reports.delete` | `DELETE /organizations/{org}/reports/{rp}` | 200, `{report_id, deleted_at, runs_removed}` | `reports.delete` | `cloud report delete` |
| `customer.reports.runs.create` | `POST /organizations/{org}/reports/{rp}/runs` | 202, `ReportRunV1` | `reports.runs.create` | `cloud report run` |
| `customer.reports.runs.get` | `GET /organizations/{org}/reports/{rp}/runs/{run}` | 200, `ReportRunOpenV1` | `reports.runs.get` | `cloud report show` |
| `customer.reports.runs.cancel` | `POST /organizations/{org}/reports/{rp}/runs/{run}/cancel` | 200, `ReportRunV1` | `reports.runs.cancel` | `cloud report cancel` |
| `customer.reports.runs.export` | `GET /organizations/{org}/reports/{rp}/runs/{run}/export?format=json\|csv` | 200, attachment bytes | `reports.runs.export` | `cloud report export` |

What each operation does, in one line:

- **list** answers the reports in the workspace that you own or are a recipient of, newest first,
  with the lane's opaque pagination (`--limit` up to 100, `--page-token`).
- **create** stores the definition and queues its first run. The run is `queued` when the 202
  arrives; poll it with **runs.get**.
- **get** answers one definition. It carries `latest_run_id`, not an answer.
- **delete** removes the definition and every run of it. Owner only.
- **runs.create** is "Run again": a new run with a new window, a new source fence and a new digest.
  Earlier runs are unchanged.
- **runs.get** polls a run and, once it is `ready` or `partial`, opens it: the answer arrives in the
  same document, redacted for the viewer.
- **runs.cancel** moves a `queued` or `running` run to `cancelled`.
- **runs.export** downloads one run as an attachment. See [Export formats](#export-formats).

The Python client exposes these as `client.reports` and `client.reports.runs`; the TypeScript
client uses the same names. Both keep the envelope, request IDs, problem documents and
nano-USD strings unchanged.

### Request bodies

`create` takes the saved view, the revision of it you read, a name, and optional recipients:

```json
{
  "name": "A1 morning check",
  "saved_view_id": "sview_…",
  "expected_view_revision": "3",
  "recipient_subject_ids": [],
  "evidence_refs": [],
  "idempotency_key": "acme-it-daily-report-01"
}
```

| Field | Type | Rules |
|---|---|---|
| `name` | string | 1 to 120 characters, no control characters; unique per owner in the workspace |
| `saved_view_id` | string | a saved view you can currently see |
| `expected_view_revision` | decimal string | the view's `revision` as you read it; a mismatch is 412 |
| `recipient_subject_ids` | list of subject IDs | current members of the organization, at most 20; may be empty |
| `evidence_refs` | list | must be empty in this release; anything else is 422 |
| `idempotency_key` | string | optional in the body; the `Idempotency-Key` header is the fallback |

`runs.create` takes only the key:

```json
{ "idempotency_key": "acme-it-daily-report-02" }
```

`get`, `delete`, `runs.get`, `runs.cancel` and `runs.export` take no body.

### Idempotency

`create` and `runs.create` are the two idempotent writes. The key travels in the body as
`idempotency_key`; the `Idempotency-Key` header is read when the body has none.

| You send | You get |
|---|---|
| a new key | a new report and run (or a new run), 202 |
| the same key with the same body | the report and run the first request created, 202 |
| the same key with a different body | 409 `conflict` |

A key is scoped to the workspace and the caller. The CLI mints a fresh key per invocation unless
you pass `--idempotency-key`, and the SDKs take `idempotency_key=` (Python) or
`{ idempotencyKey }` (TypeScript). A create that timed out is retried with the key it was sent
with, which answers the first report instead of making a second.

### ReportV1

The definition. Revisions are unsigned integers carried as JSON strings, as saved views carry
theirs; copy them, do not parse them.

| Field | Type | What it holds |
|---|---|---|
| `report_id` | string | `rpt_…` |
| `organization_id` | string | `org_…` |
| `workspace_id` | string | `t_…` |
| `owner` | subject ID | the person who created the report |
| `name` | string | as created |
| `saved_view_id` | string | the view the report copies its question from |
| `view_revision` | decimal string | the view revision the report pins |
| `recipient_subject_ids` | list | people who may also see the report |
| `evidence_refs` | list | empty in this release |
| `revision` | decimal string | the definition's own revision |
| `latest_run_id` | string | `rrun_…`, the most recent run |
| `created_at`, `updated_at` | RFC 3339 UTC | |

### ReportRunV1

One run. `create` answers it inside `{report, run}`; `runs.create` and `runs.cancel` answer it
alone.

| Field | Type | What it holds |
|---|---|---|
| `run_id` | string | `rrun_…` |
| `report_id`, `organization_id`, `workspace_id` | string | the run's owners |
| `initiator` | subject ID | who created this run |
| `idempotency_key` | string | the key the run was created with |
| `state` | enum | see [Run states](#run-states) |
| `failure_code`, `failure_detail` | string | empty unless the run `failed` |
| `question` | object | the `insight-query/v1` document copied from the view, scope included |
| `view_revision` | decimal string | the view revision the question was copied at |
| `window` | object | `from` and `to`, a half-open UTC window `[from, to)` |
| `display_tz` | IANA zone | the view's display zone; it labels times and does not change the window |
| `authority_epoch` | integer | the access generation the run was executed under |
| `attempt` | integer | how many times a worker has tried the run |
| `coverage` | list | coverage reasons the reader reported; empty when complete |
| `truncated` | boolean | `true` when the answer hit a row bound |
| `artifact_digest` | string | `sha256:…` over the question, window, view revision and stored answer |
| `group_count` | integer | groups in the stored answer |
| `byte_count` | integer | size of the stored answer |
| `created_at`, `started_at`, `finished_at` | RFC 3339 UTC | |
| `expires_at` | RFC 3339 UTC | 30 days after `finished_at`. For a `ready` or `partial` run, this is when the answer is removed and the run becomes `expired`. A `failed` or `cancelled` run carries the same date but has no answer to remove. The run's metadata stays readable for 90 days either way |

### ReportRunOpenV1

What `runs.get` answers: every `ReportRunV1` field, plus these.

| Field | Type | What it holds |
|---|---|---|
| `answer` | object | the insight answer, redacted for the viewer; present only when `ready` or `partial` |
| `presentation_digest` | string | `sha256:…` over the document this viewer received |
| `money_withheld` | boolean | `true` when money cells were replaced because the viewer lacks `cost.read` |

`presentation_digest` equals `artifact_digest` only when nothing was withheld. A redacted
presentation carries its own digest and makes no claim to match the stored answer byte for byte.

Answers arrive inside the lane's envelope. The envelope kinds are `reports` (list),
`report-creation` (create), `report` (get and the delete acknowledgement), `report-run`
(runs.create and runs.cancel) and `opened-report-run` (runs.get). Export has no envelope.

### Run states

A run starts `queued` and settles in one of four states. `expired` follows `ready` or `partial`
when retention removes the answer.

| State | Meaning | Answer present |
|---|---|---|
| `queued` | waiting for a worker | no |
| `running` | a worker holds the run and is querying | no |
| `ready` | finished with a complete answer; an empty window is `ready` with "no requests" cells | yes |
| `partial` | finished, and the answer is truncated, carries coverage reasons, or has a cell that is partial or unavailable for a reason other than `scope_not_authorized` | yes |
| `failed` | finished without an answer; `failure_code` says why | no |
| `cancelled` | stopped by the owner or initiator before it finished | no |
| `expired` | the stored answer was removed; the run's metadata is still readable | no |

`failure_code` is one of these, or a reader refusal code recorded as the reader sent it (for
example `read_context_expired` or `metric_version_unknown`):

| `failure_code` | What happened |
|---|---|
| `scope_unavailable` | the initiator's access no longer covered the frozen scope when the run executed; the window and question were kept |
| `output_cap_exceeded` | the answer passed 1,000 groups or 10 MiB |
| `attempts_exhausted` | five transient failures (reader or store unavailable, or a lapsed worker lease) |

Transient failures are retried with exponential backoff, 2^n seconds capped at 5 minutes, up to
five attempts. Lost access, an unknown metric version, a validation failure and an expired read
context end the run on the first attempt.

### Export formats

`runs.export` answers `200` with `Content-Disposition: attachment` and no envelope.

| `format` | Bytes |
|---|---|
| `json` (default in the CLI) | the `ReportRunOpenV1` document for the viewer |
| `csv` | the answer flattened to one row per group and cell |

Both are redacted for the person exporting, under the same rules as `runs.get`. The CLI writes
the bytes verbatim to `--output` or stdout and prints the content type and a `sha256` of what
arrived on stderr. That digest hashes the downloaded file; it is not the run's `artifact_digest`.

### Problems

Refusals use the lane's existing problem document and its existing slugs. No slug is new to
reports.

| Status | `type` | When |
|---|---|---|
| 404 | `not_found` | the report or run does not exist, or you are not its owner or a recipient, or your current `metadata.read` covers less than its frozen scope |
| 403 | `forbidden` | a recipient tried to delete, or somebody other than the owner or initiator tried to cancel |
| 409 | `conflict` | an idempotency key reused with a different body, a report name you already use in the workspace, or a cancel on a run that has already settled |
| 412 | `precondition_failed` | `expected_view_revision` does not match the view, or on `runs.create`, the view has moved past the revision the report pins |
| 422 | `validation_failed` | a malformed body, a name out of bounds, a recipient who is not a member, more than 20 recipients, or a non-empty `evidence_refs` |
| 503 | `dependency_unavailable` | this deployment has no report store, reader or worker composed, so a report would not run |

Insight refusals from the reader, such as `coverage_unknown`, `read_context_expired`,
`metric_version_unknown` and `scope_required`, reach you unchanged. Once a run is queued, a
reader refusal ends the run and is recorded as its `failure_code`.

A 404 and a 403 differ for a reason. Somebody who can see a report but may not remove it is told
so; somebody who cannot see it is told there is no such report.

### Authority, money and metadata

Reports reuse the saved-view permissions. There is no separate report permission in this release.

| Action | Who may |
|---|---|
| create, run again | a caller with `metadata.read` whose access covers the view's whole scope and who can see the view |
| list, get, open, export | the owner and listed recipients, each checked against their own current access at the moment they read |
| cancel | the report's owner or the run's initiator |
| delete | the owner |

A narrower access block is concealed as 404; a report is not silently narrowed to what the
caller can see. Money cells (nano-USD, the catalog's money metrics) need `cost.read` on the
viewer's block. Without it they read `unavailable{scope_not_authorized}` and `money_withheld`
is `true`.

Money figures come from the catalog accounting behind
[the gateway insight reads](/docs/reference/gateway-insights), with the same unavailable reasons.
They are not an invoice. Producing a report makes no provider call and records no inference
usage. No document holds prompts, responses or provider secrets.

Runtime keys do not open reports. Use a person's session or a management key.

### Limits and retention

| Limit | Value | Enforcement |
|---|---|---|
| report definitions per workspace | 50 | documented capacity bound; not enforced by a refusal in this release |
| recipients per report | 20 | 422 `validation_failed` |
| groups per answer | 1,000 | the run fails with `output_cap_exceeded` |
| stored answer size | 10 MiB | the run fails with `output_cap_exceeded` |
| attempts per run | 5 | the run fails with `attempts_exhausted` |
| list page size | 100 | the largest `--limit` the CLI accepts |
| stored answer kept | 30 days after `finished_at` | the run becomes `expired` |
| run metadata kept | 90 days | |

## Schedules and inbox

A schedule runs one report again on a cadence, as the report's owner, and delivers each settled run
to the in-app inbox of the schedule's recipients. Delivery is in-app only: a schedule sends no
email and no Slack message. Each occurrence makes an ordinary report run, so everything under
[Reports](#reports) applies to it.

See [Deliver a gateway report on a schedule](/docs/build/schedule-gateway-report) to schedule a
report you own, read when it will run next, and open a delivered run as a recipient.

<Callout type="key">

A saved schedule is not a promise that it will run. Read `execution_state` on every schedule
document: `armed`, `paused` with a reason, or `unavailable` with the missing parts named.

</Callout>

### Schedule and inbox operations

All eight share the customer base path `/portal/api/v1/customer/v1` and the arrangement reports
use: collection rows under the workspace, item rows under the organization.

| Operation | Method and path | Success | Python / TypeScript | CLI |
|---|---|---|---|---|
| `customer.report_schedules.list` | `GET /organizations/{org}/workspaces/{ws}/report-schedules` | 200, list of `ReportScheduleV1` | `report_schedules.list` / `reportSchedules.list` | `cloud report schedule list` |
| `customer.report_schedules.create` | `POST /organizations/{org}/workspaces/{ws}/report-schedules` | 201, `ReportScheduleV1` | `report_schedules.create` / `reportSchedules.create` | `cloud report schedule create` |
| `customer.report_schedules.get` | `GET /organizations/{org}/report-schedules/{sch}` | 200, `ReportScheduleV1` | `report_schedules.get` / `reportSchedules.get` | `cloud report schedule get` |
| `customer.report_schedules.update` | `PUT /organizations/{org}/report-schedules/{sch}` | 200, `ReportScheduleV1` | `report_schedules.update` / `reportSchedules.update` | `cloud report schedule update` |
| `customer.report_schedules.delete` | `DELETE /organizations/{org}/report-schedules/{sch}` | 200, `{schedule_id, deleted_at, occurrences_cancelled}` | `report_schedules.delete` / `reportSchedules.delete` | `cloud report schedule delete` |
| `customer.report_schedules.occurrences` | `GET /organizations/{org}/report-schedules/{sch}/occurrences` | 200, page of `ReportOccurrenceV1` | `report_schedules.occurrences` / `reportSchedules.occurrences` | `cloud report schedule occurrences` |
| `customer.report_inbox.list` | `GET /organizations/{org}/workspaces/{ws}/report-inbox` | 200, page of `ReportInboxItemV1` | `report_inbox.list` / `reportInbox.list` | `cloud report inbox list` |
| `customer.report_inbox.open` | `POST /organizations/{org}/report-inbox/{delivery}/open` | 200, `OpenedInboxItemV1` | `report_inbox.open` / `reportInbox.open` | `cloud report inbox open` |

What each operation does, in one line:

- **list** answers the workspace's schedules you own or receive, newest first.
- **create** schedules a report you own, at the report revision you read. The answer states
  `execution_state` and the next three occurrences.
- **get** answers one schedule you own or receive, with its next three occurrences computed from
  the current time. There is no separate preview operation.
- **update** replaces the cadence, recipients and `paused` flag under `expected_revision`. Owner
  only.
- **delete** retires the schedule and cancels its in-flight work. Owner only. Its occurrences and
  delivered inbox items stay as history, and the schedule leaves every read.
- **occurrences** is the execution history, newest scheduled first: what ran, failed, was cancelled
  or was missed. It is not a forecast.
- **inbox list** answers your own delivered items in the workspace, newest first. An empty page
  means nothing has been delivered to you yet.
- **inbox open** checks your current access to the item's run, then answers the item and the run
  and records the first read.

`occurrences` and `inbox list` page with an opaque cursor: pass `cursor` (the CLI's `--cursor`)
from the previous page's `next_cursor`, and set `limit` from 1 to 100; the server's default is 50.
An empty `next_cursor` ends the list. A forged cursor is 422.

### Schedule request bodies

`create` takes the report, the report revision you read, a cadence, recipients and an idempotency
key:

```json
{
  "report_id": "rpt_…",
  "expected_report_revision": "1",
  "cadence": {"mode": "wall_clock", "frequency": "daily", "local_time": "09:00",
              "timezone": "America/Los_Angeles"},
  "recipient_subject_ids": ["<subject>"],
  "paused": false,
  "idempotency_key": "acme-it-schedule-01"
}
```

| Field | Type | Rules |
|---|---|---|
| `report_id` | string | a report you own, in this workspace |
| `expected_report_revision` | decimal string | the report's `revision` as you read it; a mismatch is 412 |
| `cadence` | object | see [Cadence](#cadence) |
| `recipient_subject_ids` | list of subject IDs | at most 20; each a current member, the report's owner or one of its recipients, and able to open the report's latest run under their own access; may be empty |
| `paused` | boolean | `true` saves the schedule paused (`pause_reason` `manual`) |
| `idempotency_key` | string | optional in the body; the `Idempotency-Key` header is the fallback |

Create is the only idempotent schedule write, with the same rules as a report
[create](#idempotency): the same key and body answer the first schedule, and the same key with a
different body is 409 `conflict`. The Python client takes `idempotency_key=`, the TypeScript
client `{ idempotencyKey }`, and the CLI `--idempotency-key`.

`update` replaces the whole editable document. Every member is required, and an empty
`recipient_subject_ids` removes every recipient:

```json
{
  "expected_revision": "3",
  "cadence": {"mode": "wall_clock", "frequency": "daily", "local_time": "09:00",
              "timezone": "America/Los_Angeles"},
  "recipient_subject_ids": ["<subject>"],
  "paused": true
}
```

`expected_revision` is the schedule's `revision` as you read it; a stale one is 412, and nothing
is merged. The Python client takes it as the `expected_revision=` argument, the TypeScript client
in the body, and the CLI as `--expected-revision` with the other three members in `--file`.

`get`, `delete`, `occurrences`, `inbox list` and `inbox open` take no body.

### Cadence

A cadence has one of two modes. It keeps its mode until an update names another one, and there is
no cron expression.

| Field | `interval` | `wall_clock` |
|---|---|---|
| `mode` | `interval` | `wall_clock` |
| `frequency` | `hourly`, `daily` or `weekly`: every 3,600, 86,400 or 604,800 seconds of elapsed time | `daily` or `weekly`; `hourly` is not accepted |
| `anchor` | optional RFC 3339 instant the grid counts from; defaults to the creation instant and is kept by an update that stays `interval` | not accepted |
| `local_time` | not accepted | required, `HH:MM` from `00:00` to `23:59` |
| `timezone` | optional IANA zone; labels the local side of each occurrence and moves nothing | required IANA zone; drives the local time |
| `weekday` | not accepted | required for `weekly`, `monday` to `sunday`; not accepted for `daily` |

A `wall_clock` daily cadence runs once per local calendar day, and weekly once per local week. When
the local time falls in a daylight-saving gap, the occurrence runs later by the length of the gap
(02:30 becomes 03:30 for a one-hour gap). When it falls in a fold, it runs once, at the earlier
instant. An `interval` cadence runs on elapsed time, so its local hour shifts by the clock change.
`Local` is not accepted as a timezone.

A cadence that breaks one of these rules is 422 `validation_failed`, and the problem detail names
the cadence code and the field:

| Code | Meaning |
|---|---|
| `invalid_mode` | `mode` is not `interval` or `wall_clock` |
| `invalid_frequency` | `frequency` is not `hourly`, `daily` or `weekly` |
| `hourly_requires_interval` | `hourly` with `wall_clock` |
| `invalid_timezone` | an unknown or missing IANA zone, or `Local` |
| `invalid_local_time` | `local_time` is not `HH:MM` in range |
| `invalid_weekday` | a weekly `wall_clock` cadence without a valid `weekday` |
| `field_not_allowed` | a field the mode does not take, such as `anchor` with `wall_clock` |
| `fixed_view_not_schedulable` | the report's view has a fixed window; a repeated historical window is ambiguous, so create a relative view or run the report by hand |
| `invalid_window` | the report's relative view has no positive window |

### ReportScheduleV1

The schedule. Revisions are decimal strings, as elsewhere on this page.

| Field | Type | What it holds |
|---|---|---|
| `schedule_id` | string | `rsch_…` |
| `organization_id`, `workspace_id` | string | where the schedule lives |
| `report_id` | string | the report it runs |
| `owner` | subject ID | the report's owner, who created the schedule and as whom it runs |
| `cadence` | object | as saved, with an `interval` anchor filled in |
| `tzdb_version` | string | the time zone database the server resolved the zone with |
| `recipient_subject_ids` | list | who receives each run |
| `paused`, `pause_reason` | boolean, string | `pause_reason` is empty unless paused; see [Execution and pause states](#execution-and-pause-states) |
| `execution_state` | enum | `armed`, `paused` or `unavailable` |
| `unavailable_reasons` | list | the missing parts when `unavailable`: `dispatcher`, `report_worker`, `reader`, `inbox` |
| `revision` | decimal string | bumped by every change you can see |
| `next_run_at` | RFC 3339 UTC or null | the next scheduled instant; null while paused |
| `next_occurrences` | list | the next three `{scheduled_utc, local, note}`; `note` is empty, `dst_gap` or `dst_fold` |
| `last_scheduled_for` | RFC 3339 UTC or null | the last instant an occurrence was made for |
| `skipped_total` | integer | occurrences passed over by the catch-up rule, over the schedule's life |
| `created_at`, `updated_at` | RFC 3339 UTC | |

### Execution and pause states

| `execution_state` | Meaning |
|---|---|
| `armed` | not paused, and the dispatcher, report worker, reader and inbox are all running |
| `paused` | paused; `pause_reason` says why |
| `unavailable` | not paused, and at least one of those parts is missing from this deployment |

A paused schedule reads `paused` whatever else is missing. A schedule is `armed` only while every
part it needs is present.

| `pause_reason` | Set by |
|---|---|
| `manual` | the owner, on create or update |
| `authority_lost` | the server, when the owner's current access no longer covers the report's view or its whole scope |
| `report_deleted` | the server, when the report was deleted |
| `view_changed` | the server, when the view moved past the revision the report pins |

Resuming (`paused: false`) or changing the cadence starts at the next instant after the update.
Nothing owed during the pause is run.

### ReportOccurrenceV1

One scheduled instant and what happened to it.

| Field | Type | What it holds |
|---|---|---|
| `occurrence_id` | string | `rocc_…` |
| `schedule_id`, `report_id` | string | |
| `schedule_revision`, `view_revision` | decimal string | the revisions pinned when the occurrence was claimed |
| `scheduled_for` | RFC 3339 UTC | the instant the occurrence is for |
| `window` | object | `from` and `to`, half-open UTC: `to` is `scheduled_for`, `from` is `to` minus the view's relative window; absent on `missed` |
| `state` | enum | see the next table |
| `run_id` | string | the `rrun_…` it made; empty on `missed` |
| `failure_code`, `failure_detail` | string | set on `failed` and `cancelled` |
| `skipped_count`, `skipped_from`, `skipped_to` | integer, RFC 3339 UTC | older instants the catch-up rule passed over, counted on this occurrence |
| `delivered_count`, `suppressed_count` | integer | recipients who received an item, and recipients who could no longer open the run |
| `attempt` | integer | transient failures retried so far, at most 5 |
| `created_at`, `finished_at` | RFC 3339 UTC | |

Recipients are counted on an occurrence, not listed. The claim pins the schedule revision, the
recipients, the view revision and the window, so a worker delay, a retry or a later edit moves none
of them. The window is elapsed time: 24 hours stays 24 hours across a clock change.

| `state` | Meaning |
|---|---|
| `claimed` | the dispatcher has taken the instant and pinned the window |
| `running` | its report run exists and has not settled |
| `delivered` | the run settled and each recipient who can still open it has an item |
| `failed` | it ended without delivery; `failure_code` says why |
| `cancelling` | a pause or delete is cancelling its run |
| `cancelled` | a pause, delete or run cancel stopped it |
| `missed` | the latest owed instant was more than 24 hours old; no window and no run |

| `failure_code` | What happened |
|---|---|
| `authority_lost` | the owner's access no longer covers the report's scope; the schedule pauses `authority_lost` |
| `report_deleted` | the report was deleted |
| `view_changed` | the view moved past the revision the report pins |
| `invalid_request` | the run could not be created from the pinned inputs |
| `window_mismatch` | the run's window differed from the pinned window |
| `run_expired` | the run's answer expired before delivery |
| `attempts_exhausted` | five transient failures |
| `schedule_paused`, `schedule_deleted`, `run_cancelled` | the reason on a `cancelled` occurrence |

A report run's own `failure_code`, such as `scope_unavailable` or `output_cap_exceeded`, is copied
onto the occurrence unchanged.

When the dispatcher runs late, it runs at most one catch-up: the latest owed instant within 24
hours, with the older instants counted in `skipped_count`, `skipped_from` and `skipped_to`. If the
latest owed instant is older than 24 hours, a `missed` occurrence records the count and range, and
the schedule moves to its next future instant.

Only one scheduled report runs at a time in a workspace. The dispatcher claims due schedules oldest
first, at most one per workspace, and none from a workspace that already has an occurrence in
flight. A schedule that waits keeps the window of the instant it was scheduled for.

### ReportInboxItemV1

One delivered item. It carries identifiers only: no answer, no money and no prompt.

| Field | Type | What it holds |
|---|---|---|
| `delivery_id` | string | `rdlv_…` |
| `organization_id`, `workspace_id` | string | |
| `event_type` | enum | `report_delivered` for a scheduled report |
| `source_kind`, `source_id` | string | `report_occurrence` and the `rocc_…` that delivered it |
| `report_id`, `run_id`, `schedule_id` | string | the report, run and schedule behind the item |
| `delivered_at` | RFC 3339 UTC | when the item was committed |
| `read_at` | RFC 3339 UTC or null | the first open; later opens do not change it |

{/* R03: alert events share this inbox (event_type alert_opened / alert_resolved,
    source_kind alert_episode, policy_id, episode_id). The R03 docs task documents them;
    add their rows here, not above. */}

`OpenedInboxItemV1` is `{item, run}`: the item with `read_at` set, and `run`, the
[`ReportRunOpenV1`](#reportrunopenv1) document redacted for you, as `runs.get` would answer it.

Delivered means committed and listable in the product. Read means you opened it, not that an email
arrived. Each recipient's current access is checked at delivery: a recipient who can no longer open
the run gets no item and is counted in `suppressed_count`, and the other recipients are unaffected.
Opening an item checks your access again, and an open that fails that check records nothing.

Answers arrive inside the lane's envelope. The envelope kinds are `report-schedules` (list),
`report-schedule` (create, get, update and the delete acknowledgement),
`report-schedule-occurrences` (occurrences), `report-inbox` (inbox list) and
`opened-report-inbox-item` (inbox open).

### Schedule problems

Problems use the lane's existing slugs. No slug is new to schedules or the inbox.

| Status | `type` | When |
|---|---|---|
| 404 | `not_found` | the schedule or item does not exist, was deleted, or is not yours to see; or the report is not visible to you; or you can no longer open the item's run |
| 403 | `forbidden` | a recipient tried to create, update or delete; only the report's owner may |
| 409 | `conflict` | an idempotency key reused with a different body |
| 412 | `precondition_failed` | `expected_report_revision` or `expected_revision` is stale, or the report pins a view revision the view has moved past |
| 422 | `validation_failed` | a cadence code from [Cadence](#cadence), a recipient who is not eligible, more than 20 recipients, a forged cursor, a `limit` outside 1 to 100, or resuming a schedule whose report was deleted |
| 503 | `dependency_unavailable` | this deployment composes no schedule store, dispatcher and report worker, or no inbox; or an item's source cannot be opened here |

The 503 means the schedule lane is missing, and nothing was saved. A schedule that was saved and
reads `unavailable` is different: the lane is present and one part that runs it is not.

### Schedule authority

Schedules use the report permissions: `metadata.read` for the organization, plus the owner and
recipient rules below. There is no separate schedule permission.

| Action | Who may |
|---|---|
| create, update, delete | the report's owner, who must still see the report's view at the pinned revision |
| list, get, occurrences | the schedule's owner and its recipients |
| inbox list, inbox open | the item's recipient, with their own credential |

At each occurrence the owner's current access is resolved before anything runs, and each
recipient's access is resolved before their item is written. Naming a recipient grants nothing.

### Schedule limits

| Limit | Value | Enforcement |
|---|---|---|
| schedules per workspace | 20 | documented capacity bound; not enforced in this release |
| recipients per schedule | 20 | 422 `validation_failed` |
| scheduled reports running per workspace | 1 | later due schedules wait their turn |
| next occurrences shown | 3 | |
| catch-up after a late start | 1 occurrence, within 24 hours | older instants are counted as skipped or `missed` |
| attempts per occurrence | 5 | the occurrence fails with `attempts_exhausted` |
| page size for occurrences and inbox | 100, default 50 | a larger `limit` is 422 |

The run each occurrence makes follows the report limits and retention above: 1,000 groups, 10 MiB,
and the answer kept 30 days after the run finishes.

## Alerts

{/* R03: alert policies, episodes, acknowledgements and silences go in this section. The R03
    docs task owns it; leave the R02 sections above unchanged when filling it. */}

Not available yet. There are no alert policies, alert episodes or silences in this release.

<NextSteps items={[
  { title: "Keep a dated report of a gateway view", href: "/docs/build/create-gateway-report", description: "Create a report from a saved view, wait for its run, open it, run it again and export it." },
  { title: "What a report run keeps, and who can open it", href: "/docs/concepts/report-snapshots-and-access", description: "Why a run keeps one answer while a view asks again, and why two viewers can see different cells." },
  { title: "Save a gateway view and share it", href: "/docs/build/save-gateway-views", description: "Store the question a report copies, and keep its revision to hand." },
  { title: "Gateway insight reads", href: "/docs/reference/gateway-insights", description: "The metrics, the cell states and the reader refusals a report answer inherits." }
]} />
