# Saram control plane

- **OpenAPI Version:** `3.2.0`
- **API Version:** `0.16.1`

Saram sends synthetic personas through your product in a real browser and returns what they found as
evidence: **issues** a replay reproduced, and **persona feedback** - verdicts and opportunities - quoted from
what each persona did and said. This API starts runs, follows them while they execute, and reads their
results, together with the files, credentials and keys a run needs.

## Authentication

Every route but `GET /api/v1/healthz` wants `Authorization: Bearer <credential>`. The credential is an API
key (`sk_saram_...`), minted in the dashboard or with `POST /api/v1/api-keys` and shown once, or the access
token of a person signed in to the dashboard. A missing, unknown, expired or revoked credential gets one
indistinguishable `401`. A key carries scopes, and a route it lacks the scope for answers `403` naming it:

- `runs:read` - read runs, sessions, steps, results, evidence, personas and usage, list a run's share links,
  and validate a run spec.
- `runs:write` - start, pause, resume, cancel and delete runs, retest an issue, and mint and revoke share links.
- `files:write` - upload, list and delete the files a persona can bring into a run.
- `credentials:write` - manage your own model provider keys, test-account logins and ChatGPT connection.
- `keys:admin` - mint, list and revoke API keys, and manage billing.

A key minted without `scopes` gets `runs:write` and `runs:read`.

## A run, end to end

1. `POST /api/v1/runs/validate` with a run spec: every check `POST /api/v1/runs` makes, the sessions it
   would plan and the most it can bill on your plan (`cost_ceiling`: dollars, a one-line basis, and who
   bills its model calls) - and no run.
2. `POST /api/v1/runs` with the same spec: `202` with the `run_id`.
3. Poll `GET /api/v1/runs/{run_id}`. `status` moves `pending`, `running`, `observing`, then stops on
   `completed`, `failed` or `cancelled`. Only `completed` means there are results to read.
4. Read `GET /api/v1/runs/{run_id}/issues`, `/verdicts` and `/opportunities`, or the whole artifact at
   `GET /api/v1/runs/{run_id}/artifact`.

## Issues and persona feedback

An issue whose reading says `may_file_ticket_alone: true` was reproduced by a replay and can become a
ticket as it stands. Verdicts and opportunities are persona feedback: subjective, tied to the evidence and
the persona's own words, and never a ticket on their own.

## Conventions

- Responses leave an unset field out rather than writing `null`.
- An error is JSON with a `detail` that says what was refused and why.
- A run, credential or key of another tenant's answers the same `404` as one that does not exist.
- Starting, following and reading runs are also MCP tools at `https://api.saram.sh/mcp`, for a coding agent,
  behind the same credential and the same scopes.

## Contents

**Operations**

- [GET /api/v1/healthz](#scalar-operation-get-apiv1healthz)
- [POST /api/v1/runs](#scalar-operation-post-apiv1runs)
- [GET /api/v1/runs](#scalar-operation-get-apiv1runs)
- [GET /api/v1/runs/{run\_id}](#scalar-operation-get-apiv1runsrun-id)
- [POST /api/v1/runs/{run\_id}/{signal}](#scalar-operation-post-apiv1runsrun-idsignal)
- [GET /api/v1/runs/{run\_id}/artifact](#scalar-operation-get-apiv1runsrun-idartifact)
- [POST /api/v1/runs/validate](#scalar-operation-post-apiv1runsvalidate)
- [POST /api/v1/runs/{run\_id}/delete](#scalar-operation-post-apiv1runsrun-iddelete)
- [POST /api/v1/runs/{run\_id}/sessions/{session\_id}/shares](#scalar-operation-post-apiv1runsrun-idsessionssession-idshares)
- [GET /api/v1/runs/{run\_id}/shares](#scalar-operation-get-apiv1runsrun-idshares)
- [POST /api/v1/shares/{share\_id}/revoke](#scalar-operation-post-apiv1sharesshare-idrevoke)
- [GET /api/v1/runs/{run\_id}/sessions](#scalar-operation-get-apiv1runsrun-idsessions)
- [GET /api/v1/runs/{run\_id}/sessions/{session\_id}/steps](#scalar-operation-get-apiv1runsrun-idsessionssession-idsteps)
- [GET /api/v1/runs/{run\_id}/issues](#scalar-operation-get-apiv1runsrun-idissues)
- [GET /api/v1/runs/{run\_id}/issues/{issue\_id}](#scalar-operation-get-apiv1runsrun-idissuesissue-id)
- [POST /api/v1/runs/{run\_id}/issues/{issue\_id}/retest](#scalar-operation-post-apiv1runsrun-idissuesissue-idretest)
- [GET /api/v1/runs/{run\_id}/issues/{issue\_id}/retests](#scalar-operation-get-apiv1runsrun-idissuesissue-idretests)
- [GET /api/v1/runs/{run\_id}/issues/{issue\_id}/retests/{retest\_id}](#scalar-operation-get-apiv1runsrun-idissuesissue-idretestsretest-id)
- [GET /api/v1/runs/{run\_id}/verdicts](#scalar-operation-get-apiv1runsrun-idverdicts)
- [GET /api/v1/runs/{run\_id}/opportunities](#scalar-operation-get-apiv1runsrun-idopportunities)
- [GET /api/v1/runs/{run\_id}/tester-errors](#scalar-operation-get-apiv1runsrun-idtester-errors)
- [GET /api/v1/usage](#scalar-operation-get-apiv1usage)
- [GET /api/v1/usage.csv](#scalar-operation-get-apiv1usagecsv)
- [POST /api/v1/billing/portal](#scalar-operation-post-apiv1billingportal)
- [GET /api/v1/billing](#scalar-operation-get-apiv1billing)
- [POST /api/v1/billing/auto-refill](#scalar-operation-post-apiv1billingauto-refill)
- [POST /api/v1/billing/subscription](#scalar-operation-post-apiv1billingsubscription)
- [DELETE /api/v1/billing/subscription](#scalar-operation-delete-apiv1billingsubscription)
- [POST /api/v1/billing/setup](#scalar-operation-post-apiv1billingsetup)
- [POST /api/v1/billing/top-ups](#scalar-operation-post-apiv1billingtop-ups)
- [GET /api/v1/billing/top-ups/{topup\_id}](#scalar-operation-get-apiv1billingtop-upstopup-id)
- [POST /api/v1/providers/codex/login](#scalar-operation-post-apiv1providerscodexlogin)
- [GET /api/v1/providers/codex](#scalar-operation-get-apiv1providerscodex)
- [POST /api/v1/providers/codex/disconnect](#scalar-operation-post-apiv1providerscodexdisconnect)
- [GET /api/v1/credentials](#scalar-operation-get-apiv1credentials)
- [POST /api/v1/credentials](#scalar-operation-post-apiv1credentials)
- [DELETE /api/v1/credentials/{name}](#scalar-operation-delete-apiv1credentialsname)
- [GET /api/v1/me](#scalar-operation-get-apiv1me)
- [GET /api/v1/api-keys](#scalar-operation-get-apiv1api-keys)
- [POST /api/v1/api-keys](#scalar-operation-post-apiv1api-keys)
- [DELETE /api/v1/api-keys/{key\_id}](#scalar-operation-delete-apiv1api-keyskey-id)
- [GET /api/v1/github/installations](#scalar-operation-get-apiv1githubinstallations)
- [GET /api/v1/files](#scalar-operation-get-apiv1files)
- [POST /api/v1/files](#scalar-operation-post-apiv1files)
- [GET /api/v1/files/{file\_id}](#scalar-operation-get-apiv1filesfile-id)
- [POST /api/v1/files/{file\_id}/delete](#scalar-operation-post-apiv1filesfile-iddelete)
- [GET /api/v1/personas](#scalar-operation-get-apiv1personas)
- [GET /api/v1/personas/{address}](#scalar-operation-get-apiv1personasaddress)
- [GET /api/v1/blobs](#scalar-operation-get-apiv1blobs)
- [HEAD /api/v1/blobs](#scalar-operation-head-apiv1blobs)

**Schemas**

- [A](#scalar-schema-a)
- [AbandonBehavior](#scalar-schema-abandonbehavior)
- [AccessibilityEnum](#scalar-schema-accessibilityenum)
- [ActionPolicy](#scalar-schema-actionpolicy)
- [ActionStep](#scalar-schema-actionstep)
- [AlignedStep](#scalar-schema-alignedstep)
- [ApiKeyList](#scalar-schema-apikeylist)
- [ApiKeyMinted](#scalar-schema-apikeyminted)
- [ApiKeySummary](#scalar-schema-apikeysummary)
- [ApiKeyWrite](#scalar-schema-apikeywrite)
- [Attribution](#scalar-schema-attribution)
- [AuthState](#scalar-schema-authstate)
- [AutoRefill](#scalar-schema-autorefill)
- [AutoRefillState](#scalar-schema-autorefillstate)
- [B](#scalar-schema-b)
- [BaselineStatus](#scalar-schema-baselinestatus)
- [BillingOverview](#scalar-schema-billingoverview)
- [BillingPortal](#scalar-schema-billingportal)
- [BillingSetup](#scalar-schema-billingsetup)
- [BillingSubscription](#scalar-schema-billingsubscription)
- [Check](#scalar-schema-check)
- [CheckedBy](#scalar-schema-checkedby)
- [Claim](#scalar-schema-claim)
- [CodexDisconnected](#scalar-schema-codexdisconnected)
- [CodexGateway](#scalar-schema-codexgateway)
- [CodexLoginStarted](#scalar-schema-codexloginstarted)
- [CodexPending](#scalar-schema-codexpending)
- [CodexProviders](#scalar-schema-codexproviders)
- [CodexProvidersEntry](#scalar-schema-codexprovidersentry)
- [CodexStatus](#scalar-schema-codexstatus)
- [Constraints](#scalar-schema-constraints)
- [Contradiction](#scalar-schema-contradiction)
- [Conversation](#scalar-schema-conversation)
- [CostCeiling](#scalar-schema-costceiling)
- [CredentialList](#scalar-schema-credentiallist)
- [CredentialRecord](#scalar-schema-credentialrecord)
- [CredentialWrite](#scalar-schema-credentialwrite)
- [DecidedBy](#scalar-schema-decidedby)
- [Decision](#scalar-schema-decision)
- [Device](#scalar-schema-device)
- [DisplaySplit](#scalar-schema-displaysplit)
- [DomainLiteracy](#scalar-schema-domainliteracy)
- [ErrorResponse](#scalar-schema-errorresponse)
- [Evidence](#scalar-schema-evidence)
- [Evidence1](#scalar-schema-evidence1)
- [EvidenceRefs](#scalar-schema-evidencerefs)
- [Exploration](#scalar-schema-exploration)
- [FileDeleted](#scalar-schema-filedeleted)
- [FileList](#scalar-schema-filelist)
- [FileRecord](#scalar-schema-filerecord)
- [GitHubInstallationList](#scalar-schema-githubinstallationlist)
- [GitHubInstallationRecord](#scalar-schema-githubinstallationrecord)
- [Goals](#scalar-schema-goals)
- [HTTPValidationError](#scalar-schema-httpvalidationerror)
- [HelpSeeking](#scalar-schema-helpseeking)
- [Input](#scalar-schema-input)
- [Intensity](#scalar-schema-intensity)
- [Issue](#scalar-schema-issue)
- [IssueDetail](#scalar-schema-issuedetail)
- [IssueList](#scalar-schema-issuelist)
- [IssueReading](#scalar-schema-issuereading)
- [JudgeDecision](#scalar-schema-judgedecision)
- [Kind](#scalar-schema-kind)
- [Kind1](#scalar-schema-kind1)
- [LiveStatus](#scalar-schema-livestatus)
- [Location](#scalar-schema-location)
- [Location1](#scalar-schema-location1)
- [Me](#scalar-schema-me)
- [MeTenant](#scalar-schema-metenant)
- [MeUser](#scalar-schema-meuser)
- [Method](#scalar-schema-method)
- [MonthlyCap](#scalar-schema-monthlycap)
- [Network](#scalar-schema-network)
- [Opportunity](#scalar-schema-opportunity)
- [OpportunityList](#scalar-schema-opportunitylist)
- [PaymentRequired](#scalar-schema-paymentrequired)
- [Persona](#scalar-schema-persona)
- [PersonaList](#scalar-schema-personalist)
- [PersonaSummary](#scalar-schema-personasummary)
- [Phrasing](#scalar-schema-phrasing)
- [PlanLimits](#scalar-schema-planlimits)
- [PlannedSession](#scalar-schema-plannedsession)
- [Priors](#scalar-schema-priors)
- [ProductFamiliarity](#scalar-schema-productfamiliarity)
- [Provenance](#scalar-schema-provenance)
- [ProviderRun](#scalar-schema-providerrun)
- [ProviderSpend](#scalar-schema-providerspend)
- [QuoteStep](#scalar-schema-quotestep)
- [Reading](#scalar-schema-reading)
- [RefillSkip](#scalar-schema-refillskip)
- [ReproStep](#scalar-schema-reprostep)
- [Reproduction](#scalar-schema-reproduction)
- [Reproduction1](#scalar-schema-reproduction1)
- [Result1](#scalar-schema-result1)
- [RetestAttempt](#scalar-schema-retestattempt)
- [RetestList](#scalar-schema-retestlist)
- [RetestRecord](#scalar-schema-retestrecord)
- [RetestRequest](#scalar-schema-retestrequest)
- [RunDeleted](#scalar-schema-rundeleted)
- [RunDetail](#scalar-schema-rundetail)
- [RunFile](#scalar-schema-runfile)
- [RunFileAssign](#scalar-schema-runfileassign)
- [RunList](#scalar-schema-runlist)
- [RunSignalled](#scalar-schema-runsignalled)
- [RunSummary](#scalar-schema-runsummary)
- [SessionList](#scalar-schema-sessionlist)
- [SessionRow](#scalar-schema-sessionrow)
- [Severity](#scalar-schema-severity)
- [ShareCreated](#scalar-schema-sharecreated)
- [ShareList](#scalar-schema-sharelist)
- [ShareSummary](#scalar-schema-sharesummary)
- [ShareWrite](#scalar-schema-sharewrite)
- [SpecValidation](#scalar-schema-specvalidation)
- [StartedRun](#scalar-schema-startedrun)
- [Status](#scalar-schema-status)
- [StepRef](#scalar-schema-stepref)
- [StepTiming](#scalar-schema-steptiming)
- [StepTokens](#scalar-schema-steptokens)
- [StepsPage](#scalar-schema-stepspage)
- [SubscriptionState](#scalar-schema-subscriptionstate)
- [Summary](#scalar-schema-summary)
- [TechnicalLiteracy](#scalar-schema-technicalliteracy)
- [TesterError](#scalar-schema-testererror)
- [TesterErrorList](#scalar-schema-testererrorlist)
- [TesterErrorReading](#scalar-schema-testererrorreading)
- [Tolerance](#scalar-schema-tolerance)
- [TopUpRecord](#scalar-schema-topuprecord)
- [TopUpWrite](#scalar-schema-topupwrite)
- [TrustDefaults](#scalar-schema-trustdefaults)
- [Type](#scalar-schema-type)
- [UsageCeiling](#scalar-schema-usageceiling)
- [UsageChargeRun](#scalar-schema-usagechargerun)
- [UsageCharges](#scalar-schema-usagecharges)
- [UsageMeter](#scalar-schema-usagemeter)
- [UsagePersona](#scalar-schema-usagepersona)
- [UsageRefund](#scalar-schema-usagerefund)
- [UsageReport](#scalar-schema-usagereport)
- [UsageRun](#scalar-schema-usagerun)
- [UsageTotals](#scalar-schema-usagetotals)
- [ValidationError](#scalar-schema-validationerror)
- [Verbosity](#scalar-schema-verbosity)
- [Verdict](#scalar-schema-verdict)
- [Verdict1](#scalar-schema-verdict1)
- [VerdictDiscarded](#scalar-schema-verdictdiscarded)
- [VerdictList](#scalar-schema-verdictlist)
- [VerdictPromptSource](#scalar-schema-verdictpromptsource)
- [VerdictRow](#scalar-schema-verdictrow)
- [Verdicts](#scalar-schema-verdicts)
- [Verification](#scalar-schema-verification)
- [Video](#scalar-schema-video)
- [Vocabulary](#scalar-schema-vocabulary)
- [Voice](#scalar-schema-voice)
- [RunSpec](#scalar-schema-runspec)
- [RunSpecMission](#scalar-schema-runspecmission)
- [RunSpecProvider](#scalar-schema-runspecprovider)

<a id="scalar-context-global-servers"></a>

## Servers

- **URL:** `https://api.saram.sh`
  - **Description:** Saram

## Tags

### runs

Start a run from a run spec, check a spec without starting one, list and read runs, and pause, resume or cancel one while it executes.

### sessions

One session is one persona on one mission. Read a run's sessions, and each session's steps: what the persona saw, did and said, aligned with what the browser recorded, readable while it runs.

### shares

Publish one finished session's replay by an unlisted link anyone holding it can open with no sign-in, list a run's links, and revoke one. The recording is not redacted. Off until Saram turns it on for your tenant.

### artifact

What a run found: the artifact, its reproduced issues, the personas' verdicts and opportunities, and retests that replay an issue again to answer whether it is fixed.

### personas

The personas a run spec can name, by `id` or `id@version`, and each one's full document.

### files

Files a persona can bring into a run - a document to upload, an image to attach. Uploaded once, named in a run spec by their `ref`, never served back.

### blobs

Evidence bytes - screenshots, videos, traces - streamed by the ref a step or an issue records, with `Range` for seeking.

### credentials

Your own model provider keys and the test-account logins your personas sign in with. Stored as secrets and named in a run spec by `secret_ref`; a value is never returned.

### providers

Connect your own ChatGPT subscription as a model provider for the runs you start yourself: a device login, its status, and disconnecting it.

### identity

Who a credential is - its tenant, role and scopes - and the API keys that tenant holds.

### usage

What your runs have used and cost, by month, and billing: your plan, prepaid balance, top-ups, auto-refill and the card on file.

### health

Whether the API is up. The one route that needs no credential.

## Operations

<a id="scalar-operation-get-apiv1healthz"></a>

### Healthz

- **Method:** `GET`
- **Path:** `/api/v1/healthz`
- **Operation ID:** `healthz`
- **Tags:** health

Whether the API is up: `{"status": "ok"}`. The one route that takes no credential.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

`object`

**Additional properties:**

`string`

<a id="scalar-example-1"></a>

**Generated example:**

```json
{
  "additionalProperty": ""
}
```

<a id="scalar-operation-post-apiv1runs"></a>

### Create Run

- **Method:** `POST`
- **Path:** `/api/v1/runs`
- **Operation ID:** `create_run`
- **Tags:** runs

Start a run: send the personas in the spec through the target, one session per persona per mission.

The body is a run spec. It is checked in full before anything starts - the contract, the references
between its parts, your model providers and credentials - and a spec that fails any check is a 422 naming
the field and the reason, with no run created. `POST /api/v1/runs/validate` makes the same checks
without starting anything, and says what the run would cost. A 402 is a run your plan cannot pay for.

The answer is the `run_id` at once, while the run starts in the background: follow it with
`GET /api/v1/runs/{run_id}` until `status` is terminal.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Request body

**Required:** `true`

**Content type:** `application/json`

[RunSpec](#scalar-schema-runspec)

Input to the control plane. A run = target x persona set x missions x budget x baseline.

**Example:**

```json
{
  "target": {
    "entry_url": "https://example.com"
  },
  "personas": [
    "dana_first_timer@1"
  ],
  "missions": [
    {
      "id": "sign_up",
      "goal": "create an account",
      "success_criteria": [
        {
          "kind": "judge_assertion",
          "value": "the persona reached a signed-in page"
        }
      ]
    }
  ],
  "budget": {
    "steps_per_session": 30
  }
}
```

#### Responses

##### 202 The run is accepted and has started.

**Content type:** `application/json`

[StartedRun](#scalar-schema-startedrun)

A run accepted and started. Follow it by its `run_id`: `GET /api/v1/runs/{run_id}`, or the MCP tools
`get_run_status` and `wait_for_run`.

<a id="scalar-example-2"></a>

**Generated example:**

```json
{
  "run_id": "",
  "workflow_id": ""
}
```

##### 402 no run is started. On a plan that bills steps (pilot), because this tenant's month has billed its monthly cap (the detail carries what the month has billed and what the cap is, and monthly\_cap the same numbers). On a plan that prices Usage (free, pro), because the available balance does not cover the most Usage this run can use (the detail carries the ceiling, the balance, and what to change, and usage\_ceiling the same numbers as POST /api/v1/runs/validate answers them); such a tenant never meets the monthly cap

**Content type:** `application/json`

[PaymentRequired](#scalar-schema-paymentrequired)

No run is started, because the tenant's plan cannot pay for it: `POST /runs`' 402 body, and the structured
content of the `start_run` tool's refusal for the same reason.

<a id="scalar-example-3"></a>

**Generated example:**

```json
{
  "detail": "",
  "usage_ceiling": {
    "sessions": 1,
    "steps_per_session": 1,
    "wall_clock_s_per_session": 1,
    "usage_per_session": 1,
    "usage": 1,
    "usage_usd": 1,
    "usage_price_usd": 1,
    "available_usd": 1,
    "covered": true,
    "steps_per_session_that_fit": 1,
    "enforced": true
  },
  "monthly_cap": {
    "period": "",
    "billed_usd": 1,
    "cap_usd": 1
  }
}
```

##### 403 the API key lacks the required scope

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

<a id="scalar-example-4"></a>

**Generated example:**

```json
{
  "detail": [
    {
      "loc": [
        ""
      ],
      "msg": "",
      "type": "",
      "input": null,
      "ctx": {}
    }
  ]
}
```

<a id="scalar-operation-get-apiv1runs"></a>

### List Runs

- **Method:** `GET`
- **Path:** `/api/v1/runs`
- **Operation ID:** `list_runs`
- **Tags:** runs

Your runs, newest first: up to `limit` (50 unless you ask for up to 500), optionally only those with
one `status` or against one target.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Query parameters

- **`limit`**: `integer`, default: `50`, minimum: `1`, maximum: `500`
- **`status`**: `string | null`
- **`target`**: `string | null`

  entry URL or target name, exact

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[RunList](#scalar-schema-runlist)

Your runs, newest first.

<a id="scalar-example-5"></a>

**Generated example:**

```json
{
  "runs": [
    {
      "run_id": "",
      "status": "",
      "status_reason": "",
      "target": "",
      "target_name": "",
      "baseline_run_id": "",
      "tenant_id": "",
      "created_by": "",
      "caller": "",
      "confirmation_rate": 1,
      "repro_attempts": 1,
      "repro_gate_discards": 1,
      "repro_gate_ref": "",
      "billed_steps": 0,
      "billable_usd": 1,
      "cost_usd": 1,
      "actor_cost_usd": 1,
      "judge_cost_usd": 1,
      "usage": 1,
      "usage_usd": 1,
      "refunded_usd": 1,
      "held_usd": 1,
      "usage_state": "live",
      "sessions_per_hour": 1,
      "artifact_ref": "",
      "summary": {
        "personas_run": 1,
        "missions": 1,
        "sessions": 1,
        "completed": 1,
        "abandoned": 1,
        "blocked": 1,
        "errored": 1,
        "budget_exhausted": 0,
        "issues_total": 1,
        "issues_reproducible": 1,
        "issues_marker_matched": 1,
        "tester_errors": 1,
        "issues_new_vs_baseline": 1,
        "issues_resolved_vs_baseline": 1,
        "issues_persisting_vs_baseline": 1,
        "opportunities_total": 1,
        "verdicts": {
          "positive": 1,
          "negative": 1,
          "undecided": 1,
          "top_reasons": [
            ""
          ]
        },
        "confirmation_rate": 0,
        "reproduction": {
          "candidates": 1,
          "merged": 1,
          "evaluated": 1,
          "confirmed": 1,
          "inconclusive": 1,
          "excluded": 1
        },
        "gate_discards": 0,
        "path_diversity": 0,
        "cost_usd": 1,
        "actor_cost_usd": 0,
        "judge_cost_usd": 0,
        "wall_clock_s": 1
      },
      "session_counts": {
        "additionalProperty": 1
      },
      "packs": [
        ""
      ],
      "created_at": "",
      "started_at": "",
      "finished_at": "",
      "evidence_expires_at": "",
      "evidence_expired": false
    }
  ]
}
```

##### 403 the API key lacks the required scope

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

[Generated example](#scalar-example-4)

<a id="scalar-operation-get-apiv1runsrun-id"></a>

### Get Run

- **Method:** `GET`
- **Path:** `/api/v1/runs/{run_id}`
- **Operation ID:** `get_run`
- **Tags:** runs

One run: its status, what it has cost and billed so far, the artifact's summary once there is one,
and every session with where it has got to.

Poll this while a run is open. `status` moves `pending`, `running`, `observing`, then stops on
`completed`, `failed` or `cancelled`, and only `completed` means there are results to read. Live fields
(`billed_steps`, each session's `heartbeat_step`) climb while the run does. A run of another tenant's is a
404, the same as a run that does not exist.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Path parameters

- **`run_id` (required)**: `string`

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[RunDetail](#scalar-schema-rundetail)

One run in full: everything `RunSummary` carries, plus its budgets, the files it hands its personas, its
live state while it is open, and every session.

**Example:**

```json
{
  "run_id": "run_2026-09-26_4f1c2a",
  "status": "completed",
  "target": "https://example.com",
  "tenant_id": "ten_2026-06-02_3f9a2c14",
  "created_by": "key_2026-09-01_8d1e04b7",
  "caller": "acme/ci",
  "confirmation_rate": 1,
  "repro_attempts": 2,
  "repro_gate_discards": 0,
  "repro_gate_ref": "s3://saram-evidence/run_2026-09-26_4f1c2a/repro_gate.json",
  "billed_steps": 27,
  "cost_usd": 0.59,
  "actor_cost_usd": 0.38,
  "judge_cost_usd": 0.21,
  "usage": 30.114,
  "usage_usd": 0.48,
  "refunded_usd": 0,
  "usage_state": "settled",
  "sessions_per_hour": 49.7,
  "artifact_ref": "s3://saram-evidence/run_2026-09-26_4f1c2a/artifact.json",
  "summary": {
    "personas_run": 2,
    "missions": 1,
    "sessions": 2,
    "completed": 1,
    "abandoned": 1,
    "blocked": 0,
    "errored": 0,
    "budget_exhausted": 0,
    "issues_total": 1,
    "issues_reproducible": 1,
    "issues_marker_matched": 0,
    "tester_errors": 0,
    "opportunities_total": 0,
    "verdicts": {
      "positive": 1,
      "negative": 1,
      "undecided": 0,
      "top_reasons": [
        "The sign-up form failed without saying why.",
        "Signing up was fast."
      ]
    },
    "confirmation_rate": 1,
    "reproduction": {
      "candidates": 2,
      "merged": 1,
      "evaluated": 1,
      "confirmed": 1,
      "inconclusive": 0,
      "excluded": 0
    },
    "gate_discards": 0,
    "path_diversity": 1,
    "cost_usd": 0.59,
    "actor_cost_usd": 0.38,
    "judge_cost_usd": 0.21,
    "wall_clock_s": 214
  },
  "session_counts": {
    "completed": 1,
    "abandoned": 1
  },
  "packs": [
    "general"
  ],
  "created_at": "2026-09-26T14:02:11Z",
  "started_at": "2026-09-26T14:02:14Z",
  "finished_at": "2026-09-26T14:05:47Z",
  "evidence_expires_at": "2026-12-25T14:02:11Z",
  "evidence_expired": false,
  "workflow_id": "run-run_2026-09-26_4f1c2a",
  "budgets": {
    "steps_per_session": 30
  },
  "run_spec_ref": "s3://saram-evidence/run_2026-09-26_4f1c2a/run_spec.json",
  "files": [],
  "sessions": [
    {
      "session_id": "run_2026-09-26_4f1c2a.sess_01_dana_first_timer",
      "persona_id": "dana_first_timer@1",
      "pack": "general",
      "tags": [
        "general",
        "novice",
        "desktop",
        "first_time"
      ],
      "mission_id": "sign_up",
      "status": "abandoned",
      "result": "abandoned",
      "end_reason": "patience_exhausted",
      "fault": "none",
      "steps": 18,
      "end_step": 18,
      "abandon_step": 18,
      "tokens_in": 212340,
      "tokens_out": 3120,
      "tokens_cache_read": 168000,
      "tokens_cache_write": 0,
      "model": "anthropic:claude-sonnet-5",
      "cost_usd": 0.26,
      "price_source": "genai-prices/0.0.41",
      "wall_clock_s": 142.6,
      "uncertain_steps": [],
      "resumed_at_steps": [],
      "verdict_decision": "negative",
      "outcome_read": "abandoned",
      "usage": 21.412,
      "usage_usd": 0.34,
      "refunded_usd": 0,
      "workflow_id": "run_2026-09-26_4f1c2a/run_2026-09-26_4f1c2a.sess_01_dana_first_timer",
      "agent_trace_ref": "s3://saram-evidence/run_2026-09-26_4f1c2a/run_2026-09-26_4f1c2a.sess_01_dana_first_timer/agent.ndjson",
      "surface_trace_ref": "s3://saram-evidence/run_2026-09-26_4f1c2a/run_2026-09-26_4f1c2a.sess_01_dana_first_timer/surface.ndjson",
      "video_ref": "s3://saram-evidence/run_2026-09-26_4f1c2a/run_2026-09-26_4f1c2a.sess_01_dana_first_timer/video.webm",
      "video_url": "/api/v1/blobs?ref=s3%3A%2F%2Fsaram-evidence%2Frun_2026-09-26_4f1c2a%2Frun_2026-09-26_4f1c2a.sess_01_dana_first_timer%2Fvideo.webm",
      "recorded_under_mask": false,
      "created_at": "2026-09-26T14:02:14Z",
      "finished_at": "2026-09-26T14:04:39Z"
    },
    {
      "session_id": "run_2026-09-26_4f1c2a.sess_02_marcus_fast_skimmer",
      "persona_id": "marcus_fast_skimmer@1",
      "pack": "general",
      "tags": [
        "general",
        "power_user",
        "desktop",
        "skimmer",
        "fast"
      ],
      "mission_id": "sign_up",
      "status": "completed",
      "result": "completed",
      "end_reason": "success_criteria_met",
      "fault": "none",
      "steps": 9,
      "end_step": 9,
      "tokens_in": 88410,
      "tokens_out": 1460,
      "tokens_cache_read": 70200,
      "tokens_cache_write": 0,
      "model": "anthropic:claude-sonnet-5",
      "cost_usd": 0.12,
      "price_source": "genai-prices/0.0.41",
      "wall_clock_s": 71.3,
      "uncertain_steps": [],
      "resumed_at_steps": [],
      "verdict_decision": "positive",
      "outcome_read": "completed",
      "usage": 8.702,
      "usage_usd": 0.14,
      "refunded_usd": 0,
      "workflow_id": "run_2026-09-26_4f1c2a/run_2026-09-26_4f1c2a.sess_02_marcus_fast_skimmer",
      "agent_trace_ref": "s3://saram-evidence/run_2026-09-26_4f1c2a/run_2026-09-26_4f1c2a.sess_02_marcus_fast_skimmer/agent.ndjson",
      "surface_trace_ref": "s3://saram-evidence/run_2026-09-26_4f1c2a/run_2026-09-26_4f1c2a.sess_02_marcus_fast_skimmer/surface.ndjson",
      "video_ref": "s3://saram-evidence/run_2026-09-26_4f1c2a/run_2026-09-26_4f1c2a.sess_02_marcus_fast_skimmer/video.webm",
      "video_url": "/api/v1/blobs?ref=s3%3A%2F%2Fsaram-evidence%2Frun_2026-09-26_4f1c2a%2Frun_2026-09-26_4f1c2a.sess_02_marcus_fast_skimmer%2Fvideo.webm",
      "recorded_under_mask": false,
      "created_at": "2026-09-26T14:02:14Z",
      "finished_at": "2026-09-26T14:03:27Z"
    }
  ]
}
```

##### 403 the API key lacks the required scope

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

[Generated example](#scalar-example-4)

<a id="scalar-operation-post-apiv1runsrun-idsignal"></a>

### Signal Run

- **Method:** `POST`
- **Path:** `/api/v1/runs/{run_id}/{signal}`
- **Operation ID:** `signal_run`
- **Tags:** runs

Pause, resume or cancel a run in flight: the last path segment is `pause`, `resume` or `cancel`.

`pause` starts no new session and holds the running ones; `resume` releases a paused run, including one
that paused itself on reaching `budget.total_cost_usd`; `cancel` stops the running sessions, starts no
more, and ends the run `cancelled` without judging what they did. A run that has already finished is a
409\.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Path parameters

- **`run_id` (required)**: `string`
- **`signal` (required)**: `string`

#### Responses

##### 202 The signal is delivered to the run.

**Content type:** `application/json`

[RunSignalled](#scalar-schema-runsignalled)

A pause, resume or cancel delivered to a run.

<a id="scalar-example-6"></a>

**Generated example:**

```json
{
  "run_id": "",
  "signal": ""
}
```

##### 403 the API key lacks the required scope

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

[Generated example](#scalar-example-4)

<a id="scalar-operation-get-apiv1runsrun-idartifact"></a>

### Get Artifact

- **Method:** `GET`
- **Path:** `/api/v1/runs/{run_id}/artifact`
- **Operation ID:** `get_artifact`
- **Tags:** artifact

The run's whole artifact: every issue, verdict, opportunity and tester error, each session's outcome,
the summary, and the evidence refs behind them, as one document in the published artifact format.

A 404 until the run has assembled one: a `completed` run has one, and a run that failed or was cancelled
before assembly has none. The per-kind views (`/issues`, `/verdicts`, `/opportunities`,
`/tester-errors`) serve the same records with how each may be acted on beside them.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Path parameters

- **`run_id` (required)**: `string`

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

`object`

<a id="scalar-example-7"></a>

**Generated example:**

```json
{
  "additionalProperty": "anything"
}
```

##### 403 the API key lacks the required scope

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

[Generated example](#scalar-example-4)

<a id="scalar-operation-post-apiv1runsvalidate"></a>

### Validate Run

- **Method:** `POST`
- **Path:** `/api/v1/runs/validate`
- **Operation ID:** `validate_run`
- **Tags:** runs

The dry run: everything `POST /runs` checks, the sessions it would plan, the most it can bill on the
caller's plan (`cost_ceiling`, on every plan), and no run.

Every refusal `POST /runs` would give is the same 422 here, and a 200 means that spec is accepted as
written - so a caller can learn the API, and check a spec they are about to spend money on, without
spending it.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Request body

**Required:** `true`

**Content type:** `application/json`

[RunSpec](#scalar-schema-runspec)

Input to the control plane. A run = target x persona set x missions x budget x baseline.

**Example:**

```json
{
  "target": {
    "entry_url": "https://example.com"
  },
  "personas": [
    "dana_first_timer@1"
  ],
  "missions": [
    {
      "id": "sign_up",
      "goal": "create an account",
      "success_criteria": [
        {
          "kind": "judge_assertion",
          "value": "the persona reached a signed-in page"
        }
      ]
    }
  ],
  "budget": {
    "steps_per_session": 30
  }
}
```

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[SpecValidation](#scalar-schema-specvalidation)

`POST /api/v1/runs/validate`: what `POST /runs` would make of this spec, without making it.

Everything `POST /runs` would refuse is the same 422 here: the contract, the cross-references
inside `providers`, and the caller's own model provider - including a ChatGPT login the gateway no longer
holds. A 200 means the spec is accepted as written.

<a id="scalar-example-8"></a>

**Generated example:**

```json
{
  "run_id": "",
  "spec": {
    "additionalProperty": "anything"
  },
  "sessions": [
    {
      "session_id": "",
      "persona_id": "",
      "mission_id": "",
      "entry_url": "",
      "account_secret_ref": ""
    }
  ],
  "warnings": [
    ""
  ],
  "cost_ceiling": {
    "usd": 1,
    "billed_in": "usage",
    "plan": "",
    "price_usd": 1,
    "sessions": 1,
    "steps_per_session": 1,
    "wall_clock_s_per_session": 1,
    "usage_per_session": 1,
    "usage": 1,
    "basis": "",
    "model_calls": ""
  },
  "usage_ceiling": {
    "sessions": 1,
    "steps_per_session": 1,
    "wall_clock_s_per_session": 1,
    "usage_per_session": 1,
    "usage": 1,
    "usage_usd": 1,
    "usage_price_usd": 1,
    "available_usd": 1,
    "covered": true,
    "steps_per_session_that_fit": 1,
    "enforced": true
  }
}
```

##### 403 the API key lacks the required scope

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

[Generated example](#scalar-example-4)

<a id="scalar-operation-post-apiv1runsrun-iddelete"></a>

### Delete Run

- **Method:** `POST`
- **Path:** `/api/v1/runs/{run_id}/delete`
- **Operation ID:** `delete_run`
- **Tags:** runs

Delete one of your runs, for good: its traces, screenshots, video and checkpoints, its own documents
(the spec, the artifact), and its record - the run, its sessions, issues, verdicts, opportunities and
retests. Every route then answers the run with the 404 an unknown run gets.

Your own tenant's runs and no other: another tenant's run is that same 404, so a delete neither reaches
nor confirms a run that is not yours. Asking again answers what the first delete did, so a retry is safe.
A run that is still running, or has a retest still running, is a 409 and nothing is deleted: cancel it
and delete it once it has stopped. What the run was billed stays in your usage.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Path parameters

- **`run_id` (required)**: `string`

#### Responses

##### 200 The run is deleted, now or by an earlier request.

**Content type:** `application/json`

[RunDeleted](#scalar-schema-rundeleted)

A run its owner deleted: its evidence, its recordings, and its record - the run, its sessions, issues,
verdicts, opportunities, retests and artifact - are gone, and every route now answers the run with the 404
an unknown run gets. Asking again answers this same body, so a retried delete is safe.

What is kept is what the run was billed: the usage ledger outlives a deleted run as it outlives expired
evidence, so a month's usage still counts it.

<a id="scalar-example-9"></a>

**Generated example:**

```json
{
  "run_id": "",
  "deleted_at": "",
  "objects_deleted": 1
}
```

##### 403 the API key lacks the required scope

##### 409 the run, or a retest of it, is still running: nothing is deleted under something still writing to it. Cancel it and delete it once it has stopped

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

[Generated example](#scalar-example-4)

<a id="scalar-operation-post-apiv1runsrun-idsessionssession-idshares"></a>

### Create Share Link

- **Method:** `POST`
- **Path:** `/api/v1/runs/{run_id}/sessions/{session_id}/shares`
- **Operation ID:** `create_share_link`
- **Tags:** shares

Publish one finished session's replay by a link anyone holding it can open, with no sign-in.

The link plays the session's recording with the persona's steps and narration beside it. Whatever the browser
showed that is not masked - the test account's email, any text the product rendered - anyone with the link
sees, and `warning` in the answer says what that is: show it to whoever posts the link. Revoke it with
`POST /api/v1/shares/{share_id}/revoke`, and it stops playing on the next request.

The `url` is shown once, in this answer: only a hash of it is kept, so a lost link is replaced by minting
another and revoking the first. It expires after `expires_in_days` (30 unless you ask for 1 to 90), and never
later than the run's evidence does. A session that has not ended yet is a 409, as is a run whose evidence
has expired, and a session the browser pool did not record under its mask (`masked` in the answer says the
session's was). Sharing is off for every tenant until Saram turns it on, and a 403 says so.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Path parameters

- **`run_id` (required)**: `string`
- **`session_id` (required)**: `string`

#### Request body

**Content type:** `application/json`

[ShareWrite](#scalar-schema-sharewrite) | `null`

The optional body of `POST /runs/{run_id}/sessions/{session_id}/shares`.

<a id="scalar-example-10"></a>

**Generated example:**

```json
{
  "expires_in_days": 1
}
```

#### Responses

##### 201 The link is minted. Its URL is in this answer and in no other.

**Content type:** `application/json`

[ShareCreated](#scalar-schema-sharecreated)

A new public link to one session's replay, and the one and only sight of its URL.

Anyone holding `url` can watch the session's recording, which hides only what the browser pool masked when it
recorded it (`masked`): whatever else the browser showed is in it. Only a hash of the link's token is stored, so
no route shows the URL again; a lost link is replaced by minting another and revoking this one with
`POST /api/v1/shares/{id}/revoke`.

<a id="scalar-example-11"></a>

**Generated example:**

```json
{
  "id": "",
  "url": "",
  "run_id": "",
  "session_id": "",
  "created_at": "",
  "created_by": "",
  "expires_at": "",
  "capped_by_evidence": true,
  "masked": true,
  "warning": ""
}
```

##### 403 the API key lacks the required scope, or public sharing is off for this tenant: only Saram turns it on, because a recording shows what the browser showed, but for what the pool masked

##### 404 no such run or session of yours - the same answer for another tenant's as for none

##### 409 the session has not ended, so its recording is not stored yet; the run's evidence has expired and there is no recording left to share; or the browser pool did not record the session under its mask, which paints over what the persona typed

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

[Generated example](#scalar-example-4)

##### 503 this deployment has no share host to open a link on

<a id="scalar-operation-get-apiv1runsrun-idshares"></a>

### List Share Links

- **Method:** `GET`
- **Path:** `/api/v1/runs/{run_id}/shares`
- **Operation ID:** `list_share_links`
- **Tags:** shares

Every public link minted on a run's sessions, newest first - live, expired and revoked - with who minted
each, when it expires and how often it was opened. Never a link itself: that was shown once, when it was
minted. `public_shares` says whether your tenant may share at all; while it is off no link plays.
`mask_required` says whether a session of yours is shared only when the browser pool recorded it under its
mask (the session row's `recorded_under_mask`): while it is true, a share of any other is a 409.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Path parameters

- **`run_id` (required)**: `string`

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[ShareList](#scalar-schema-sharelist)

A run's public links, revoked and expired ones included, newest first.

<a id="scalar-example-12"></a>

**Generated example:**

```json
{
  "run_id": "",
  "public_shares": true,
  "mask_required": true,
  "shares": [
    {
      "id": "",
      "run_id": "",
      "session_id": "",
      "token_hint": "",
      "state": "live",
      "created_at": "",
      "created_by": "",
      "caller": "",
      "expires_at": "",
      "revoked_at": "",
      "revoked_by": "",
      "view_count": 1,
      "last_viewed_at": ""
    }
  ]
}
```

##### 403 the API key lacks the required scope

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

[Generated example](#scalar-example-4)

<a id="scalar-operation-post-apiv1sharesshare-idrevoke"></a>

### Revoke Share Link

- **Method:** `POST`
- **Path:** `/api/v1/shares/{share_id}/revoke`
- **Operation ID:** `revoke_share_link`
- **Tags:** shares

Stop a public link playing: the next request for it is refused, a video already playing included once it
asks for its next range. Any key or member of your tenant that may write runs can revoke any of its links,
not only the one who minted it.

The link's record stays, with who revoked it and when, so the list still says what was shared. Asking again
answers the same record. Another tenant's link is the 404 an unknown id gets.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Path parameters

- **`share_id` (required)**: `string`

#### Responses

##### 200 The link is revoked, now or by an earlier request.

**Content type:** `application/json`

[ShareSummary](#scalar-schema-sharesummary)

One public link to a session's replay, as the list and the revocation answer it. Never the link itself.

<a id="scalar-example-13"></a>

**Generated example:**

```json
{
  "id": "",
  "run_id": "",
  "session_id": "",
  "token_hint": "",
  "state": "live",
  "created_at": "",
  "created_by": "",
  "caller": "",
  "expires_at": "",
  "revoked_at": "",
  "revoked_by": "",
  "view_count": 1,
  "last_viewed_at": ""
}
```

##### 403 the API key lacks the required scope

##### 404 no link of yours has that id - the same answer for another tenant's link as for none

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

[Generated example](#scalar-example-4)

<a id="scalar-operation-get-apiv1runsrun-idsessions"></a>

### List Sessions

- **Method:** `GET`
- **Path:** `/api/v1/runs/{run_id}/sessions`
- **Operation ID:** `list_sessions`
- **Tags:** sessions

A run's sessions, one per persona per mission: each with its status, result, whose fault an error
was, its tokens, cost and Usage, and - while it runs - where it has got to. `pack` narrows them to one
persona cohort.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Path parameters

- **`run_id` (required)**: `string`

#### Query parameters

- **`pack`**: `string | null`

  persona pack (cohort), exact

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[SessionList](#scalar-schema-sessionlist)

A run's sessions, one per persona per mission.

<a id="scalar-example-14"></a>

**Generated example:**

```json
{
  "run_id": "",
  "sessions": [
    {
      "session_id": "",
      "persona_id": "",
      "pack": "",
      "tags": [
        ""
      ],
      "mission_id": "",
      "status": "",
      "result": "",
      "end_reason": "",
      "fault": "none",
      "steps": 1,
      "end_step": 1,
      "abandon_step": 1,
      "tokens_in": 1,
      "tokens_out": 1,
      "tokens_cache_read": 0,
      "tokens_cache_write": 0,
      "model": "",
      "cost_usd": 1,
      "price_source": "",
      "wall_clock_s": 1,
      "uncertain_steps": [
        1
      ],
      "resumed_at_steps": [
        1
      ],
      "verdict_decision": "",
      "outcome_read": "",
      "observer_error": "",
      "verdict_discarded": {
        "judge_decision": "positive",
        "gate_reason": ""
      },
      "heartbeat_step": 1,
      "last_heartbeat_at": "",
      "heartbeat_usage": 1,
      "usage": 1,
      "usage_usd": 1,
      "refunded_usd": 1,
      "waiting_for_seat": true,
      "waiting_since": "",
      "workflow_id": "",
      "browser_session_id": "",
      "agent_trace_ref": "",
      "surface_trace_ref": "",
      "video_ref": "",
      "video_url": "",
      "recorded_under_mask": false,
      "created_at": "",
      "finished_at": ""
    }
  ]
}
```

##### 403 the API key lacks the required scope

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

[Generated example](#scalar-example-4)

<a id="scalar-operation-get-apiv1runsrun-idsessionssession-idsteps"></a>

### Get Steps

- **Method:** `GET`
- **Path:** `/api/v1/runs/{run_id}/sessions/{session_id}/steps`
- **Operation ID:** `get_steps`
- **Tags:** sessions

One session's steps, both halves aligned: what the persona decided and said, beside what the browser
did and saw - url, inputs, dialogs, downloads, network failures, console errors, screenshot and video
window.

Readable while the session runs. Page through a long session with `from` and `to`, or tail a live one by
passing the previous answer's `last_step_id` as `after` until `status` is no longer `in_flight`.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Path parameters

- **`run_id` (required)**: `string`
- **`session_id` (required)**: `string`

#### Query parameters

- **`from`**: `integer | null`, minimum: `0`

  Inclusive lower bound on step\_id. Mutually exclusive with after, which is the same bound minus one.
- **`to`**: `integer | null`, minimum: `0`
- **`after`**: `integer | null`, minimum: `0`

  Exclusive lower bound: return the steps written after this step\_id. The cursor for tailing a session that is still running - pass back the last\_step\_id of the previous page. Mutually exclusive with from.

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[StepsPage](#scalar-schema-stepspage)

A session's steps, aligned. Readable while the session runs, not only after it ends.

One step is written as the persona takes it, so a caller polling `?after=<last_step_id>` tails a live
session: each call returns the steps written since the last one, and `status` says whether more are coming.
`heartbeat_step` beside it is where the browser has got to, which is at or one ahead of `last_step_id` while
a step is in flight.

<a id="scalar-example-15"></a>

**Generated example:**

```json
{
  "run_id": "",
  "session_id": "",
  "persona_id": "",
  "mission_id": "",
  "status": "",
  "heartbeat_step": 1,
  "last_heartbeat_at": "",
  "waiting_for_seat": true,
  "waiting_since": "",
  "workflow_id": "",
  "from_step": 1,
  "to_step": 1,
  "total_steps": 1,
  "first_step_id": 1,
  "last_step_id": 1,
  "agent_trace_ref": "",
  "surface_trace_ref": "",
  "video_ref": "",
  "video_url": "",
  "steps": [
    {
      "step_id": 1,
      "has_agent": true,
      "has_surface": true,
      "terminal": true,
      "ts_agent": "",
      "action_type": "",
      "target_description": "",
      "action_args": {
        "additionalProperty": "anything"
      },
      "narration": "",
      "reasoning_summary": "",
      "perceived_state": "",
      "observation_mode": "",
      "tolerance_state": {
        "additionalProperty": "anything"
      },
      "latency_ms": 1,
      "tokens": {
        "input": 1,
        "output": 1,
        "cached": 1
      },
      "model": "",
      "end_reason": "",
      "result": "",
      "field": {
        "additionalProperty": "anything"
      },
      "ts_surface": "",
      "url": "",
      "route": "",
      "viewport": "",
      "inputs": [
        {
          "additionalProperty": "anything"
        }
      ],
      "files_attached": [
        {
          "additionalProperty": "anything"
        }
      ],
      "dialogs": [
        {
          "additionalProperty": "anything"
        }
      ],
      "downloads": [
        {
          "additionalProperty": "anything"
        }
      ],
      "network_failures": [
        {
          "additionalProperty": "anything"
        }
      ],
      "console_errors": [
        {
          "additionalProperty": "anything"
        }
      ],
      "dom_mutation_count": 1,
      "timing": {
        "action_to_settle_ms": 1,
        "longest_task_ms": 1,
        "navigation_ms": 1
      },
      "screenshot_ref": "",
      "screenshot_url": "",
      "video_ref": "",
      "video_url": "",
      "ts_start_s": 1,
      "ts_end_s": 1
    }
  ]
}
```

##### 403 the API key lacks the required scope

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

[Generated example](#scalar-example-4)

<a id="scalar-operation-get-apiv1runsrun-idissues"></a>

### Get Issues

- **Method:** `GET`
- **Path:** `/api/v1/runs/{run_id}/issues`
- **Operation ID:** `get_issues`
- **Tags:** artifact

The product defects the run found, each with its evidence and repro steps, and beside them how each
may be acted on: an issue whose reading says `may_file_ticket_alone` was reproduced by a replay and can
become a ticket as it stands; any other must be reported with its evidence and marked unconfirmed.

Answers from the moment the run exists: an empty list with the run's `status` while it is still going,
and a result only once that status is terminal.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Path parameters

- **`run_id` (required)**: `string`

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[IssueList](#scalar-schema-issuelist)

A run's issues: product defects the run found, each with its evidence, and beside them how each may be
acted on (`reading`). Only an issue whose reading says `may_file_ticket_alone` is a confirmed bug.

**Example:**

```json
{
  "run_id": "run_2026-09-26_4f1c2a",
  "status": "completed",
  "issues": [
    {
      "id": "iss_signup_submit_500",
      "type": "bug",
      "severity": "high",
      "reproducible": true,
      "reproduction": {
        "attempted": true,
        "result": "reproduced",
        "attempts": 2,
        "notes": "Both replays filled the sign-up form with a password containing a space and pressed Create account: POST /api/signup answered 500 and the page did not change."
      },
      "persona_specific": false,
      "claim": {
        "action_step": {
          "session_id": "run_2026-09-26_4f1c2a.sess_01_dana_first_timer",
          "step_id": 7
        },
        "expected": "Create account submits the form and signs the new user in.",
        "observed": "POST /api/signup answered 500; the form stayed on screen with every field filled and no message.",
        "verification": {
          "status": "verified",
          "method": "marker",
          "markers": [
            "failed_requests"
          ],
          "note": "The same failed request at the judged step on both attempts; the claim check found the title supported by the network line, and the failure is the product's."
        },
        "attribution": "product",
        "check": {
          "verdict": "supported",
          "quote": "[step 7] click -> button[type=submit]: POST /api/signup 500",
          "quote_step": {
            "session_id": "run_2026-09-26_4f1c2a.sess_01_dana_first_timer",
            "step_id": 7
          },
          "reason": "The request the button sends fails on the server, which is what the title claims.",
          "checked_by": "judge"
        }
      },
      "affected_sessions": [
        "run_2026-09-26_4f1c2a.sess_01_dana_first_timer"
      ],
      "affected_personas": [
        "dana_first_timer@1"
      ],
      "affected_count": 1,
      "affected_fraction": 0.5,
      "reached_sessions": [
        "run_2026-09-26_4f1c2a.sess_01_dana_first_timer",
        "run_2026-09-26_4f1c2a.sess_02_marcus_fast_skimmer"
      ],
      "first_seen_step": 7,
      "location": {
        "route": "/signup",
        "selector": "button[type=submit]",
        "element_description": "Create account button under the sign-up form",
        "viewport": "1440x900"
      },
      "title": "Create account fails with a server error when the password contains a space",
      "description": "On /signup, submitting the form with a password that contains a space sends POST /api/signup, which answers 500. The page shows no error and keeps the form filled, so the persona pressed Create account three more times before giving up.",
      "persona_rationale": "I pressed the button four times and nothing happened, so I assumed the site was broken.",
      "evidence": {
        "step_refs": [
          {
            "session_id": "run_2026-09-26_4f1c2a.sess_01_dana_first_timer",
            "step_id": 7
          },
          {
            "session_id": "run_2026-09-26_4f1c2a.sess_01_dana_first_timer",
            "step_id": 10
          }
        ],
        "screenshots": [
          "s3://saram-evidence/run_2026-09-26_4f1c2a/run_2026-09-26_4f1c2a.sess_01_dana_first_timer/shots/0007.png"
        ]
      },
      "repro_steps": [
        {
          "action": "navigate",
          "target": "https://example.com/signup",
          "expect": "sign-up form visible"
        },
        {
          "action": "fill",
          "target": "form#signup",
          "expect": "a password containing a space"
        },
        {
          "action": "click",
          "target": "button[type=submit]",
          "expect": "a signed-in page"
        }
      ],
      "preconditions": [
        "viewport 1440x900",
        "auth_state anonymous"
      ],
      "baseline_status": "new",
      "confidence": 0.92
    }
  ],
  "reading": [
    {
      "issue_id": "iss_signup_submit_500",
      "may_file_ticket_alone": true,
      "subjective_fields": [
        "persona_rationale"
      ],
      "advisory": "SUBJECTIVE. `persona_rationale` is persona-voiced narration from an affected session, not a statement of observed behaviour. Quote it as the persona's words; the objective claim is `description`."
    }
  ]
}
```

##### 403 the API key lacks the required scope

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

[Generated example](#scalar-example-4)

<a id="scalar-operation-get-apiv1runsrun-idissuesissue-id"></a>

### Get Issue

- **Method:** `GET`
- **Path:** `/api/v1/runs/{run_id}/issues/{issue_id}`
- **Operation ID:** `get_issue`
- **Tags:** artifact

One issue in full - its claim and how it was checked, repro steps, preconditions and evidence - with
its reading and the evidence refs to fetch. A 404 until the run's artifact is assembled; an id that is
one of the run's tester errors is a 404 that says where to read it instead.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Path parameters

- **`run_id` (required)**: `string`
- **`issue_id` (required)**: `string`

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[IssueDetail](#scalar-schema-issuedetail)

One issue with its reading and its evidence refs beside it: `GET /runs/{id}/issues/{issue_id}` and the MCP
`get_issue` answer.

`issue` is the contract object verbatim and `reading` is the entry `IssueList.reading[]` carries for the same
issue, computed by the same function, so a surface that shows one issue - an issue page, or an agent that
calls `get_issue` first - reads whether it may be filed alone, and how to read its persona-voiced fields,
without a second call and cannot disagree with the list about them.

**Example:**

```json
{
  "run_id": "run_2026-09-26_4f1c2a",
  "issue": {
    "id": "iss_signup_submit_500",
    "type": "bug",
    "severity": "high",
    "reproducible": true,
    "reproduction": {
      "attempted": true,
      "result": "reproduced",
      "attempts": 2,
      "notes": "Both replays filled the sign-up form with a password containing a space and pressed Create account: POST /api/signup answered 500 and the page did not change."
    },
    "persona_specific": false,
    "claim": {
      "action_step": {
        "session_id": "run_2026-09-26_4f1c2a.sess_01_dana_first_timer",
        "step_id": 7
      },
      "expected": "Create account submits the form and signs the new user in.",
      "observed": "POST /api/signup answered 500; the form stayed on screen with every field filled and no message.",
      "verification": {
        "status": "verified",
        "method": "marker",
        "markers": [
          "failed_requests"
        ],
        "note": "The same failed request at the judged step on both attempts; the claim check found the title supported by the network line, and the failure is the product's."
      },
      "attribution": "product",
      "check": {
        "verdict": "supported",
        "quote": "[step 7] click -> button[type=submit]: POST /api/signup 500",
        "quote_step": {
          "session_id": "run_2026-09-26_4f1c2a.sess_01_dana_first_timer",
          "step_id": 7
        },
        "reason": "The request the button sends fails on the server, which is what the title claims.",
        "checked_by": "judge"
      }
    },
    "affected_sessions": [
      "run_2026-09-26_4f1c2a.sess_01_dana_first_timer"
    ],
    "affected_personas": [
      "dana_first_timer@1"
    ],
    "affected_count": 1,
    "affected_fraction": 0.5,
    "reached_sessions": [
      "run_2026-09-26_4f1c2a.sess_01_dana_first_timer",
      "run_2026-09-26_4f1c2a.sess_02_marcus_fast_skimmer"
    ],
    "first_seen_step": 7,
    "location": {
      "route": "/signup",
      "selector": "button[type=submit]",
      "element_description": "Create account button under the sign-up form",
      "viewport": "1440x900"
    },
    "title": "Create account fails with a server error when the password contains a space",
    "description": "On /signup, submitting the form with a password that contains a space sends POST /api/signup, which answers 500. The page shows no error and keeps the form filled, so the persona pressed Create account three more times before giving up.",
    "persona_rationale": "I pressed the button four times and nothing happened, so I assumed the site was broken.",
    "evidence": {
      "step_refs": [
        {
          "session_id": "run_2026-09-26_4f1c2a.sess_01_dana_first_timer",
          "step_id": 7
        },
        {
          "session_id": "run_2026-09-26_4f1c2a.sess_01_dana_first_timer",
          "step_id": 10
        }
      ],
      "screenshots": [
        "s3://saram-evidence/run_2026-09-26_4f1c2a/run_2026-09-26_4f1c2a.sess_01_dana_first_timer/shots/0007.png"
      ]
    },
    "repro_steps": [
      {
        "action": "navigate",
        "target": "https://example.com/signup",
        "expect": "sign-up form visible"
      },
      {
        "action": "fill",
        "target": "form#signup",
        "expect": "a password containing a space"
      },
      {
        "action": "click",
        "target": "button[type=submit]",
        "expect": "a signed-in page"
      }
    ],
    "preconditions": [
      "viewport 1440x900",
      "auth_state anonymous"
    ],
    "baseline_status": "new",
    "confidence": 0.92
  },
  "reading": {
    "issue_id": "iss_signup_submit_500",
    "may_file_ticket_alone": true,
    "subjective_fields": [
      "persona_rationale"
    ],
    "advisory": "SUBJECTIVE. `persona_rationale` is persona-voiced narration from an affected session, not a statement of observed behaviour. Quote it as the persona's words; the objective claim is `description`."
  },
  "evidence_refs": {
    "screenshots": [
      "s3://saram-evidence/run_2026-09-26_4f1c2a/run_2026-09-26_4f1c2a.sess_01_dana_first_timer/shots/0007.png"
    ]
  }
}
```

##### 403 the API key lacks the required scope

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

[Generated example](#scalar-example-4)

<a id="scalar-operation-post-apiv1runsrun-idissuesissue-idretest"></a>

### Retest Issue

- **Method:** `POST`
- **Path:** `/api/v1/runs/{run_id}/issues/{issue_id}/retest`
- **Operation ID:** `retest_issue`
- **Tags:** artifact

Replay one issue's recorded path again, now, and keep the answer beside the run's own.

The same deterministic replay the run's reproduction ran: no persona and no model walks the path, two
attempts, the target's partition reset before each. When the failure does happen again, the claim check
the run's reproduction asks is asked once more, on the run's own reproduction provider. The issue and the
artifact keep what the run found; this record is where the new answer goes. A replay's steps are never
billed. Poll `GET .../retests/{retest_id}` until `status` is `completed` or `failed`.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Path parameters

- **`run_id` (required)**: `string`
- **`issue_id` (required)**: `string`

#### Request body

**Content type:** `application/json`

[RetestRequest](#scalar-schema-retestrequest) | `null`

The optional body of `POST /runs/{id}/issues/{issue_id}/retest`.

<a id="scalar-example-16"></a>

**Generated example:**

```json
{
  "entry_url": ""
}
```

#### Responses

##### 202 Successful Response

**Content type:** `application/json`

[RetestRecord](#scalar-schema-retestrecord)

One retest of one issue: a later replay a person asked for, kept beside what the run concluded.

The issue itself - `GET /runs/{id}/issues/{issue_id}` - still says what the run's own reproduction found,
and so does the artifact: a retest never rewrites the run's record. Every retest is its own record.

<a id="scalar-example-17"></a>

**Generated example:**

```json
{
  "retest_id": "",
  "run_id": "",
  "issue_id": "",
  "status": "pending",
  "entry_url": "",
  "requested_by": "",
  "workflow_id": "",
  "result": "reproduced",
  "verification": {
    "status": "verified",
    "method": "marker",
    "markers": [
      ""
    ],
    "note": ""
  },
  "check": {
    "verdict": "supported",
    "quote": "",
    "quote_step": {
      "session_id": "",
      "step_id": 1
    },
    "reason": "",
    "checked_by": "judge"
  },
  "attempts": [
    {
      "attempt_id": "",
      "issue_id": "",
      "session_id": "",
      "outcome": "",
      "expected": {
        "additionalProperty": "anything"
      },
      "observed": {
        "additionalProperty": "anything"
      },
      "notes": "",
      "surface_trace_ref": "",
      "screenshot_refs": [
        ""
      ]
    }
  ],
  "gate_discards": 1,
  "gate_ref": "",
  "error": "",
  "created_at": "",
  "started_at": "",
  "finished_at": ""
}
```

##### 403 the API key lacks the required scope

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

[Generated example](#scalar-example-4)

<a id="scalar-operation-get-apiv1runsrun-idissuesissue-idretests"></a>

### List Retests

- **Method:** `GET`
- **Path:** `/api/v1/runs/{run_id}/issues/{issue_id}/retests`
- **Operation ID:** `list_retests`
- **Tags:** artifact

Every retest asked for on one issue, newest first, each with its status and, once it has one, its
result. Empty for an issue nobody has retested.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Path parameters

- **`run_id` (required)**: `string`
- **`issue_id` (required)**: `string`

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[RetestList](#scalar-schema-retestlist)

Every retest asked for on one issue, newest first.

<a id="scalar-example-18"></a>

**Generated example:**

```json
{
  "run_id": "",
  "issue_id": "",
  "retests": [
    {
      "retest_id": "",
      "run_id": "",
      "issue_id": "",
      "status": "pending",
      "entry_url": "",
      "requested_by": "",
      "workflow_id": "",
      "result": "reproduced",
      "verification": {
        "status": "verified",
        "method": "marker",
        "markers": [
          ""
        ],
        "note": ""
      },
      "check": {
        "verdict": "supported",
        "quote": "",
        "quote_step": {
          "session_id": "",
          "step_id": 1
        },
        "reason": "",
        "checked_by": "judge"
      },
      "attempts": [
        {
          "attempt_id": "",
          "issue_id": "",
          "session_id": "",
          "outcome": "",
          "expected": {
            "additionalProperty": "anything"
          },
          "observed": {
            "additionalProperty": "anything"
          },
          "notes": "",
          "surface_trace_ref": "",
          "screenshot_refs": [
            ""
          ]
        }
      ],
      "gate_discards": 1,
      "gate_ref": "",
      "error": "",
      "created_at": "",
      "started_at": "",
      "finished_at": ""
    }
  ]
}
```

##### 403 the API key lacks the required scope

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

[Generated example](#scalar-example-4)

<a id="scalar-operation-get-apiv1runsrun-idissuesissue-idretestsretest-id"></a>

### Get Retest

- **Method:** `GET`
- **Path:** `/api/v1/runs/{run_id}/issues/{issue_id}/retests/{retest_id}`
- **Operation ID:** `get_retest`
- **Tags:** artifact

One retest of one issue: poll it after `POST .../retest` until `status` is `completed` or `failed`.
`result` then says whether the failure happened again, and the issue itself keeps what the run found.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Path parameters

- **`run_id` (required)**: `string`
- **`issue_id` (required)**: `string`
- **`retest_id` (required)**: `string`

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[RetestRecord](#scalar-schema-retestrecord)

One retest of one issue: a later replay a person asked for, kept beside what the run concluded.

The issue itself - `GET /runs/{id}/issues/{issue_id}` - still says what the run's own reproduction found,
and so does the artifact: a retest never rewrites the run's record. Every retest is its own record.

[Generated example](#scalar-example-17)

##### 403 the API key lacks the required scope

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

[Generated example](#scalar-example-4)

<a id="scalar-operation-get-apiv1runsrun-idverdicts"></a>

### Get Verdicts

- **Method:** `GET`
- **Path:** `/api/v1/runs/{run_id}/verdicts`
- **Operation ID:** `get_verdicts`
- **Tags:** artifact

Each persona's verdict at the end of its session - its answer to the run's verdict question, quoted
from its own diary - and the cohort's tally. Persona feedback: subjective, to be attributed to the
persona, and never a ticket on its own. An empty list with the run's `status` until the run is done.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Path parameters

- **`run_id` (required)**: `string`

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[VerdictList](#scalar-schema-verdictlist)

A run's persona verdicts: each session's answer to the run's verdict question, grounded in the persona's
own diary quotes, and the cohort's tally. Persona feedback, never a ticket on its own.

**Example:**

```json
{
  "run_id": "run_2026-09-26_4f1c2a",
  "status": "completed",
  "summary": {
    "positive": 1,
    "negative": 1,
    "undecided": 0,
    "top_reasons": [
      "The sign-up form failed without saying why.",
      "Signing up was fast."
    ]
  },
  "verdicts": [
    {
      "session_id": "run_2026-09-26_4f1c2a.sess_01_dana_first_timer",
      "persona_id": "dana_first_timer@1",
      "mission_id": "sign_up",
      "result": "abandoned",
      "verdict_prompt": "Would you use this product again after today, and what decided it?",
      "verdict_prompt_source": "mission",
      "verdict": {
        "decision": "negative",
        "decision_text": "No. If I can't even make an account, I'm not trusting it with my work.",
        "reason": "I pressed the button four times and nothing happened",
        "reason_paraphrase": "The sign-up form failed without saying why.",
        "quotes": [
          "GAVE UP: I pressed the button four times and nothing happened, so I assumed the site was broken. No. If I can't even make an account, I'm not trusting it with my work."
        ],
        "step_refs": [
          10,
          18
        ]
      }
    },
    {
      "session_id": "run_2026-09-26_4f1c2a.sess_02_marcus_fast_skimmer",
      "persona_id": "marcus_fast_skimmer@1",
      "mission_id": "sign_up",
      "result": "completed",
      "verdict_prompt": "Would you use this product again after today, and what decided it?",
      "verdict_prompt_source": "mission",
      "verdict": {
        "decision": "positive",
        "decision_text": "Yes - I was in and looking at a dashboard in under a minute.",
        "reason": "I was in and looking at a dashboard in under a minute",
        "reason_paraphrase": "Signing up was fast.",
        "quotes": [
          "FINISHED: Yes - I was in and looking at a dashboard in under a minute."
        ],
        "step_refs": [
          9
        ]
      }
    }
  ],
  "advisory": "ADVISORY, SUBJECTIVE. A verdict is the persona's own answer at the end of its session, grounded in verbatim diary quotes. It is feedback, not a defect: it never files a ticket on its own and must be presented as the persona's opinion, never as an observed fact.",
  "subjective_fields": [
    "verdict"
  ]
}
```

##### 403 the API key lacks the required scope

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

[Generated example](#scalar-example-4)

<a id="scalar-operation-get-apiv1runsrun-idopportunities"></a>

### Get Opportunities

- **Method:** `GET`
- **Path:** `/api/v1/runs/{run_id}/opportunities`
- **Operation ID:** `get_opportunities`
- **Tags:** artifact

What the personas' behaviour says the product could do better, or already does well - unmet
expectations, missing capabilities, value gaps, delight - scored by how many sessions share it. Persona
feedback, never a ticket on its own. `display` says which clear the cohort floors and which are
single-persona notes. An empty list with the run's `status` until the run is done.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Path parameters

- **`run_id` (required)**: `string`

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[OpportunityList](#scalar-schema-opportunitylist)

A run's opportunities: behaviour-grounded persona feedback that is not a defect - unmet expectations,
missing capabilities, value gaps, delight - scored by how many personas share it. Never a ticket on its own.

<a id="scalar-example-19"></a>

**Generated example:**

```json
{
  "run_id": "",
  "status": "",
  "opportunities": [
    {
      "id": "",
      "kind": "unmet_expectation",
      "title": "",
      "description": "",
      "persona_rationale": "",
      "suggested_change": "",
      "affected_sessions": [
        ""
      ],
      "affected_personas": [
        ""
      ],
      "reached_sessions": [
        ""
      ],
      "consensus": 0,
      "solicited_by": "",
      "location": {
        "route": "",
        "element_description": ""
      },
      "evidence": {
        "step_refs": [
          {
            "session_id": "",
            "step_id": 1
          }
        ],
        "quotes": [
          ""
        ],
        "screenshots": [
          ""
        ]
      },
      "baseline_status": "new",
      "connector_refs": {
        "additionalProperty": ""
      }
    }
  ],
  "advisory": "",
  "subjective_fields": [
    ""
  ],
  "display": {
    "note": "",
    "min_consensus": 1,
    "min_affected_sessions": 1,
    "strong_consensus": 1,
    "shown": [
      ""
    ],
    "folded": [
      ""
    ]
  }
}
```

##### 403 the API key lacks the required scope

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

[Generated example](#scalar-example-4)

<a id="scalar-operation-get-apiv1runsrun-idtester-errors"></a>

### Get Tester Errors

- **Method:** `GET`
- **Path:** `/api/v1/runs/{run_id}/tester-errors`
- **Operation ID:** `get_tester_errors`
- **Tags:** artifact

The findings this run attributed to the persona's own action rather than to the product.

A mistyped credential, the wrong element, placeholder text read as a value: issue-shaped records that are
never issues - not in `/issues`, not in `issues_total`, not reproduced, never a ticket. Read them for why
a session stalled and for what to fix in the test setup. Empty with the run's `status` until the artifact
is assembled, like the other views; `classified` tells an artifact that never asked from one that found
none.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Path parameters

- **`run_id` (required)**: `string`

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[TesterErrorList](#scalar-schema-testererrorlist)

The run's tester errors: findings the harness attributed to its own persona, never to the product.

Issue-shaped - the same id, evidence and screenshots an issue carries - and never an issue: not in
`IssueList`, not in `issues_total`, not reproduced, never a ticket. They are served so a customer can see
why a session stalled on their own test setup (a mistyped credential, the wrong element) instead of reading
the stall as the product's.

<a id="scalar-example-20"></a>

**Generated example:**

```json
{
  "run_id": "",
  "status": "",
  "classified": true,
  "tester_errors": [
    {
      "id": "",
      "type": "blocker",
      "severity": "low",
      "reproducible": true,
      "reproduction": {
        "attempted": true,
        "result": "reproduced",
        "attempts": 1,
        "notes": ""
      },
      "persona_specific": true,
      "claim": {
        "action_step": {
          "session_id": "",
          "step_id": 1
        },
        "expected": "",
        "observed": "",
        "verification": {
          "status": "verified",
          "method": "marker",
          "markers": [
            ""
          ],
          "note": ""
        },
        "attribution": "product",
        "check": {
          "verdict": "supported",
          "quote": "",
          "quote_step": {
            "session_id": "",
            "step_id": 1
          },
          "reason": "",
          "checked_by": "judge"
        }
      },
      "tester_error": {
        "kind": "mistyped_credential",
        "quote": "",
        "quote_step": {
          "session_id": "",
          "step_id": 1
        },
        "decided_by": "code",
        "reason": ""
      },
      "affected_sessions": [
        ""
      ],
      "affected_personas": [
        ""
      ],
      "affected_count": 1,
      "affected_fraction": 0,
      "reached_sessions": [
        ""
      ],
      "first_seen_step": 1,
      "location": {
        "route": "",
        "selector": "",
        "element_description": "",
        "viewport": ""
      },
      "title": "",
      "description": "",
      "persona_rationale": "",
      "contradiction": {
        "a": {
          "step_id": 1,
          "session_id": "",
          "text": ""
        },
        "b": {
          "step_id": 1,
          "session_id": "",
          "text": ""
        }
      },
      "evidence": {
        "step_refs": [
          {
            "session_id": "",
            "step_id": 1
          }
        ],
        "screenshots": [
          ""
        ],
        "video": {
          "ref": "",
          "ts_start_s": 1,
          "ts_end_s": 1
        },
        "dom_snapshot": "",
        "network": [
          {
            "additionalProperty": "anything"
          }
        ],
        "console": [
          {
            "additionalProperty": "anything"
          }
        ]
      },
      "repro_steps": [
        {
          "action": "",
          "target": "",
          "value": "",
          "replace": true,
          "expect": ""
        }
      ],
      "preconditions": [
        ""
      ],
      "baseline_status": "new",
      "confidence": 0,
      "suggested_owner": "",
      "connector_refs": {
        "additionalProperty": ""
      }
    }
  ],
  "reading": [
    {
      "tester_error_id": "",
      "label": "",
      "may_file_ticket_alone": false
    }
  ],
  "advisory": "",
  "subjective_fields": [
    ""
  ]
}
```

##### 403 the API key lacks the required scope

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

[Generated example](#scalar-example-4)

<a id="scalar-operation-get-apiv1usage"></a>

### Get Usage

- **Method:** `GET`
- **Path:** `/api/v1/usage`
- **Operation ID:** `get_usage`
- **Tags:** usage

This tenant's usage for one calendar month: the meters, the runs behind them, and the totals.

Your own tenant and no other - the ledger is filtered by the tenant the request's key belongs to.
A month you ran nothing in is an empty report, not a 404.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Query parameters

- **`period`**: `string | null`

  The calendar month to report, YYYY-MM, in UTC. Omitted, it is the month it is now. A month you ran nothing in answers with zero totals, not a 404.

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[UsageReport](#scalar-schema-usagereport)

One month of your tenant's usage: the meters, the runs behind them, and the totals (`GET /api/v1/usage`).

Your own tenant and no other: the ledger is filtered by the tenant the request's key belongs to, so a
neighbour's run is not in these figures and cannot be. `GET /api/v1/usage.csv` is the same report as the
file an invoice is written from.

**One meter is billed and four are reported.** `billable_usd` is the month's bill: billed steps at
`step_price_usd`, less `included_steps`, plus `monthly_fee_usd`. The model spend, the session count and
the browser seconds are here so you can see what your keys spent and how long your personas were in your
product; none of them is multiplied by anything. The arithmetic is served rather than left to the
client, so a screen and an invoice cannot disagree.

<a id="scalar-example-21"></a>

**Generated example:**

```json
{
  "period": "",
  "from": "",
  "to": "",
  "tenant_id": "",
  "tenant_slug": "",
  "tenant_name": "",
  "plan": "",
  "house_models": false,
  "meters": [
    {
      "meter": "",
      "role": "",
      "quantity": 1
    }
  ],
  "totals": {
    "steps": 0,
    "sessions": 0,
    "browser_seconds": 0,
    "model_usd_hosted": 0,
    "model_usd_byo": 0
  },
  "runs": [
    {
      "run_id": "",
      "caller": "",
      "occurred_at": "",
      "steps": 0,
      "billed_steps": 0,
      "sessions": 0,
      "browser_seconds": 0,
      "model_usd_hosted": 0,
      "model_usd_byo": 0,
      "billable_usd": 0
    }
  ],
  "model_usd_complete": false,
  "billed_steps": 0,
  "step_price_usd": 0,
  "included_steps": 0,
  "monthly_fee_usd": 0,
  "monthly_cap_usd": 1,
  "billable_usd": 0,
  "usage": {
    "usage_price_usd": 1,
    "usage": 0,
    "usage_usd": 0,
    "refunded_usd": 0,
    "sessions": 0,
    "runs": [
      {
        "run_id": "",
        "caller": "",
        "settled_at": "",
        "sessions": 1,
        "usage": 1,
        "usage_usd": 1,
        "refunded_usd": 1
      }
    ],
    "personas": [
      {
        "persona_id": "",
        "persona_label": "",
        "deleted": true,
        "runs": 1,
        "sessions": 1,
        "usage": 1,
        "usage_usd": 1,
        "refunded_usd": 1
      }
    ],
    "refunds": [
      {
        "run_id": "",
        "session_id": "",
        "persona_id": "",
        "persona_label": "",
        "fault": "saram",
        "usage": 1,
        "amount_usd": 1,
        "refunded_at": "",
        "note": ""
      }
    ]
  },
  "provider": {
    "model_usd_byo": 0,
    "complete": false,
    "runs": [
      {
        "run_id": "",
        "model_usd_byo": 1
      }
    ]
  }
}
```

##### 403 the API key lacks the required scope

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

[Generated example](#scalar-example-4)

<a id="scalar-operation-get-apiv1usagecsv"></a>

### Get Usage CSV

- **Method:** `GET`
- **Path:** `/api/v1/usage.csv`
- **Operation ID:** `get_usage_csv`
- **Tags:** usage

The same month as a CSV: each run oldest first with a row per session under it - its Usage, whose
failure ended it and what was charged and refunded for it - then a `TOTAL` row, and below it the month's
grants, top-ups, expiries and adjustments. What an invoice is written from by hand - the per-role split is
in the JSON above.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Query parameters

- **`period`**: `string | null`

  The calendar month to report, YYYY-MM, in UTC. Omitted, it is the month it is now. A month you ran nothing in answers with zero totals, not a 404.

#### Responses

##### 200 One header row; each run oldest first, followed by one row per session of that run; then a TOTAL row. Columns: period, tenant, run\_id, session\_id, persona\_id, persona\_label, caller, occurred\_at, then the run's meters - steps, sessions, browser\_seconds, model\_usd\_hosted, model\_usd\_byo - then the session's persona\_steps, persona\_seconds (the time its steps took, without setup and teardown), usage, fault (none, saram, provider or target), usage\_usd (net) and refunded\_usd, then billable\_usd. A run's row leaves the session columns empty and a session's row leaves the run's empty, so every column sums to its TOTAL. A session that failed on Saram's side (fault saram) reads usage\_usd 0.0000 with its whole debit in refunded\_usd; usage\_usd and refunded\_usd are empty where the prepaid ledger moved no money for the session. Then one empty line and a second table, always with its header: period, tenant, kind (grant, top\_up, expiry or adjustment), bucket (included or purchased), amount\_usd (signed, to four places), created\_at and stripe\_ref (the Stripe invoice behind a top-up or a Pro grant) - each credit that came into or lapsed from the balance that month, oldest first. An expiry is in the month whose credit lapsed.

**Content type:** `application/json`

`any`

<a id="scalar-example-22"></a>

**Generated example:**

```json
null
```

**Content type:** `text/csv`

`string`

<a id="scalar-example-23"></a>

**Generated example:**

```json
""
```

##### 403 the API key lacks the required scope

##### 422 period is not a calendar month (YYYY-MM)

<a id="scalar-operation-post-apiv1billingportal"></a>

### Billing Portal

- **Method:** `POST`
- **Path:** `/api/v1/billing/portal`
- **Operation ID:** `billing_portal`
- **Tags:** usage

A link into Stripe's hosted customer portal for this tenant: cards, invoices, receipts.

Saram holds no payment instrument and renders no payment form - what it holds is the usage the
invoice is derived from, which is the two routes above. The Stripe customer is created here if this
tenant has never had one (`billing.ensure_customer`), so a tenant that has not run yet can still put
a card on file.

Minted per call and never cached: a portal session is short-lived and single-use, and a cached link
would hand the second person the first person's session.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[BillingPortal](#scalar-schema-billingportal)

A one-use link into Stripe's hosted customer portal (`POST /api/v1/billing/portal`).

Where a customer manages the card Saram never sees: Saram stores no payment instrument, renders no
payment form and is not in the flow at all. The link is short-lived and single-use by Stripe's design, so it
is minted per click and never cached; the invoices, receipts and the subscription behind it are Stripe's
screens, and what this API serves is the usage the invoice is derived from (`GET /api/v1/usage`).

<a id="scalar-example-24"></a>

**Generated example:**

```json
{
  "url": "",
  "return_url": ""
}
```

##### 403 this credential may not manage billing: an API key without the keys:admin scope, or a person whose role in this tenant is not owner or admin

##### 502 Stripe refused the request or could not be reached; the detail is Stripe's own message

##### 503 this deployment has no Stripe key configured, so there is no billing account to open

<a id="scalar-operation-get-apiv1billing"></a>

### Billing Overview

- **Method:** `GET`
- **Path:** `/api/v1/billing`
- **Operation ID:** `billing_overview`
- **Tags:** usage

This tenant's plan and its prices, the Pro subscription, prepaid balance, card on file, auto-refill and
recent top-ups.

Read from Saram's ledger, which is the balance (section 3.2): Stripe collects money and holds no copy of
it. Answers without a Stripe key too, because the ledger is this deployment's own. The subscription is
Stripe's, mirrored as its events arrive; a Pro subscription nothing has mirrored yet is read from Stripe
once, here.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[BillingOverview](#scalar-schema-billingoverview)

`GET /api/v1/billing`: the tenant's plan, prepaid balance, card on file, auto-refill and recent top-ups.

The balance is Saram's ledger, not Stripe's: `included` is the plan's monthly credit and expires at month end,
`purchased` is top-ups and does not; `held` is what runs in flight have reserved, and `available` is what a
new run can hold against.

<a id="scalar-example-25"></a>

**Generated example:**

```json
{
  "plan": "",
  "limits": {
    "max_concurrent_sessions": 1,
    "evidence_retention_days": 1,
    "max_members": 1,
    "automation": true
  },
  "monthly_fee_usd": 0,
  "included_credit_usd": 0,
  "usage_price_usd": 1,
  "subscription": {
    "status": "",
    "cancel_at_period_end": true,
    "current_period_end": ""
  },
  "members_used": 0,
  "billing_status": "",
  "included_usd": 1,
  "purchased_usd": 1,
  "balance_usd": 1,
  "held_usd": 1,
  "available_usd": 1,
  "card_on_file": true,
  "auto_refill": {
    "auto_refill": true,
    "refill_below_usd": 0,
    "refill_amount_usd": 10,
    "monthly_budget_usd": 0,
    "last_skipped": {
      "at": "",
      "reason": "monthly_budget",
      "detail": ""
    }
  },
  "budget_used_usd": 1,
  "top_ups": [
    {
      "id": "",
      "amount_usd": 1,
      "origin": "manual",
      "status": "pending",
      "invoice_id": "",
      "client_secret": "",
      "failure_code": "",
      "failure_message": "",
      "created_at": "",
      "finished_at": ""
    }
  ]
}
```

##### 403 this credential may not manage billing: an API key without the keys:admin scope, or a person whose role in this tenant is not owner or admin

##### 422 an amount out of bounds, or auto-refill turned on without a threshold, amount or card

##### 502 Stripe refused the request or could not be reached; the detail is Stripe's own message

##### 503 this deployment has no Stripe key configured, or its Stripe account is not set up for what was asked, so nothing is started and nothing is charged

<a id="scalar-operation-post-apiv1billingauto-refill"></a>

### Billing Auto Refill

- **Method:** `POST`
- **Path:** `/api/v1/billing/auto-refill`
- **Operation ID:** `billing_auto_refill`
- **Tags:** usage

Set auto-refill and the monthly budget. Turning auto-refill on wants a threshold, an amount and a card
on file (`POST /billing/setup`); if the balance is already under the threshold, the first refill starts
now rather than at the next run.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Request body

**Required:** `true`

**Content type:** `application/json`

[AutoRefill](#scalar-schema-autorefill)

The tenant's auto-refill setting and monthly budget (`POST /api/v1/billing/auto-refill`, and on
`GET /api/v1/billing`).

With `auto_refill` on, whenever the balance less what running runs hold falls below `refill_below_usd`, Saram
charges the card on file `refill_amount_usd` - never past `monthly_budget_usd` in a calendar month, manual
top-ups included. A declined charge, or one the bank wants authenticated, turns auto-refill off.

<a id="scalar-example-26"></a>

**Generated example:**

```json
{
  "auto_refill": true,
  "refill_below_usd": 0,
  "refill_amount_usd": 10,
  "monthly_budget_usd": 0
}
```

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[BillingOverview](#scalar-schema-billingoverview)

`GET /api/v1/billing`: the tenant's plan, prepaid balance, card on file, auto-refill and recent top-ups.

The balance is Saram's ledger, not Stripe's: `included` is the plan's monthly credit and expires at month end,
`purchased` is top-ups and does not; `held` is what runs in flight have reserved, and `available` is what a
new run can hold against.

[Generated example](#scalar-example-25)

##### 403 this credential may not manage billing: an API key without the keys:admin scope, or a person whose role in this tenant is not owner or admin

##### 422 an amount out of bounds, or auto-refill turned on without a threshold, amount or card

##### 502 Stripe refused the request or could not be reached; the detail is Stripe's own message

##### 503 this deployment has no Stripe key configured, or its Stripe account is not set up for what was asked, so nothing is started and nothing is charged

<a id="scalar-operation-post-apiv1billingsubscription"></a>

### Billing Upgrade

- **Method:** `POST`
- **Path:** `/api/v1/billing/subscription`
- **Operation ID:** `billing_upgrade`
- **Tags:** usage

Upgrade this tenant to Saram Pro: an `incomplete` subscription whose first invoice the Payment Element
pays with `client_secret`. Asked again before paying, it answers the same checkout; asked while Pro is
set to cancel, it takes the cancellation back. The plan becomes `pro` when Stripe reports it active.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[BillingSubscription](#scalar-schema-billingsubscription)

`POST` and `DELETE /api/v1/billing/subscription`: the Saram Pro subscription and, while its first invoice
is unpaid, the secret the Payment Element pays it with.

The tenant's plan becomes `pro` when Stripe reports the subscription active, not when this answers: an
abandoned checkout leaves the tenant where it was, and expires at Stripe within a day.

<a id="scalar-example-27"></a>

**Generated example:**

```json
{
  "subscription_id": "",
  "status": "",
  "client_secret": "",
  "cancel_at_period_end": true
}
```

##### 403 this credential may not manage billing: an API key without the keys:admin scope, or a person whose role in this tenant is not owner or admin

##### 409 the tenant is already on Saram Pro or on a subscription an operator moves; or the top-up would pass the tenant's own monthly budget

##### 422 an amount out of bounds, or auto-refill turned on without a threshold, amount or card

##### 502 Stripe refused the request or could not be reached; the detail is Stripe's own message

##### 503 this deployment has no Stripe key configured, or its Stripe account is not set up for what was asked, so nothing is started and nothing is charged

<a id="scalar-operation-delete-apiv1billingsubscription"></a>

### Billing Cancel

- **Method:** `DELETE`
- **Path:** `/api/v1/billing/subscription`
- **Operation ID:** `billing_cancel`
- **Tags:** usage

Cancel Saram Pro at the end of the month already paid for; the tenant then moves to Free. The month's
included credit stays until it expires, because the month's fee bought it.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[BillingSubscription](#scalar-schema-billingsubscription)

`POST` and `DELETE /api/v1/billing/subscription`: the Saram Pro subscription and, while its first invoice
is unpaid, the secret the Payment Element pays it with.

The tenant's plan becomes `pro` when Stripe reports the subscription active, not when this answers: an
abandoned checkout leaves the tenant where it was, and expires at Stripe within a day.

[Generated example](#scalar-example-27)

##### 403 this credential may not manage billing: an API key without the keys:admin scope, or a person whose role in this tenant is not owner or admin

##### 409 the tenant is already on Saram Pro or on a subscription an operator moves; or the top-up would pass the tenant's own monthly budget

##### 422 an amount out of bounds, or auto-refill turned on without a threshold, amount or card

##### 502 Stripe refused the request or could not be reached; the detail is Stripe's own message

##### 503 this deployment has no Stripe key configured, or its Stripe account is not set up for what was asked, so nothing is started and nothing is charged

<a id="scalar-operation-post-apiv1billingsetup"></a>

### Billing Setup

- **Method:** `POST`
- **Path:** `/api/v1/billing/setup`
- **Operation ID:** `billing_setup`
- **Tags:** usage

A SetupIntent to put a card on file, or change it (`stripe.confirmSetup`). The card becomes the one
top-ups and auto-refill charge when Stripe reports the intent succeeded.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[BillingSetup](#scalar-schema-billingsetup)

`POST /api/v1/billing/setup`: a SetupIntent for a card Saram may charge off-session (auto-refill). The card
becomes this tenant's card on file when Stripe reports the intent succeeded.

<a id="scalar-example-28"></a>

**Generated example:**

```json
{
  "setup_intent_id": "",
  "client_secret": ""
}
```

##### 403 this credential may not manage billing: an API key without the keys:admin scope, or a person whose role in this tenant is not owner or admin

##### 422 an amount out of bounds, or auto-refill turned on without a threshold, amount or card

##### 502 Stripe refused the request or could not be reached; the detail is Stripe's own message

##### 503 this deployment has no Stripe key configured, or its Stripe account is not set up for what was asked, so nothing is started and nothing is charged

<a id="scalar-operation-post-apiv1billingtop-ups"></a>

### Billing Top Up

- **Method:** `POST`
- **Path:** `/api/v1/billing/top-ups`
- **Operation ID:** `billing_top_up`
- **Tags:** usage

Buy prepaid credit: a one-off invoice for `amount_usd` x $1.00, answered with the client secret the
Payment Element pays it with (section 3.3).

A `TopUpWorkflow` makes and finalizes the invoice, and credits the balance once when Stripe says it is
paid - on the webhook, or on its own read ten minutes later if the webhook never comes. The browser's
word is never enough. An invoice left unpaid for a day is voided. Refused with a 409 when it would take
this month's charges past the monthly budget. Usually answers `open` with `client_secret`; if the invoice
is slow to make it answers `pending`, and `GET /billing/top-ups/{id}` carries the secret once it exists.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Request body

**Required:** `true`

**Content type:** `application/json`

[TopUpWrite](#scalar-schema-topupwrite)

`POST /api/v1/billing/top-ups`: how much credit to buy, in whole dollars.

<a id="scalar-example-29"></a>

**Generated example:**

```json
{
  "amount_usd": 10
}
```

#### Responses

##### 201 Successful Response

**Content type:** `application/json`

[TopUpRecord](#scalar-schema-topuprecord)

One top-up: where it stands, and while it is open, the secret the Payment Element pays it with.

`pending` (the invoice is being made), `open` (awaiting payment), then `paid` (credited to the balance),
`failed` (declined, or no invoice could be made), `requires_action` (an off-session charge the bank wants the
person to authenticate) or `void` (unpaid after a day). Credit is added once, when Stripe says the invoice is
paid - never on the browser's word. A top-up that ended unpaid says why in `failure_code` and
`failure_message`.

<a id="scalar-example-30"></a>

**Generated example:**

```json
{
  "id": "",
  "amount_usd": 1,
  "origin": "manual",
  "status": "pending",
  "invoice_id": "",
  "client_secret": "",
  "failure_code": "",
  "failure_message": "",
  "created_at": "",
  "finished_at": ""
}
```

##### 403 this credential may not manage billing: an API key without the keys:admin scope, or a person whose role in this tenant is not owner or admin

##### 409 the tenant is already on Saram Pro or on a subscription an operator moves; or the top-up would pass the tenant's own monthly budget

##### 422 an amount out of bounds, or auto-refill turned on without a threshold, amount or card

##### 502 Stripe refused the request or could not be reached; the detail is Stripe's own message

##### 503 this deployment has no Stripe key configured, or its Stripe account is not set up for what was asked, so nothing is started and nothing is charged

<a id="scalar-operation-get-apiv1billingtop-upstopup-id"></a>

### Billing Top Up Status

- **Method:** `GET`
- **Path:** `/api/v1/billing/top-ups/{topup_id}`
- **Operation ID:** `billing_top_up_status`
- **Tags:** usage

One of this tenant's top-ups, with its client secret while it is still open. Another tenant's id is
the 404 an unknown one gets.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Path parameters

- **`topup_id` (required)**: `string`

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[TopUpRecord](#scalar-schema-topuprecord)

One top-up: where it stands, and while it is open, the secret the Payment Element pays it with.

`pending` (the invoice is being made), `open` (awaiting payment), then `paid` (credited to the balance),
`failed` (declined, or no invoice could be made), `requires_action` (an off-session charge the bank wants the
person to authenticate) or `void` (unpaid after a day). Credit is added once, when Stripe says the invoice is
paid - never on the browser's word. A top-up that ended unpaid says why in `failure_code` and
`failure_message`.

[Generated example](#scalar-example-30)

##### 403 this credential may not manage billing: an API key without the keys:admin scope, or a person whose role in this tenant is not owner or admin

##### 422 an amount out of bounds, or auto-refill turned on without a threshold, amount or card

##### 502 Stripe refused the request or could not be reached; the detail is Stripe's own message

##### 503 this deployment has no Stripe key configured, or its Stripe account is not set up for what was asked, so nothing is started and nothing is charged

<a id="scalar-operation-post-apiv1providerscodexlogin"></a>

### Codex Login

- **Method:** `POST`
- **Path:** `/api/v1/providers/codex/login`
- **Operation ID:** `codex_login`
- **Tags:** providers

Start connecting your ChatGPT subscription, so your runs' models can bill it: open `verification_url`,
type `user_code`, then poll `GET /api/v1/providers/codex` until `state` is `connected`.

A login serves only the caller that made it, and only runs that caller starts. A 409 is a login already
in flight; a 403 is a deployment that does not offer ChatGPT logins to this caller.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Responses

##### 202 Successful Response

**Content type:** `application/json`

[CodexLoginStarted](#scalar-schema-codexloginstarted)

`POST /api/v1/providers/codex/login`: open `verification_url`, type `user_code`, then poll `status_url`.

<a id="scalar-example-31"></a>

**Generated example:**

```json
{
  "caller": "",
  "user_code": "",
  "verification_url": "",
  "expires_in": 1,
  "status_url": ""
}
```

##### 403 the API key lacks the required scope

<a id="scalar-operation-get-apiv1providerscodex"></a>

### Codex Status

- **Method:** `GET`
- **Path:** `/api/v1/providers/codex`
- **Operation ID:** `codex_status`
- **Tags:** providers

Your ChatGPT login's state - `none`, `pending`, `connected`, `expired` or `error` - with its plan,
when it was connected and when it expires, and the subscription's rate-limit use. Never any part of the
login itself.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[CodexStatus](#scalar-schema-codexstatus)

`GET /api/v1/providers/codex`. Metadata about a login; never any part of one.

<a id="scalar-example-32"></a>

**Generated example:**

```json
{
  "caller": "",
  "state": "none",
  "pending": {
    "user_code": "",
    "verification_url": "",
    "expires_at": ""
  },
  "account_id": "",
  "plan_type": "",
  "connected_at": "",
  "expires_at": "",
  "last_refresh": "",
  "error": "",
  "gateway": {
    "login": "",
    "primary_used_percent": 1,
    "primary_reset_at": "",
    "last_error": ""
  },
  "providers": {
    "entries": {
      "additionalProperty": {
        "model": "",
        "base_url": "",
        "credential_ref": ""
      }
    },
    "roles": {
      "additionalProperty": ""
    }
  }
}
```

##### 403 the API key lacks the required scope

<a id="scalar-operation-post-apiv1providerscodexdisconnect"></a>

### Codex Disconnect

- **Method:** `POST`
- **Path:** `/api/v1/providers/codex/disconnect`
- **Operation ID:** `codex_disconnect`
- **Tags:** providers

Disconnect your ChatGPT login: Saram revokes it at OpenAI and forgets it, and no run can bill that
subscription any more. The revocation is best effort: if OpenAI cannot be reached the login is still
forgotten, and then stops serving within a minute and at OpenAI only when its token expires. Your run
specs keep their `providers` entry, so connecting again is all a later run needs. A 404 when there is no
login to disconnect.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[CodexDisconnected](#scalar-schema-codexdisconnected)

A ChatGPT login disconnected: this caller's `state` is now `none`.

<a id="scalar-example-33"></a>

**Generated example:**

```json
{
  "caller": "",
  "state": "none"
}
```

##### 403 the API key lacks the required scope

<a id="scalar-operation-get-apiv1credentials"></a>

### List Credentials

- **Method:** `GET`
- **Path:** `/api/v1/credentials`
- **Operation ID:** `list_credentials`
- **Tags:** credentials

This tenant's live credentials, oldest first. Metadata only; the values never come back out.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[CredentialList](#scalar-schema-credentiallist)

The tenant's own credentials, as metadata: never a value, which the API stores and cannot read back.

<a id="scalar-example-34"></a>

**Generated example:**

```json
{
  "credentials": [
    {
      "id": "",
      "name": "",
      "kind": "",
      "secret_ref": "",
      "created_at": "",
      "deleted_at": ""
    }
  ]
}
```

##### 403 the API key lacks the required scope

<a id="scalar-operation-post-apiv1credentials"></a>

### Create Credential

- **Method:** `POST`
- **Path:** `/api/v1/credentials`
- **Operation ID:** `create_credential`
- **Tags:** credentials

This tenant's own provider key, test-account login, webhook key or GitHub token, written to Secrets
Manager and named by an `awssm://` ref.

The ref that comes back is what goes into a run spec's `providers.entries[].credential_ref` - or, for
kind `account`, into `target.seed.accounts`, for `webhook` into `connectors.webhook.secret_ref`, and for
`github` into `connectors.github.token_ref` - and only this tenant's specs may name it: another
tenant's spec naming it is refused at `POST /runs` with a 422. The value is never returned, logged,
put on a span, or stored anywhere but the secret.

What is refused: a name that is not one readable segment, a kind no worker resolves, an empty or
oversized value, and a value of the wrong shape for its kind - JSON on a kind whose credential is one
string, a non-object on `google-cloud`, or an `account` that is not a valid secret-store entry (422
each); a name this tenant already has, or whose secret
already exists (409). There is no replace: rotation is `DELETE` then `POST`, so a mistyped name cannot
retire a working key.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Request body

**Required:** `true`

**Content type:** `application/json`

[CredentialWrite](#scalar-schema-credentialwrite)

`POST /api/v1/credentials`: the one request body in this API that carries a secret.

The API stores `value` once and never answers it back: no response anywhere has a `value` field. To change a
credential, delete it and write it again.

<a id="scalar-example-35"></a>

**Generated example:**

```json
{
  "name": "",
  "kind": "",
  "value": ""
}
```

#### Responses

##### 201 Successful Response

**Content type:** `application/json`

[CredentialRecord](#scalar-schema-credentialrecord)

One of this tenant's credentials, a provider key or a test account, as all three routes answer it.
Metadata only.

<a id="scalar-example-36"></a>

**Generated example:**

```json
{
  "id": "",
  "name": "",
  "kind": "",
  "secret_ref": "",
  "created_at": "",
  "deleted_at": ""
}
```

##### 403 the API key lacks the credentials:write scope, or the request carries no tenant

##### 409 this tenant already has a credential by that name

##### 422 bad name, a kind no worker resolves, or a value of the wrong shape for its kind

<a id="scalar-operation-delete-apiv1credentialsname"></a>

### Delete Credential

- **Method:** `DELETE`
- **Path:** `/api/v1/credentials/{name}`
- **Operation ID:** `delete_credential`
- **Tags:** credentials

Force-delete the secret and free the name. A name this tenant does not have is a 404 - the same
404 whether it belongs to nobody or to another tenant, because the lookup never leaves this one.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Path parameters

- **`name` (required)**: `string`

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[CredentialRecord](#scalar-schema-credentialrecord)

One of this tenant's credentials, a provider key or a test account, as all three routes answer it.
Metadata only.

[Generated example](#scalar-example-36)

##### 403 the API key lacks the credentials:write scope, or the request carries no tenant

##### 404 this tenant has no credential by that name

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

[Generated example](#scalar-example-4)

<a id="scalar-operation-get-apiv1me"></a>

### Who Am I

- **Method:** `GET`
- **Path:** `/api/v1/me`
- **Operation ID:** `who_am_i`
- **Tags:** identity

Who this credential is, which tenant it spends and reads on, and what it may do.

The dashboard's first call, and deliberately the one route with no scope of its own: every credential
may ask what it is, including one whose role reads nothing else, and including an ungated request -
which answers as the `fleet` tenant, because that is whose runs an open dev API serves.

`scopes` is the *effective* answer, not the stored one: a person's role expanded through
`tenancy.ROLE_SCOPES`, a minted key's own list, and the full set for a credential that carries none.
A caller deciding which buttons to draw should never have to know that an empty list meant everything.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[Me](#scalar-schema-me)

Who this credential is, which tenant it spends and reads on, and what it may do.

The first call a dashboard shell makes: it decides which tenant's name is in the header, whether the
members and keys screens are offered, and whether the "start a run" button is there at all. It is
deliberately answerable by *every* credential - a person's token, a minted key, a break-glass entry, an
ungated request - so that one screen works against every way of reaching this API.

<a id="scalar-example-37"></a>

**Generated example:**

```json
{
  "subject": "",
  "caller": "",
  "tenant": {
    "id": "",
    "slug": "",
    "name": "",
    "plan": "",
    "status": "",
    "house_models": true
  },
  "role": "",
  "scopes": [
    ""
  ],
  "expires_at": "",
  "user": {
    "id": "",
    "email": "",
    "display_name": ""
  }
}
```

<a id="scalar-operation-get-apiv1api-keys"></a>

### List API Keys

- **Method:** `GET`
- **Path:** `/api/v1/api-keys`
- **Operation ID:** `list_api_keys`
- **Tags:** identity

This tenant's live keys, oldest first. Metadata only - no secret and no hash comes back out.

`last_used_at` is the field this list exists for: it answers "is anything still using this" before a
revocation, and it is stamped at most once every few minutes per key, not on every request.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[ApiKeyList](#scalar-schema-apikeylist)

The tenant's API keys, as metadata: never a key's secret, which is shown once, when it is minted.

<a id="scalar-example-38"></a>

**Generated example:**

```json
{
  "api_keys": [
    {
      "id": "",
      "name": "",
      "scopes": [
        ""
      ],
      "created_by": "",
      "created_at": "",
      "last_used_at": "",
      "expires_at": "",
      "revoked_at": ""
    }
  ]
}
```

##### 403 this credential may not mint keys: an API key without the keys:admin scope, or a person whose role in this tenant is not owner or admin

<a id="scalar-operation-post-apiv1api-keys"></a>

### Create API Key

- **Method:** `POST`
- **Path:** `/api/v1/api-keys`
- **Operation ID:** `create_api_key`
- **Tags:** identity

Mint one machine key for your own tenant. The secret is in this response and in nothing else.

What it is for: a person signs in to the dashboard and needs a credential for their CI, their laptop
or their coding agent. The key carries *scopes* and no role, so a CI key minted `runs:write,runs:read`
starts and reads runs and can neither mint another key nor upload a provider credential.

Only `sha256` of the secret is stored, so there is no route that shows it again: losing it means
minting another and revoking this one, which is the same motion as a rotation and is meant to be
cheap. The tenant is the one your own credential belongs to and cannot be named in the request.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Request body

**Required:** `true`

**Content type:** `application/json`

[ApiKeyWrite](#scalar-schema-apikeywrite)

`POST /api/v1/api-keys`: what a person asks for when they mint one of their tenant's machine keys.

<a id="scalar-example-39"></a>

**Generated example:**

```json
{
  "name": "",
  "scopes": [
    ""
  ],
  "expires_in_days": 1
}
```

#### Responses

##### 201 Successful Response

**Content type:** `application/json`

[ApiKeyMinted](#scalar-schema-apikeyminted)

What `POST /api/v1/api-keys` answers: the row, and the one and only sight of the secret.

`secret` is not stored anywhere - `api_keys` holds `sha256` of it - so a caller who does not keep this
response mints another and revokes this one. That is the same motion as a rotation, and it is meant to be.

<a id="scalar-example-40"></a>

**Generated example:**

```json
{
  "id": "",
  "name": "",
  "scopes": [
    ""
  ],
  "created_by": "",
  "created_at": "",
  "last_used_at": "",
  "expires_at": "",
  "revoked_at": "",
  "secret": ""
}
```

##### 403 this credential may not mint keys: an API key without the keys:admin scope, or a person whose role in this tenant is not owner or admin

##### 422 a name the table cannot hold, or a scope that is not one of ours

<a id="scalar-operation-delete-apiv1api-keyskey-id"></a>

### Revoke API Key

- **Method:** `DELETE`
- **Path:** `/api/v1/api-keys/{key_id}`
- **Operation ID:** `revoke_api_key`
- **Tags:** identity

Revoke one of this tenant's keys. The row stays and `revoked_at` is set; the key stops at the gate.

Never deleted, because `runs.created_by` points at it: a revoked key that vanished would leave every
run it started attributed to nothing. An id belonging to another tenant is the same 404 an id nobody
ever minted gets, so this route cannot be used to learn which key ids are real.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Path parameters

- **`key_id` (required)**: `string`

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[ApiKeySummary](#scalar-schema-apikeysummary)

One of this tenant's machine keys, as the list and the revocation answer it. The secret is not here.

<a id="scalar-example-41"></a>

**Generated example:**

```json
{
  "id": "",
  "name": "",
  "scopes": [
    ""
  ],
  "created_by": "",
  "created_at": "",
  "last_used_at": "",
  "expires_at": "",
  "revoked_at": ""
}
```

##### 403 this credential may not mint keys: an API key without the keys:admin scope, or a person whose role in this tenant is not owner or admin

##### 404 no key of this tenant's has that id - deliberately the same answer whether the id belongs to nobody or to another tenant, so that a 404 cannot be used to enumerate key ids

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

[Generated example](#scalar-example-4)

<a id="scalar-operation-get-apiv1githubinstallations"></a>

### List GitHub Installations

- **Method:** `GET`
- **Path:** `/api/v1/github/installations`
- **Operation ID:** `list_github_installations`
- **Tags:** credentials

The Saram GitHub App's installations linked to this tenant, by account: where the App writes for it.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[GitHubInstallationList](#scalar-schema-githubinstallationlist)

The Saram GitHub App's installations linked to this tenant.

<a id="scalar-example-42"></a>

**Generated example:**

```json
{
  "installations": [
    {
      "installation_id": 1,
      "account": "",
      "linked_by": "",
      "linked_at": ""
    }
  ]
}
```

##### 403 the API key lacks the required scope

<a id="scalar-operation-get-apiv1files"></a>

### List Files

- **Method:** `GET`
- **Path:** `/api/v1/files`
- **Operation ID:** `list_files`
- **Tags:** files

This tenant's files, newest first. Metadata only: the API never serves an uploaded file back.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[FileList](#scalar-schema-filelist)

The files this tenant has uploaded and not deleted, as metadata.

<a id="scalar-example-43"></a>

**Generated example:**

```json
{
  "files": [
    {
      "ref": "",
      "name": "",
      "size_bytes": 1,
      "media_type": "",
      "sha256": "",
      "uploaded_at": "",
      "expires_at": ""
    }
  ]
}
```

##### 403 the API key lacks the required scope

<a id="scalar-operation-post-apiv1files"></a>

### Upload File

- **Method:** `POST`
- **Path:** `/api/v1/files`
- **Operation ID:** `upload_file`
- **Tags:** files

One file, multipart, into this tenant's own prefix of the evidence store.

The answer is a `saram-file://<tenant_id>/<file_id>` ref, which is the only form a run spec may name a
file by - in a run started by any key or member of the same tenant, and by nobody else's. What is
refused, and why each: more bytes than the per-file limit, 25 MiB unless the deployment sets another
(413, and the body is cut off at the cap rather than read and then measured); a name that is not one path
segment, a media type the deployment does not accept, or magic bytes that disagree with the declared type
(422 each); and a caller that may not own files (403). Accepted by default: PNG, JPEG, WebP, GIF, PDF,
CSV, plain text, JSON, and Word and Excel documents (`.docx`, `.xlsx`); not HTML, SVG, scripts,
archives or executables.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Request body

**Required:** `true`

**Content type:** `multipart/form-data`

- **`file` (required)**: `string`, format: `binary`

  The bytes, with the part's Content-Type declaring the media type. One file per request.
- **`name`**: `string`

  What to store the file as; overrides the part's filename. One path segment: A-Z, a-z, 0-9, space, '.', '\_' or '-', no leading dot.

<a id="scalar-example-44"></a>

**Generated example:**

```json
{
  "file": "@filename",
  "name": ""
}
```

#### Responses

##### 201 Successful Response

**Content type:** `application/json`

[FileRecord](#scalar-schema-filerecord)

One file this tenant uploaded, as `POST /api/v1/files` and the two read routes answer it.

Metadata only, and deliberately: the API never serves an uploaded file's bytes back. Nothing needs them -
the dashboard shows names - and `GET /api/v1/blobs` stays evidence-only, which is what keeps a `files/`
key unreachable there. The tenant is not a field of its own: it is the ref's first segment, and it is
always the tenant of whoever asked.

<a id="scalar-example-45"></a>

**Generated example:**

```json
{
  "ref": "",
  "name": "",
  "size_bytes": 1,
  "media_type": "",
  "sha256": "",
  "uploaded_at": "",
  "expires_at": ""
}
```

##### 403 this caller may not own files, or the API key lacks the files:write scope

##### 413 larger than the most one file may be on this deployment; the detail names the limit

##### 422 bad name, a media type off the allowlist, or bytes that disagree with it

<a id="scalar-operation-get-apiv1filesfile-id"></a>

### Get File

- **Method:** `GET`
- **Path:** `/api/v1/files/{file_id}`
- **Operation ID:** `get_file`
- **Tags:** files

One uploaded file's metadata: its ref, name, size, media type and when it expires. Never its bytes.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Path parameters

- **`file_id` (required)**: `string`

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[FileRecord](#scalar-schema-filerecord)

One file this tenant uploaded, as `POST /api/v1/files` and the two read routes answer it.

Metadata only, and deliberately: the API never serves an uploaded file's bytes back. Nothing needs them -
the dashboard shows names - and `GET /api/v1/blobs` stays evidence-only, which is what keeps a `files/`
key unreachable there. The tenant is not a field of its own: it is the ref's first segment, and it is
always the tenant of whoever asked.

[Generated example](#scalar-example-45)

##### 403 the API key lacks the required scope

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

[Generated example](#scalar-example-4)

<a id="scalar-operation-post-apiv1filesfile-iddelete"></a>

### Delete File

- **Method:** `POST`
- **Path:** `/api/v1/files/{file_id}/delete`
- **Operation ID:** `delete_file`
- **Tags:** files

Delete one uploaded file. A run started afterwards that names it is refused; its ref stays in the
records of runs that already used it.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Path parameters

- **`file_id` (required)**: `string`

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[FileDeleted](#scalar-schema-filedeleted)

An uploaded file deleted: a run started from now on that names `ref` is refused.

<a id="scalar-example-46"></a>

**Generated example:**

```json
{
  "ref": "",
  "deleted": true
}
```

##### 403 the API key lacks the required scope

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

[Generated example](#scalar-example-4)

<a id="scalar-operation-get-apiv1personas"></a>

### List Personas

- **Method:** `GET`
- **Path:** `/api/v1/personas`
- **Operation ID:** `list_personas`
- **Tags:** personas

The personas a run can name: each by its `id@version` address, which is what a run spec's `personas`
lists, with its name, pack and tags.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[PersonaList](#scalar-schema-personalist)

The personas a run can name. `GET /api/v1/personas/{address}` answers one in full.

<a id="scalar-example-47"></a>

**Generated example:**

```json
{
  "personas": [
    {
      "address": "",
      "id": "",
      "version": 1,
      "name": "",
      "pack": "",
      "tags": [
        ""
      ]
    }
  ]
}
```

##### 403 the API key lacks the required scope

<a id="scalar-operation-get-apiv1personasaddress"></a>

### Get Persona

- **Method:** `GET`
- **Path:** `/api/v1/personas/{address}`
- **Operation ID:** `get_persona`
- **Tags:** personas

One persona in full, by `id@version` or by `id` for its latest version: its goals, priors, tolerance,
constraints, voice and verdict question - everything that drives how it behaves in a run.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Path parameters

- **`address` (required)**: `string`

#### Responses

##### 200 Successful Response

**Content type:** `application/json`

[Persona](#scalar-schema-persona)

A structured persona: a sampling strategy over the UI state space plus a tolerance function. Two kinds of fields: a structured core the run enforces and the dashboard diffs (`tolerance`, `constraints`, `action_policy`), and prose behavior fields the model reads (`backstory`, `vocabulary`, `facts`, `conversation`, `verdict_prompt`). Both drive what the persona does. `voice` affects narration only.

<a id="scalar-example-48"></a>

**Generated example:**

```json
{
  "id": "",
  "version": 1,
  "name": "",
  "pack": "",
  "tags": [
    ""
  ],
  "backstory": "",
  "vocabulary": {
    "knows": [
      ""
    ],
    "unknown": [
      ""
    ]
  },
  "facts": {
    "additionalProperty": ""
  },
  "conversation": {
    "phrasing": "plain_speech",
    "asks_before_hunting": true,
    "pushes_back_on_generic": true
  },
  "verdict_prompt": "",
  "goals": {
    "primary": "",
    "secondary": [
      ""
    ],
    "intensity": "low"
  },
  "priors": {
    "product_familiarity": "none",
    "domain_literacy": "low",
    "technical_literacy": "low",
    "mental_models": [
      ""
    ],
    "prior_instructions": [
      ""
    ]
  },
  "action_policy": {
    "reading": "reads",
    "input": "mouse",
    "exploration": "explores",
    "trust_defaults": "low",
    "help_seeking": "never",
    "error_response": "retry_same"
  },
  "tolerance": {
    "patience_steps": 1,
    "error_threshold": 0,
    "latency_sensitivity_ms": 0,
    "confusion_threshold": 1,
    "attempts_per_subtask": 1,
    "abandon_behavior": "narrates_reason"
  },
  "constraints": {
    "device": "desktop",
    "viewport": "",
    "network": "fast",
    "accessibility": [
      "screen_reader"
    ],
    "locale": "en-US",
    "auth_state": "anonymous"
  },
  "voice": {
    "style": "",
    "verbosity": "terse"
  },
  "provenance": {
    "generator": "",
    "sources": [
      ""
    ],
    "generated_at": "",
    "notes": ""
  }
}
```

##### 403 the API key lacks the required scope

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

[Generated example](#scalar-example-4)

<a id="scalar-operation-get-apiv1blobs"></a>

### Get Blob

- **Method:** `GET`
- **Path:** `/api/v1/blobs`
- **Operation ID:** `get_blob`
- **Tags:** blobs

Evidence bytes, streamed from the store a chunk at a time, with `Range`, `HEAD` and conditional requests.

Nothing here holds the object: `get_blob` returns a handle whose `size` answers `Content-Length` and
`Content-Range`, and the body is a `StreamingResponse` over `Blob.open`, which reads `CHUNK_SIZE`
at a time and asks the store for the requested byte range only. So a dashboard playing three session
videos costs three chunks in this process, and seeking to the last second of a recording transfers a
second of it. `HEAD` answers those headers and no body, which is how a caller (the MCP server's
`get_evidence_url`) learns a blob's size without fetching it.

Except a step stream: an `.ndjson` blob is read whole and served with its keystrokes withheld - a surface
half's keyboard input reads `[secret]` unless that step's agent half says it was not a credential
(`blobs.withhold_keystrokes`) - because a pool without `type.secret` writes a typed password into its
`surface.ndjson` verbatim, and so does a replay. Its `Content-Length`, ranges and `ETag` describe the bytes
served, not the bytes stored.

Every answer with bytes carries the blob's `ETag` and `Last-Modified`, from the metadata read that gave
its size, and the route answers the conditional requests a browser cache and a media player make with
them (RFC 9110 section 13): `If-None-Match` or `If-Modified-Since` that still match are a 304 with the
`ETag` and no body, and a `Range` under an `If-Range` that no longer matches is ignored for the whole
blob with a 200, so a player never splices a stale half onto a new one. The 304 is decided before the
range, which is the order RFC 9110 gives, and is the same for `HEAD`.

The generator handed to `StreamingResponse` is deliberately synchronous: Starlette pulls a sync
iterator through its threadpool, so the store's blocking read never sits on the event loop, and the
`with` inside it closes the file handle or the S3 body even when the client disconnects mid-stream.

Signed S3 URLs would be the other way to get the bytes out of this process, and are refused for now:
a presigned URL is bearer authority on the object, so it leaves both the API key gate and the
run-ownership check behind and cannot be narrowed to the caller who asked.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Query parameters

- **`ref` (required)**: `string`

  a `file://` or `s3://` evidence URI as recorded in a trace

#### Header parameters

- **`Range`**: `string | null`
- **`If-None-Match`**: `string | null`

  ETags the caller already holds, or \*. If one matches the blob's (weak comparison), the answer is 304 with no body. When present, If-Modified-Since is not evaluated.
- **`If-Modified-Since`**: `string | null`

  An HTTP-date. If the blob has not been written since, the answer is 304 with no body. Ignored when If-None-Match is present, or when it is not a valid date.
- **`If-Range`**: `string | null`

  An ETag or an HTTP-date the caller's partial copy was fetched under. If it still matches the blob (strong comparison), Range is honoured; if not, Range is ignored and the whole blob comes back with a 200.

#### Responses

##### 200 Successful Response

**Headers:**

- **`ETag`**

  a strong entity tag for the blob's bytes; send it back as If-None-Match or If-Range

  `string`
- **`Last-Modified`**

  when the blob was last written, as an HTTP-date; send it back as If-Modified-Since

  `string`

**Content type:** `application/octet-stream`

##### 206 Partial Content

**Headers:**

- **`ETag`**

  a strong entity tag for the blob's bytes; send it back as If-None-Match or If-Range

  `string`
- **`Last-Modified`**

  when the blob was last written, as an HTTP-date; send it back as If-Modified-Since

  `string`

##### 304 Not Modified: If-None-Match or If-Modified-Since matched, so the caller's copy is current

**Headers:**

- **`ETag`**

  a strong entity tag for the blob's bytes; send it back as If-None-Match or If-Range

  `string`

##### 403 a ref this API never serves: outside the evidence store, or a bucket that is not its run's

##### 404 nothing here for this key: the blob is gone, the ref names no run, or it names a run belonging to another tenant - deliberately one answer, so that a 403 can never confirm a run id exists

##### 416 Range Not Satisfiable

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

[Generated example](#scalar-example-4)

<a id="scalar-operation-head-apiv1blobs"></a>

### Head Blob

- **Method:** `HEAD`
- **Path:** `/api/v1/blobs`
- **Operation ID:** `head_blob`
- **Tags:** blobs

Evidence bytes, streamed from the store a chunk at a time, with `Range`, `HEAD` and conditional requests.

Nothing here holds the object: `get_blob` returns a handle whose `size` answers `Content-Length` and
`Content-Range`, and the body is a `StreamingResponse` over `Blob.open`, which reads `CHUNK_SIZE`
at a time and asks the store for the requested byte range only. So a dashboard playing three session
videos costs three chunks in this process, and seeking to the last second of a recording transfers a
second of it. `HEAD` answers those headers and no body, which is how a caller (the MCP server's
`get_evidence_url`) learns a blob's size without fetching it.

Except a step stream: an `.ndjson` blob is read whole and served with its keystrokes withheld - a surface
half's keyboard input reads `[secret]` unless that step's agent half says it was not a credential
(`blobs.withhold_keystrokes`) - because a pool without `type.secret` writes a typed password into its
`surface.ndjson` verbatim, and so does a replay. Its `Content-Length`, ranges and `ETag` describe the bytes
served, not the bytes stored.

Every answer with bytes carries the blob's `ETag` and `Last-Modified`, from the metadata read that gave
its size, and the route answers the conditional requests a browser cache and a media player make with
them (RFC 9110 section 13): `If-None-Match` or `If-Modified-Since` that still match are a 304 with the
`ETag` and no body, and a `Range` under an `If-Range` that no longer matches is ignored for the whole
blob with a 200, so a player never splices a stale half onto a new one. The 304 is decided before the
range, which is the order RFC 9110 gives, and is the same for `HEAD`.

The generator handed to `StreamingResponse` is deliberately synchronous: Starlette pulls a sync
iterator through its threadpool, so the store's blocking read never sits on the event loop, and the
`with` inside it closes the file handle or the S3 body even when the client disconnects mid-stream.

Signed S3 URLs would be the other way to get the bytes out of this process, and are refused for now:
a presigned URL is bearer authority on the object, so it leaves both the API key gate and the
run-ownership check behind and cannot be narrowed to the caller who asked.

**Servers:** [Inherited servers](#scalar-context-global-servers)

#### Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

#### Query parameters

- **`ref` (required)**: `string`

  a `file://` or `s3://` evidence URI as recorded in a trace

#### Header parameters

- **`Range`**: `string | null`
- **`If-None-Match`**: `string | null`

  ETags the caller already holds, or \*. If one matches the blob's (weak comparison), the answer is 304 with no body. When present, If-Modified-Since is not evaluated.
- **`If-Modified-Since`**: `string | null`

  An HTTP-date. If the blob has not been written since, the answer is 304 with no body. Ignored when If-None-Match is present, or when it is not a valid date.
- **`If-Range`**: `string | null`

  An ETag or an HTTP-date the caller's partial copy was fetched under. If it still matches the blob (strong comparison), Range is honoured; if not, Range is ignored and the whole blob comes back with a 200.

#### Responses

##### 200 Successful Response

**Headers:**

- **`ETag`**

  a strong entity tag for the blob's bytes; send it back as If-None-Match or If-Range

  `string`
- **`Last-Modified`**

  when the blob was last written, as an HTTP-date; send it back as If-Modified-Since

  `string`

**Content type:** `application/octet-stream`

##### 206 Partial Content

**Headers:**

- **`ETag`**

  a strong entity tag for the blob's bytes; send it back as If-None-Match or If-Range

  `string`
- **`Last-Modified`**

  when the blob was last written, as an HTTP-date; send it back as If-Modified-Since

  `string`

##### 304 Not Modified: If-None-Match or If-Modified-Since matched, so the caller's copy is current

**Headers:**

- **`ETag`**

  a strong entity tag for the blob's bytes; send it back as If-None-Match or If-Range

  `string`

##### 403 a ref this API never serves: outside the evidence store, or a bucket that is not its run's

##### 404 nothing here for this key: the blob is gone, the ref names no run, or it names a run belonging to another tenant - deliberately one answer, so that a 403 can never confirm a run id exists

##### 416 Range Not Satisfiable

##### 422 Validation Error

**Content type:** `application/json`

[HTTPValidationError](#scalar-schema-httpvalidationerror)

[Generated example](#scalar-example-4)

## Schemas

<a id="scalar-schema-a"></a>

### A

**Type:** `object`

The first of the two statements that conflict: what the product said (`text`), and the session and step where it said it.

- **`session_id`**: `string | null`
- **`step_id`**: `integer | null`
- **`text`**: `string | null`

<a id="scalar-example-49"></a>

**Generated example:**

```json
{
  "step_id": 1,
  "session_id": "",
  "text": ""
}
```

<a id="scalar-schema-abandonbehavior"></a>

### AbandonBehavior

**Type:** `string`, possible values: `"silent", "narrates_reason"`

What the persona's diary says when they give up, on one task or on the whole session. `narrates_reason` (the default): they say why before they stop. `silent`: they simply stop.

<a id="scalar-schema-accessibilityenum"></a>

### AccessibilityEnum

**Type:** `string`, possible values: `"screen_reader", "keyboard_only", "low_vision", "reduced_motion", "color_blind"`

One accessibility need the persona has.

<a id="scalar-schema-actionpolicy"></a>

### ActionPolicy

**Type:** `object`, no additional properties

How the persona works a page: how much of it they read, what they work it with, whether they wander or head straight for the goal, and how they treat defaults, help and errors. Each value becomes a line of the persona's briefing.

- **`exploration` (required)**: `string`, possible values: `"explores", "beeline"`

  Schema: `Exploration`.

  Wanders vs. heads straight for the goal
- **`input` (required)**: `string`, possible values: `"mouse", "keyboard", "touch"`

  Schema: `Input`.

  What the persona works the page with. `mouse`: a computer with a mouse and a keyboard. `keyboard`: tabs between fields and presses Enter rather than reach for the mouse. `touch`: a phone, tapping and swiping. This shapes the persona's habits; whether the browser itself is a touch device is `constraints.device`.
- **`reading` (required)**: `string`, possible values: `"reads", "skims", "ignores"`

  Schema: `Reading`.

  How much on-screen text they consume
- **`error_response`**: `string | null`, possible values: `"retry_same", "retry_different", "back_out", "abandon"`

  Schema: `ErrorResponse`.

  First reaction to an error
- **`help_seeking`**: `string | null`, possible values: `"never", "after_error", "eager"`

  Schema: `HelpSeeking`.

  Uses tooltips, help links, search
- **`trust_defaults`**: `string | null`, possible values: `"low", "medium", "high"`

  Schema: `TrustDefaults`.

  Accepts prefilled/default choices vs. checks everything

<a id="scalar-example-50"></a>

**Generated example:**

```json
{
  "reading": "reads",
  "input": "mouse",
  "exploration": "explores",
  "trust_defaults": "low",
  "help_seeking": "never",
  "error_response": "retry_same"
}
```

<a id="scalar-schema-actionstep"></a>

### ActionStep

**Type:** `object`, no additional properties

The step the claim is about and the replay is judged at - the action that failed, not the look that saw it or the retry after it. Written by reproduction, which picks it (`_judged_path`); absent when nothing replayed this issue.

- **`session_id` (required)**: `string`
- **`step_id` (required)**: `integer`

<a id="scalar-example-51"></a>

**Generated example:**

```json
{
  "session_id": "",
  "step_id": 1
}
```

<a id="scalar-schema-alignedstep"></a>

### AlignedStep

**Type:** `object`, no additional properties

One step, both halves: what the persona decided (the agent half) and what the browser did (the surface
half), joined on `step_id`. A half that is missing is flagged (`has_agent`, `has_surface`), not invented; the
trailing `abandon`/`complete` step is agent-only by design (`terminal`).

On an `in_flight` page (`StepsPage.status`) both halves are there for every step the browser has answered.
What waits for the session to end is the video, which is stored when the browser closes: a live step carries
its `video_ref` and window but no `video_url` yet.

- **`has_agent` (required)**: `boolean`
- **`has_surface` (required)**: `boolean`
- **`step_id` (required)**: `integer`
- **`terminal` (required)**: `boolean`
- **`action_args`**: `object`
- **`action_type`**: `string | null`
- **`console_errors`**: `array of object`
- **`dialogs`**: `array of object`

  Native dialogs the page opened, or that were answered, at this step (`surface_trace.dialogs`): type,
  message verbatim, and how the step left it - `pending` on the step that opened one, `accept`, `dismiss` or
  `auto_accepted_beforeunload` on the step that answered it. A dialog floats above the page and is in no
  screenshot, so this is the only place a reader sees it. Empty when the pool saw none.
- **`dom_mutation_count`**: `integer | null`
- **`downloads`**: `array of object`

  Files the browser saved at this step (`surface_trace.downloads`): filename, media type, size, hash and
  the stored copy's ref when the pool kept the bytes - never a path. A download moves no pixel and no url, so
  without this the step reads as a click that did nothing. Empty when the pool saw none.
- **`end_reason`**: `string | null`
- **`field`**: `object | null`

  The focused text-entry control after this step, as the persona was told it (`agent_trace.field`): its
  kind, accessible name, the value it really holds (cut at 200 characters), the value's full length, and the
  hint it shows while empty - never part of the value. A password field, and any field holding one of the
  session's credentials, is `masked`: the length is there and the value is not. Present on a `click`, `type`
  or `key` step whose focus landed in a text control, on a pool that reports it; absent otherwise. On the
  agent half, so it is readable on an `in_flight` page.
- **`files_attached`**: `array of object`

  Files the body handed to the page at this step (`surface_trace.files_attached`): name, size, media
  type, hash and whether it went through the file picker or straight into a file input - never a path. Empty
  on every step but an `upload`.
- **`inputs`**: `array of object`

  What the body dispatched at this step (`surface_trace.inputs_dispatched`): pointer and keyboard input,
  with the selector and text each resolved onto. On a `type` the persona made with the session's credential
  (`action_args.secret`), every keyboard input's `detail.text` and `resolved_text` is `[secret]`, whatever
  the pool recorded: the password is on no step a reader is served.
- **`latency_ms`**: `integer | null`
- **`model`**: `string | null`
- **`narration`**: `string | null`

  SUBJECTIVE: the persona's diary line said before this action. Quote it as the persona's, never as a
  finding.
- **`network_failures`**: `array of object`
- **`observation_mode`**: `string | null`
- **`perceived_state`**: `string | null`
- **`reasoning_summary`**: `string | null`
- **`result`**: `string | null`

  On the terminal step, the artifact result it implies (`completed`, `abandoned`, `budget_exhausted`).
- **`route`**: `string | null`
- **`screenshot_ref`**: `string | null`
- **`screenshot_url`**: `string | null`
- **`target_description`**: `string | null`
- **`timing`**: [StepTiming](#scalar-schema-steptiming)

  How long the browser took over one step, in milliseconds. Each is absent when the browser did not
  measure it.
- **`tokens`**: [StepTokens](#scalar-schema-steptokens) | `null`

  The model tokens behind one step's decision: prompt, reply, and the part of the prompt read from the
  cache. Each is absent when the provider did not report it.
- **`tolerance_state`**: `object`
- **`ts_agent`**: `string | null`, format: `date-time`
- **`ts_end_s`**: `number | null`
- **`ts_start_s`**: `number | null`

  Where this step starts in the session video, seconds from its first frame.
- **`ts_surface`**: `string | null`, format: `date-time`
- **`url`**: `string | null`
- **`video_ref`**: `string | null`
- **`video_url`**: `string | null`
- **`viewport`**: `string | null`

<a id="scalar-example-52"></a>

**Generated example:**

```json
{
  "step_id": 1,
  "has_agent": true,
  "has_surface": true,
  "terminal": true,
  "ts_agent": "",
  "action_type": "",
  "target_description": "",
  "action_args": {
    "additionalProperty": "anything"
  },
  "narration": "",
  "reasoning_summary": "",
  "perceived_state": "",
  "observation_mode": "",
  "tolerance_state": {
    "additionalProperty": "anything"
  },
  "latency_ms": 1,
  "tokens": {
    "input": 1,
    "output": 1,
    "cached": 1
  },
  "model": "",
  "end_reason": "",
  "result": "",
  "field": {
    "additionalProperty": "anything"
  },
  "ts_surface": "",
  "url": "",
  "route": "",
  "viewport": "",
  "inputs": [
    {
      "additionalProperty": "anything"
    }
  ],
  "files_attached": [
    {
      "additionalProperty": "anything"
    }
  ],
  "dialogs": [
    {
      "additionalProperty": "anything"
    }
  ],
  "downloads": [
    {
      "additionalProperty": "anything"
    }
  ],
  "network_failures": [
    {
      "additionalProperty": "anything"
    }
  ],
  "console_errors": [
    {
      "additionalProperty": "anything"
    }
  ],
  "dom_mutation_count": 1,
  "timing": {
    "action_to_settle_ms": 1,
    "longest_task_ms": 1,
    "navigation_ms": 1
  },
  "screenshot_ref": "",
  "screenshot_url": "",
  "video_ref": "",
  "video_url": "",
  "ts_start_s": 1,
  "ts_end_s": 1
}
```

<a id="scalar-schema-apikeylist"></a>

### ApiKeyList

**Type:** `object`, no additional properties

The tenant's API keys, as metadata: never a key's secret, which is shown once, when it is minted.

- **`api_keys` (required)**: array of [ApiKeySummary](#scalar-schema-apikeysummary)

  This tenant's live keys, oldest first. A revoked one is not listed.

<a id="scalar-example-53"></a>

**Generated example:**

```json
{
  "api_keys": [
    {
      "id": "",
      "name": "",
      "scopes": [
        ""
      ],
      "created_by": "",
      "created_at": "",
      "last_used_at": "",
      "expires_at": "",
      "revoked_at": ""
    }
  ]
}
```

<a id="scalar-schema-apikeyminted"></a>

### ApiKeyMinted

**Type:** `object`, no additional properties

What `POST /api/v1/api-keys` answers: the row, and the one and only sight of the secret.

`secret` is not stored anywhere - `api_keys` holds `sha256` of it - so a caller who does not keep this
response mints another and revokes this one. That is the same motion as a rotation, and it is meant to be.

- **`created_at` (required)**: `string`, format: `date-time`
- **`created_by` (required)**: `string`

  usr\_... for a person who minted it from the dashboard, operator for the CLI.
- **`id` (required)**: `string`
- **`name` (required)**: `string`
- **`scopes` (required)**: `array of string`
- **`secret` (required)**: `string`

  sk\_saram\_\_<40 characters>. Shown once, here, and never again by any route: only its hash is stored. Treat it as you would a password - it carries this tenant's authority until it is revoked.
- **`expires_at`**: `string | null`, format: `date-time`
- **`last_used_at`**: `string | null`, format: `date-time`

  When this key last got through the gate, to a few minutes: it answers whether anything is still using the key before you revoke it, which does not need the last second.
- **`revoked_at`**: `string | null`, format: `date-time`

  Set on the record DELETE answers with. The row stays; the key stops working.

<a id="scalar-example-54"></a>

**Generated example:**

```json
{
  "id": "",
  "name": "",
  "scopes": [
    ""
  ],
  "created_by": "",
  "created_at": "",
  "last_used_at": "",
  "expires_at": "",
  "revoked_at": "",
  "secret": ""
}
```

<a id="scalar-schema-apikeysummary"></a>

### ApiKeySummary

**Type:** `object`, no additional properties

One of this tenant's machine keys, as the list and the revocation answer it. The secret is not here.

- **`created_at` (required)**: `string`, format: `date-time`
- **`created_by` (required)**: `string`

  usr\_... for a person who minted it from the dashboard, operator for the CLI.
- **`id` (required)**: `string`
- **`name` (required)**: `string`
- **`scopes` (required)**: `array of string`
- **`expires_at`**: `string | null`, format: `date-time`
- **`last_used_at`**: `string | null`, format: `date-time`

  When this key last got through the gate, to a few minutes: it answers whether anything is still using the key before you revoke it, which does not need the last second.
- **`revoked_at`**: `string | null`, format: `date-time`

  Set on the record DELETE answers with. The row stays; the key stops working.

<a id="scalar-example-55"></a>

**Generated example:**

```json
{
  "id": "",
  "name": "",
  "scopes": [
    ""
  ],
  "created_by": "",
  "created_at": "",
  "last_used_at": "",
  "expires_at": "",
  "revoked_at": ""
}
```

<a id="scalar-schema-apikeywrite"></a>

### ApiKeyWrite

**Type:** `object`, no additional properties

`POST /api/v1/api-keys`: what a person asks for when they mint one of their tenant's machine keys.

- **`name` (required)**: `string`

  Which of this tenant's credentials this is: lowercase letters, digits, underscore and dash, up to 64 characters - ci, dana-laptop. Not unique, because a rotation is two live keys under one name until the caller has moved.
- **`expires_in_days`**: `integer | null`, minimum: `1`

  Refuse this key at the gate after this many days. Omitted, it does not expire on its own.
- **`scopes`**: `(array of string) | null`

  A subset of runs:write, runs:read, files:write, credentials:write, keys:admin. Omitted, the key gets runs:write and runs:read, which is what a CI key needs and nothing more.

<a id="scalar-example-56"></a>

**Generated example:**

```json
{
  "name": "",
  "scopes": [
    ""
  ],
  "expires_in_days": 1
}
```

<a id="scalar-schema-attribution"></a>

### Attribution

**Type:** `string`

Known values, open (more may be added): `product`, `tester`, `undetermined`.

Whose failure this is, asked before an issue is a product finding: could the persona's own action explain it? `product`: no - the failure is the product's to own. `tester`: yes - a mistyped credential, the wrong element, placeholder text read as a value; the record moves to `tester_errors[]` with a `tester_error` block, is never a ticket and never counted as an issue. `undetermined`: the harness could not tell (a persona handed stale credentials by the mission author typed exactly what it was told; an attribution call that failed), and a human must; it caps `status` at `marker_matched`. Absent means never asked - every issue before this release - and a reader treats absent as `undetermined`. A classification: nothing acts on it directly, the producer derives `status` from it, so a later member (`setup`) is additive and an older reader buckets it as `unknown`.

**Example:**

```json
"product"
```

**Example:**

```json
"tester"
```

**Example:**

```json
"undetermined"
```

<a id="scalar-schema-authstate"></a>

### AuthState

**Type:** `string`, possible values: `"anonymous", "new_user", "returning_user", "admin"`

The kind of visitor the persona arrives as: `anonymous` (the default), a `new_user`, a `returning_user` or an `admin`. Descriptive, and listed in each issue's `preconditions`; it signs nobody in. A session is signed in by the account it is handed from the run spec's `target.seed.accounts`.

<a id="scalar-schema-autorefill"></a>

### AutoRefill

**Type:** `object`, no additional properties

The tenant's auto-refill setting and monthly budget (`POST /api/v1/billing/auto-refill`, and on
`GET /api/v1/billing`).

With `auto_refill` on, whenever the balance less what running runs hold falls below `refill_below_usd`, Saram
charges the card on file `refill_amount_usd` - never past `monthly_budget_usd` in a calendar month, manual
top-ups included. A declined charge, or one the bank wants authenticated, turns auto-refill off.

- **`auto_refill` (required)**: `boolean`
- **`monthly_budget_usd`**: `number | null`, minimum: `0`

  The most Saram may charge the card in a calendar month; null is no budget.
- **`refill_amount_usd`**: `integer | null`, minimum: `10`, maximum: `500`
- **`refill_below_usd`**: `number | null`, minimum: `0`

<a id="scalar-example-57"></a>

**Generated example:**

```json
{
  "auto_refill": true,
  "refill_below_usd": 0,
  "refill_amount_usd": 10,
  "monthly_budget_usd": 0
}
```

<a id="scalar-schema-autorefillstate"></a>

### AutoRefillState

**Type:** `object`, no additional properties

The auto-refill setting as `GET /api/v1/billing` reads it: the setting, and the refill it last did not
charge. Only the setting is written back (`POST /api/v1/billing/auto-refill` takes `AutoRefill`).

- **`auto_refill` (required)**: `boolean`
- **`last_skipped`**: [RefillSkip](#scalar-schema-refillskip) | `null`

  The latest refill that was due and not charged, so a screen can say 'Auto-refill is on, but this month's budget is spent'. Null once a refill has been started since, once the setting has been changed (by POST /billing/auto-refill, or by auto-refill turning itself off after a declined card), and for a monthly\_budget skip once its calendar month is over, because the new month's budget has room again.
- **`monthly_budget_usd`**: `number | null`, minimum: `0`

  The most Saram may charge the card in a calendar month; null is no budget.
- **`refill_amount_usd`**: `integer | null`, minimum: `10`, maximum: `500`
- **`refill_below_usd`**: `number | null`, minimum: `0`

<a id="scalar-example-58"></a>

**Generated example:**

```json
{
  "auto_refill": true,
  "refill_below_usd": 0,
  "refill_amount_usd": 10,
  "monthly_budget_usd": 0,
  "last_skipped": {
    "at": "",
    "reason": "monthly_budget",
    "detail": ""
  }
}
```

<a id="scalar-schema-b"></a>

### B

**Type:** `object`

The second of the two statements that conflict: what the product said (`text`), and the session and step where it said it.

- **`session_id`**: `string | null`
- **`step_id`**: `integer | null`
- **`text`**: `string | null`

<a id="scalar-example-59"></a>

**Generated example:**

```json
{
  "step_id": 1,
  "session_id": "",
  "text": ""
}
```

<a id="scalar-schema-baselinestatus"></a>

### BaselineStatus

**Type:** `string`

Known values, open (more may be added): `new`, `persisting`, `resolved`, `unknown`.

How this finding compares with the run named as the baseline. `new`: the baseline reached the same page and did not have it (for an issue, only one that is `reproducible`). `persisting`: the baseline has it too. `unknown`: nothing could say - no baseline was compared, the baseline never reached the page, or an issue the baseline does not have was not verified. `resolved` is never stamped on a finding of this run: a baseline issue this run no longer has is counted in `summary.issues_resolved_vs_baseline`. More values may be added; read one you do not know as `unknown`.

**Example:**

```json
"new"
```

**Example:**

```json
"persisting"
```

**Example:**

```json
"resolved"
```

**Example:**

```json
"unknown"
```

<a id="scalar-schema-billingoverview"></a>

### BillingOverview

**Type:** `object`, no additional properties

`GET /api/v1/billing`: the tenant's plan, prepaid balance, card on file, auto-refill and recent top-ups.

The balance is Saram's ledger, not Stripe's: `included` is the plan's monthly credit and expires at month end,
`purchased` is top-ups and does not; `held` is what runs in flight have reserved, and `available` is what a
new run can hold against.

- **`auto_refill` (required)**: [AutoRefillState](#scalar-schema-autorefillstate)

  The auto-refill setting as `GET /api/v1/billing` reads it: the setting, and the refill it last did not
  charge. Only the setting is written back (`POST /api/v1/billing/auto-refill` takes `AutoRefill`).
- **`available_usd` (required)**: `number`
- **`balance_usd` (required)**: `number`
- **`billing_status` (required)**: `string`
- **`budget_used_usd` (required)**: `number`

  This month's top-ups, paid or still in flight.
- **`card_on_file` (required)**: `boolean`
- **`held_usd` (required)**: `number`
- **`included_usd` (required)**: `number`
- **`plan` (required)**: `string`
- **`purchased_usd` (required)**: `number`
- **`top_ups` (required)**: array of [TopUpRecord](#scalar-schema-topuprecord)

  The newest twenty, newest first. No client secrets.
- **`included_credit_usd`**: `number`, default: `0`

  The credit your plan includes each month, which expires at month end: $1 on Free, $10 on Pro. What this month has left of it is included\_usd.
- **`limits`**: [PlanLimits](#scalar-schema-planlimits)

  What your plan allows, from the plan's row in force now.
- **`members_used`**: `integer`, default: `0`

  How many people are in your workspace now, every role counted: see limits.max\_members.
- **`monthly_fee_usd`**: `number`, default: `0`

  Your plan's monthly fee, before tax: $0 on Free, $20 on Pro. From the plan's row now.
- **`subscription`**: [SubscriptionState](#scalar-schema-subscriptionstate) | `null`

  Your Saram Pro subscription, when there is one: whether it renews or ends, and when. Absent on a tenant that never started a Pro checkout.
- **`usage_price_usd`**: `number | null`

  USD per Usage on your plan now: available\_usd divided by this is 'about N Usage'. Absent on a plan billed in steps, which has no Usage price.

<a id="scalar-example-60"></a>

**Generated example:**

```json
{
  "plan": "",
  "limits": {
    "max_concurrent_sessions": 1,
    "evidence_retention_days": 1,
    "max_members": 1,
    "automation": true
  },
  "monthly_fee_usd": 0,
  "included_credit_usd": 0,
  "usage_price_usd": 1,
  "subscription": {
    "status": "",
    "cancel_at_period_end": true,
    "current_period_end": ""
  },
  "members_used": 0,
  "billing_status": "",
  "included_usd": 1,
  "purchased_usd": 1,
  "balance_usd": 1,
  "held_usd": 1,
  "available_usd": 1,
  "card_on_file": true,
  "auto_refill": {
    "auto_refill": true,
    "refill_below_usd": 0,
    "refill_amount_usd": 10,
    "monthly_budget_usd": 0,
    "last_skipped": {
      "at": "",
      "reason": "monthly_budget",
      "detail": ""
    }
  },
  "budget_used_usd": 1,
  "top_ups": [
    {
      "id": "",
      "amount_usd": 1,
      "origin": "manual",
      "status": "pending",
      "invoice_id": "",
      "client_secret": "",
      "failure_code": "",
      "failure_message": "",
      "created_at": "",
      "finished_at": ""
    }
  ]
}
```

<a id="scalar-schema-billingportal"></a>

### BillingPortal

**Type:** `object`, no additional properties

A one-use link into Stripe's hosted customer portal (`POST /api/v1/billing/portal`).

Where a customer manages the card Saram never sees: Saram stores no payment instrument, renders no
payment form and is not in the flow at all. The link is short-lived and single-use by Stripe's design, so it
is minted per click and never cached; the invoices, receipts and the subscription behind it are Stripe's
screens, and what this API serves is the usage the invoice is derived from (`GET /api/v1/usage`).

- **`url` (required)**: `string`

  Where to send the customer. Short-lived and single-use; mint a new one per click.
- **`return_url`**: `string | null`

  Where Stripe sends them back to afterwards, when this deployment names one.

<a id="scalar-example-61"></a>

**Generated example:**

```json
{
  "url": "",
  "return_url": ""
}
```

<a id="scalar-schema-billingsetup"></a>

### BillingSetup

**Type:** `object`, no additional properties

`POST /api/v1/billing/setup`: a SetupIntent for a card Saram may charge off-session (auto-refill). The card
becomes this tenant's card on file when Stripe reports the intent succeeded.

- **`client_secret` (required)**: `string`

  What the Payment Element confirms in the browser (stripe.confirmPayment / confirmSetup), where SCA happens. Present only while there is something to confirm. Saram never sees the card.
- **`setup_intent_id` (required)**: `string`

<a id="scalar-example-62"></a>

**Generated example:**

```json
{
  "setup_intent_id": "",
  "client_secret": ""
}
```

<a id="scalar-schema-billingsubscription"></a>

### BillingSubscription

**Type:** `object`, no additional properties

`POST` and `DELETE /api/v1/billing/subscription`: the Saram Pro subscription and, while its first invoice
is unpaid, the secret the Payment Element pays it with.

The tenant's plan becomes `pro` when Stripe reports the subscription active, not when this answers: an
abandoned checkout leaves the tenant where it was, and expires at Stripe within a day.

- **`cancel_at_period_end` (required)**: `boolean`

  Pro ends when the month already paid for does; the tenant then moves to Free.
- **`status` (required)**: `string`

  Stripe's: `incomplete` until the first invoice is paid, then `active`; `past_due` when a renewal failed; `canceled` once it has ended.
- **`subscription_id` (required)**: `string`
- **`client_secret`**: `string | null`

  What the Payment Element confirms in the browser (stripe.confirmPayment / confirmSetup), where SCA happens. Present only while there is something to confirm. Saram never sees the card.

<a id="scalar-example-63"></a>

**Generated example:**

```json
{
  "subscription_id": "",
  "status": "",
  "client_secret": "",
  "cancel_at_period_end": true
}
```

<a id="scalar-schema-check"></a>

### Check

**Type:** `object`, no additional properties

The claim check: one narrow question asked of the evidence at the cited steps after a replay matched a marker - does this evidence show the product doing what the title says? Descriptive: nothing acts on it directly; the producer derives `status` from `verdict` and `attribution`. Absent when the check never ran, which is every issue before this release and every issue no marker matched.

- **`verdict` (required)**: `string`

  Schema: `Verdict1`. Known values, open (more may be added): `supported`, `unsupported`, `ungrounded`, `skipped`.

  `supported`: the checker answered yes with a quote that gates against the ledger text of the cited steps - the only verdict that lets `status` read `verified`. `unsupported`: the checker answered no; the title claims more than the quoted line shows. `ungrounded`: the checker answered yes but its quote is not in the ledger, so the answer is discarded the way an ungrounded diary quote is and recorded in the gate ledger. `skipped`: no marker matched, or the check did not run. A classification: a later verdict is additive.
- **`checked_by`**: `string | null`

  Schema: `CheckedBy`. Known values, open (more may be added): `judge`, `code`, `manual`.

  Which instrument answered: `judge` for the observer's checker model call under its own instructions, `code` for a mechanical rule, `manual` for a human. A classification.
- **`quote`**: `string | null`
- **`quote_step`**: [QuoteStep](#scalar-schema-quotestep) | `null`

  The step whose ledger block the quote came from.
- **`reason`**: `string | null`

<a id="scalar-example-64"></a>

**Generated example:**

```json
{
  "verdict": "supported",
  "quote": "",
  "quote_step": {
    "session_id": "",
    "step_id": 1
  },
  "reason": "",
  "checked_by": "judge"
}
```

<a id="scalar-schema-checkedby"></a>

### CheckedBy

**Type:** `string`

Known values, open (more may be added): `judge`, `code`, `manual`.

Which instrument answered: `judge` for the observer's checker model call under its own instructions, `code` for a mechanical rule, `manual` for a human. A classification.

**Example:**

```json
"judge"
```

**Example:**

```json
"code"
```

**Example:**

```json
"manual"
```

<a id="scalar-schema-claim"></a>

### Claim

**Type:** `object`, no additional properties

What this issue asserts, and what checked it. A replay matching a failure marker at a step does not say the marker was the thing the title is about, and in practice it often is not; since 0.5 that match is `verification.status: marker_matched`, and `verified` is reserved for a claim a second check (`check`) found supported by a verbatim quote from the cited steps and whose failure is the product's (`attribution`). An issue with no `claim` block, or with none of `verification`, is `unverified`: never read the absence of this object as a confirmation.

- **`action_step`**: [ActionStep](#scalar-schema-actionstep) | `null`

  The step the claim is about and the replay is judged at - the action that failed, not the look that saw it or the retry after it. Written by reproduction, which picks it (`_judged_path`); absent when nothing replayed this issue.
- **`attribution`**: `string | null`

  Schema: `Attribution`. Known values, open (more may be added): `product`, `tester`, `undetermined`.

  Whose failure this is, asked before an issue is a product finding: could the persona's own action explain it? `product`: no - the failure is the product's to own. `tester`: yes - a mistyped credential, the wrong element, placeholder text read as a value; the record moves to `tester_errors[]` with a `tester_error` block, is never a ticket and never counted as an issue. `undetermined`: the harness could not tell (a persona handed stale credentials by the mission author typed exactly what it was told; an attribution call that failed), and a human must; it caps `status` at `marker_matched`. Absent means never asked - every issue before this release - and a reader treats absent as `undetermined`. A classification: nothing acts on it directly, the producer derives `status` from it, so a later member (`setup`) is additive and an older reader buckets it as `unknown`.
- **`check`**: [Check](#scalar-schema-check) | `null`

  The claim check: one narrow question asked of the evidence at the cited steps after a replay matched a marker - does this evidence show the product doing what the title says? Descriptive: nothing acts on it directly; the producer derives `status` from `verdict` and `attribution`. Absent when the check never ran, which is every issue before this release and every issue no marker matched.
- **`expected`**: `string | null`
- **`observed`**: `string | null`
- **`verification`**: [Verification](#scalar-schema-verification) | `null`

  Whether the claim was checked, and by what. The two are separate questions and were conflated once already: `status` is the answer a reader acts on, `method` only says which instrument produced it. Consumers show `status`.

<a id="scalar-example-65"></a>

**Generated example:**

```json
{
  "action_step": {
    "session_id": "",
    "step_id": 1
  },
  "expected": "",
  "observed": "",
  "verification": {
    "status": "verified",
    "method": "marker",
    "markers": [
      ""
    ],
    "note": ""
  },
  "attribution": "product",
  "check": {
    "verdict": "supported",
    "quote": "",
    "quote_step": {
      "session_id": "",
      "step_id": 1
    },
    "reason": "",
    "checked_by": "judge"
  }
}
```

<a id="scalar-schema-codexdisconnected"></a>

### CodexDisconnected

**Type:** `object`, no additional properties

A ChatGPT login disconnected: this caller's `state` is now `none`.

- **`caller` (required)**: `string`
- **`state` (required)**: `string`, possible values: `"none", "pending", "connected", "expired", "error"`

<a id="scalar-example-66"></a>

**Generated example:**

```json
{
  "caller": "",
  "state": "none"
}
```

<a id="scalar-schema-codexgateway"></a>

### CodexGateway

**Type:** `object`, no additional properties

What the ChatGPT gateway reports about this caller's slot: whether it can serve, and the consumer rate
limit's state from the last `x-codex-*` headers it saw. Never a token and never a routing key.

- **`last_error`**: `string | null`
- **`login`**: `string | null`
- **`primary_reset_at`**: `string | null`, format: `date-time`
- **`primary_used_percent`**: `number | null`

<a id="scalar-example-67"></a>

**Generated example:**

```json
{
  "login": "",
  "primary_used_percent": 1,
  "primary_reset_at": "",
  "last_error": ""
}
```

<a id="scalar-schema-codexloginstarted"></a>

### CodexLoginStarted

**Type:** `object`, no additional properties

`POST /api/v1/providers/codex/login`: open `verification_url`, type `user_code`, then poll `status_url`.

- **`caller` (required)**: `string`
- **`expires_in` (required)**: `integer`
- **`status_url` (required)**: `string`
- **`user_code` (required)**: `string`
- **`verification_url` (required)**: `string`

<a id="scalar-example-68"></a>

**Generated example:**

```json
{
  "caller": "",
  "user_code": "",
  "verification_url": "",
  "expires_in": 1,
  "status_url": ""
}
```

<a id="scalar-schema-codexpending"></a>

### CodexPending

**Type:** `object`, no additional properties

A device login in flight. The `device_auth_id` that redeems it is not here and never leaves the pod.

- **`expires_at` (required)**: `string`, format: `date-time`
- **`user_code` (required)**: `string`
- **`verification_url` (required)**: `string`

<a id="scalar-example-69"></a>

**Generated example:**

```json
{
  "user_code": "",
  "verification_url": "",
  "expires_at": ""
}
```

<a id="scalar-schema-codexproviders"></a>

### CodexProviders

**Type:** `object`, no additional properties

The `providers` block to paste into a run spec, secret name included, so the caller never has to type
one. The ref is the routing key's name, never its value.

- **`entries` (required)**: `object`

  **Additional properties:**

  [CodexProvidersEntry](#scalar-schema-codexprovidersentry)

  One `providers` entry of a run spec, routed through this caller's ChatGPT login.
- **`roles` (required)**: `object`

  **Additional properties:**

  `string`

<a id="scalar-example-70"></a>

**Generated example:**

```json
{
  "entries": {
    "additionalProperty": {
      "model": "",
      "base_url": "",
      "credential_ref": ""
    }
  },
  "roles": {
    "additionalProperty": ""
  }
}
```

<a id="scalar-schema-codexprovidersentry"></a>

### CodexProvidersEntry

**Type:** `object`, no additional properties

One `providers` entry of a run spec, routed through this caller's ChatGPT login.

- **`base_url` (required)**: `string`
- **`credential_ref` (required)**: `string`
- **`model` (required)**: `string`

<a id="scalar-example-71"></a>

**Generated example:**

```json
{
  "model": "",
  "base_url": "",
  "credential_ref": ""
}
```

<a id="scalar-schema-codexstatus"></a>

### CodexStatus

**Type:** `object`, no additional properties

`GET /api/v1/providers/codex`. Metadata about a login; never any part of one.

- **`caller` (required)**: `string`
- **`state` (required)**: `string`, possible values: `"none", "pending", "connected", "expired", "error"`
- **`account_id`**: `string | null`
- **`connected_at`**: `string | null`, format: `date-time`
- **`error`**: `string | null`

  Why the last login attempt failed, when `state` is `error` and no gateway call was involved. The OAuth
  `error_description`, which cannot carry a token.
- **`expires_at`**: `string | null`, format: `date-time`
- **`gateway`**: [CodexGateway](#scalar-schema-codexgateway) | `null`

  What the ChatGPT gateway reports about this caller's slot: whether it can serve, and the consumer rate
  limit's state from the last `x-codex-*` headers it saw. Never a token and never a routing key.
- **`last_refresh`**: `string | null`, format: `date-time`
- **`pending`**: [CodexPending](#scalar-schema-codexpending) | `null`

  A device login in flight. The `device_auth_id` that redeems it is not here and never leaves the pod.
- **`plan_type`**: `string | null`
- **`providers`**: [CodexProviders](#scalar-schema-codexproviders) | `null`

  The `providers` block to paste into a run spec, secret name included, so the caller never has to type
  one. The ref is the routing key's name, never its value.

<a id="scalar-example-72"></a>

**Generated example:**

```json
{
  "caller": "",
  "state": "none",
  "pending": {
    "user_code": "",
    "verification_url": "",
    "expires_at": ""
  },
  "account_id": "",
  "plan_type": "",
  "connected_at": "",
  "expires_at": "",
  "last_refresh": "",
  "error": "",
  "gateway": {
    "login": "",
    "primary_used_percent": 1,
    "primary_reset_at": "",
    "last_error": ""
  },
  "providers": {
    "entries": {
      "additionalProperty": {
        "model": "",
        "base_url": "",
        "credential_ref": ""
      }
    },
    "roles": {
      "additionalProperty": ""
    }
  }
}
```

<a id="scalar-schema-constraints"></a>

### Constraints

**Type:** `object`, no additional properties

The browser the persona uses: the kind of device, the screen size, the connection, the language, accessibility needs and the signed-in state they start in. Most of these set up the browser session rather than the persona's briefing. A replay of an issue runs with the same ones, and the issue lists them in `preconditions`.

- **`device` (required)**: `string`, possible values: `"desktop", "mobile", "tablet"`

  Schema: `Device`.

  The kind of device the browser presents as. `mobile` and `tablet` open a mobile, touch-enabled browser; `desktop` a desktop one. The screen size is `viewport`, set separately.
- **`viewport` (required)**: `string`, pattern: `^[0-9]+x[0-9]+$`
- **`accessibility`**: `array | null`

  **Items:**

  `string`, possible values: `"screen_reader", "keyboard_only", "low_vision", "reduced_motion", "color_blind"`

  Schema: `AccessibilityEnum`.

  One accessibility need the persona has.
- **`auth_state`**: `string | null`, possible values: `"anonymous", "new_user", "returning_user", "admin"`, default: `"anonymous"`

  Schema: `AuthState`.

  The kind of visitor the persona arrives as: `anonymous` (the default), a `new_user`, a `returning_user` or an `admin`. Descriptive, and listed in each issue's `preconditions`; it signs nobody in. A session is signed in by the account it is handed from the run spec's `target.seed.accounts`.
- **`locale`**: `string | null`, default: `"en-US"`
- **`network`**: `string | null`, possible values: `"fast", "regular_4g", "slow_3g", "offline_flaky"`, default: `"fast"`

  Schema: `Network`.

  The connection the browser runs on. `fast` (the default): not throttled. `regular_4g` and `slow_3g`: bandwidth limited and latency added, like those mobile connections. `offline_flaky` is not supported yet, and a session that asks for it does not start.

<a id="scalar-example-73"></a>

**Generated example:**

```json
{
  "device": "desktop",
  "viewport": "",
  "network": "fast",
  "accessibility": [
    "screen_reader"
  ],
  "locale": "en-US",
  "auth_state": "anonymous"
}
```

<a id="scalar-schema-contradiction"></a>

### Contradiction

**Type:** `object`, no additional properties

Populated for type=contradiction: the two statements/surfaces that conflict, each pinned to a step.

- **`a`**: [A](#scalar-schema-a) | `null`

  The first of the two statements that conflict: what the product said (`text`), and the session and step where it said it.
- **`b`**: [B](#scalar-schema-b) | `null`

  The second of the two statements that conflict: what the product said (`text`), and the session and step where it said it.

<a id="scalar-example-74"></a>

**Generated example:**

```json
{
  "a": {
    "step_id": 1,
    "session_id": "",
    "text": ""
  },
  "b": {
    "step_id": 1,
    "session_id": "",
    "text": ""
  }
}
```

<a id="scalar-schema-conversation"></a>

### Conversation

**Type:** `object`, no additional properties

How the persona talks to an in-product assistant or chat surface. Matters when the target is itself an AI product.

- **`asks_before_hunting`**: `boolean | null`
- **`phrasing`**: `string | null`, possible values: `"plain_speech", "commands", "mixed"`

  Schema: `Phrasing`.

  Asks like they would ask a person, vs. writes instructions
- **`pushes_back_on_generic`**: `boolean | null`

<a id="scalar-example-75"></a>

**Generated example:**

```json
{
  "phrasing": "plain_speech",
  "asks_before_hunting": true,
  "pushes_back_on_generic": true
}
```

<a id="scalar-schema-costceiling"></a>

### CostCeiling

**Type:** `object`, no additional properties

What this run would cost: the most it can bill on your plan, as a number and the arithmetic behind it.

A ceiling, not a forecast. Given on every plan and whatever the run gate is doing, because "what would it
cost?" has an answer on every plan: the most Usage the spec's own budgets allow, priced the way your plan
bills.

- **`basis` (required)**: `string`

  One line saying how usd was reached: the sessions, the steps, the price, and what is included. Quote it with usd.
- **`billed_in` (required)**: `string`, possible values: `"usage", "steps", "nothing"`

  What your plan bills: usage (prepaid Usage, the Free and Pro plans), steps (persona steps, the older per-step plans), or nothing.
- **`model_calls` (required)**: `string`

  Who bills the run's model calls, per model role: your own provider key (billed to you by that provider), your ChatGPT login (within that subscription), or Saram's credential (not billed separately). Never part of usd.
- **`plan` (required)**: `string`

  The plan the figure is priced on.
- **`sessions` (required)**: `integer`

  Sessions the spec plans, one per persona per mission.
- **`steps_per_session` (required)**: `integer`
- **`usage` (required)**: `number`

  The run's Usage ceiling, sessions x usage\_per\_session, whatever your plan bills in.
- **`usage_per_session` (required)**: `number`

  The most Usage one session can use.
- **`usd` (required)**: `number`

  The most Saram can bill for this run on your plan, in US dollars: a ceiling, since every step is counted at the most it can be charged. A normal run bills well under it (a typical 5-second step is about 1 Usage, against the 3.5 a step can reach). 0 when your plan bills nothing. Model calls on your own provider are never in it: model\_calls says who bills those.
- **`price_usd`**: `number | null`

  Dollars per unit of billed\_in: per Usage, or per persona step. Absent when billed\_in is nothing.
- **`wall_clock_s_per_session`**: `integer | null`

  The spec's own wall-clock budget, which bounds the seconds a session's steps are charged for. Absent when it
  names none.

<a id="scalar-example-76"></a>

**Generated example:**

```json
{
  "usd": 1,
  "billed_in": "usage",
  "plan": "",
  "price_usd": 1,
  "sessions": 1,
  "steps_per_session": 1,
  "wall_clock_s_per_session": 1,
  "usage_per_session": 1,
  "usage": 1,
  "basis": "",
  "model_calls": ""
}
```

<a id="scalar-schema-credentiallist"></a>

### CredentialList

**Type:** `object`, no additional properties

The tenant's own credentials, as metadata: never a value, which the API stores and cannot read back.

- **`credentials` (required)**: array of [CredentialRecord](#scalar-schema-credentialrecord)

  This tenant's live credentials, oldest first. A deleted one is not listed.

<a id="scalar-example-77"></a>

**Generated example:**

```json
{
  "credentials": [
    {
      "id": "",
      "name": "",
      "kind": "",
      "secret_ref": "",
      "created_at": "",
      "deleted_at": ""
    }
  ]
}
```

<a id="scalar-schema-credentialrecord"></a>

### CredentialRecord

**Type:** `object`, no additional properties

One of this tenant's credentials, a provider key or a test account, as all three routes answer it.
Metadata only.

- **`created_at` (required)**: `string`, format: `date-time`
- **`id` (required)**: `string`
- **`kind` (required)**: `string`
- **`name` (required)**: `string`
- **`secret_ref` (required)**: `string`

  The awssm:// ref to paste into a run spec as providers.entries\[].credential\_ref, for an account into target.seed.accounts, as connectors.webhook.secret\_ref for a webhook key, or as connectors.github.token\_ref for a github token. Only this tenant's specs may name it.
- **`deleted_at`**: `string | null`, format: `date-time`

  Set on the record `DELETE` answers with, absent on a live one: the secret is gone and the name is free.

<a id="scalar-example-78"></a>

**Generated example:**

```json
{
  "id": "",
  "name": "",
  "kind": "",
  "secret_ref": "",
  "created_at": "",
  "deleted_at": ""
}
```

<a id="scalar-schema-credentialwrite"></a>

### CredentialWrite

**Type:** `object`, no additional properties

`POST /api/v1/credentials`: the one request body in this API that carries a secret.

The API stores `value` once and never answers it back: no response anywhere has a `value` field. To change a
credential, delete it and write it again.

- **`kind` (required)**: `string`

  Which provider this key is for, as the model string's prefix names it: anthropic, bedrock, openai, openrouter, azure, google, google-cloud, litellm. A run spec entry naming this credential must be on the same kind. Or account: a test-account login for a persona, named in a run spec's target.seed.accounts (or target.auth.secret\_ref) instead. Or webhook: the key a run spec's connectors.webhook is signed with, named there as secret\_ref. Or github: a token that may write issues on one repository, named as connectors.github.token\_ref and used only where the Saram GitHub App is not installed on that repository.
- **`name` (required)**: `string`

  What to call this credential: lowercase letters, digits, underscore and dash, up to 64 characters. The last segment of the secret's name and part of the ref you will paste into a run spec, so pick something a person can read - anthropic, openai-judge.
- **`value` (required)**: `string`, format: `password`, writeOnly

  The provider key itself. Written to Secrets Manager and never returned, logged or echoed; for google-cloud it is the service-account JSON document, and for account it is one secret-store entry as a JSON object (email, password, inbox, notes, cookies, basic, headers).

<a id="scalar-example-79"></a>

**Generated example:**

```json
{
  "name": "",
  "kind": "",
  "value": ""
}
```

<a id="scalar-schema-decidedby"></a>

### DecidedBy

**Type:** `string`

Known values, open (more may be added): `code`, `judge`.

`code`: a mechanical comparison decided it (the credential check). `judge`: the observer's attribution model call under its own instructions decided it. A classification.

**Example:**

```json
"code"
```

**Example:**

```json
"judge"
```

<a id="scalar-schema-decision"></a>

### Decision

**Type:** `string`, possible values: `"positive", "negative", "undecided"`

Would keep paying / would recommend / would text 'it works' = positive

<a id="scalar-schema-device"></a>

### Device

**Type:** `string`, possible values: `"desktop", "mobile", "tablet"`

The kind of device the browser presents as. `mobile` and `tablet` open a mobile, touch-enabled browser; `desktop` a desktop one. The screen size is `viewport`, set separately.

<a id="scalar-schema-displaysplit"></a>

### DisplaySplit

**Type:** `object`, no additional properties

The cohort floors, and which of the run's opportunities clear them.

The split is the whole run's, so every surface - here, on GitHub, in the MCP answer - leads with the same
record. Nothing is dropped: `folded` is a single-persona note, one click away.

- **`folded` (required)**: `array of string`
- **`min_affected_sessions` (required)**: `integer`
- **`min_consensus` (required)**: `number`
- **`note` (required)**: `string`
- **`shown` (required)**: `array of string`
- **`strong_consensus` (required)**: `number`

<a id="scalar-example-80"></a>

**Generated example:**

```json
{
  "note": "",
  "min_consensus": 1,
  "min_affected_sessions": 1,
  "strong_consensus": 1,
  "shown": [
    ""
  ],
  "folded": [
    ""
  ]
}
```

<a id="scalar-schema-domainliteracy"></a>

### DomainLiteracy

**Type:** `string`, possible values: `"low", "medium", "high"`

How fluent the persona is in the language of the product's industry. `low`: does not know its terms and does not pretend to. `medium`: knows enough to follow along. `high`: fluent, and notices when a term is used loosely.

<a id="scalar-schema-errorresponse"></a>

### ErrorResponse

**Type:** `string`, possible values: `"retry_same", "retry_different", "back_out", "abandon"`

First reaction to an error

<a id="scalar-schema-evidence"></a>

### Evidence

**Type:** `object`, no additional properties

What the feedback rests on: the steps where personas raised it (`step_refs`), lines from their diaries at those steps, each checked to appear there verbatim (`quotes`), and screenshots of what they were looking at.

- **`step_refs` (required)**: array of [StepRef](#scalar-schema-stepref), minItems: `1`
- **`quotes`**: `(array of string) | null`
- **`screenshots`**: `array | null`

  **Items:**

  `string`, format: `uri`, minLength: `1`

<a id="scalar-example-81"></a>

**Generated example:**

```json
{
  "step_refs": [
    {
      "session_id": "",
      "step_id": 1
    }
  ],
  "quotes": [
    ""
  ],
  "screenshots": [
    ""
  ]
}
```

<a id="scalar-schema-evidence1"></a>

### Evidence1

**Type:** `object`, no additional properties

What the issue rests on: the steps where sessions met it (`step_refs`) and, when the run kept them, screenshots, a clip of the session's video, a snapshot of the page, and the network requests and console messages around it.

- **`step_refs` (required)**: array of [StepRef](#scalar-schema-stepref), minItems: `1`
- **`console`**: `(array of object) | null`
- **`dom_snapshot`**: `string | null`, format: `uri`, minLength: `1`
- **`network`**: `(array of object) | null`
- **`screenshots`**: `array | null`

  **Items:**

  `string`, format: `uri`, minLength: `1`
- **`video`**: [Video](#scalar-schema-video) | `null`

  A clip of a session's video that shows the issue: where the video is stored (`ref`), and where the clip starts and ends, in seconds from the start of the recording.

<a id="scalar-example-82"></a>

**Generated example:**

```json
{
  "step_refs": [
    {
      "session_id": "",
      "step_id": 1
    }
  ],
  "screenshots": [
    ""
  ],
  "video": {
    "ref": "",
    "ts_start_s": 1,
    "ts_end_s": 1
  },
  "dom_snapshot": "",
  "network": [
    {
      "additionalProperty": "anything"
    }
  ],
  "console": [
    {
      "additionalProperty": "anything"
    }
  ]
}
```

<a id="scalar-schema-evidencerefs"></a>

### EvidenceRefs

**Type:** `object`, no additional properties

The store URIs on one issue, gathered where a consumer that wants to *fetch* evidence looks for them.

`screenshots` are what the MCP `get_screenshot` tool accepts. The other two are not - it serves images
only - and are fetched over `GET /api/v1/blobs?ref=` with the same key, which is why they are named here
rather than left for a caller to dig out of `issue.evidence`.

- **`dom_snapshot`**: `string | null`
- **`screenshots`**: `array of string`
- **`video`**: `string | null`

<a id="scalar-example-83"></a>

**Generated example:**

```json
{
  "screenshots": [
    ""
  ],
  "dom_snapshot": "",
  "video": ""
}
```

<a id="scalar-schema-exploration"></a>

### Exploration

**Type:** `string`, possible values: `"explores", "beeline"`

Wanders vs. heads straight for the goal

<a id="scalar-schema-filedeleted"></a>

### FileDeleted

**Type:** `object`, no additional properties

An uploaded file deleted: a run started from now on that names `ref` is refused.

- **`ref` (required)**: `string`
- **`deleted`**: `boolean`, default: `true`

<a id="scalar-example-84"></a>

**Generated example:**

```json
{
  "ref": "",
  "deleted": true
}
```

<a id="scalar-schema-filelist"></a>

### FileList

**Type:** `object`, no additional properties

The files this tenant has uploaded and not deleted, as metadata.

- **`files` (required)**: array of [FileRecord](#scalar-schema-filerecord)

  This tenant's files, newest first.

<a id="scalar-example-85"></a>

**Generated example:**

```json
{
  "files": [
    {
      "ref": "",
      "name": "",
      "size_bytes": 1,
      "media_type": "",
      "sha256": "",
      "uploaded_at": "",
      "expires_at": ""
    }
  ]
}
```

<a id="scalar-schema-filerecord"></a>

### FileRecord

**Type:** `object`, no additional properties

One file this tenant uploaded, as `POST /api/v1/files` and the two read routes answer it.

Metadata only, and deliberately: the API never serves an uploaded file's bytes back. Nothing needs them -
the dashboard shows names - and `GET /api/v1/blobs` stays evidence-only, which is what keeps a `files/`
key unreachable there. The tenant is not a field of its own: it is the ref's first segment, and it is
always the tenant of whoever asked.

- **`expires_at` (required)**: `string`

  When the file is deleted: its upload time plus how long this deployment keeps uploads. A run naming a file
  past this is refused at `POST /runs`.
- **`media_type` (required)**: `string`
- **`name` (required)**: `string`
- **`ref` (required)**: `string`

  `saram-file://<tenant_id>/<file_id>`: what a run spec's `files[].ref` names, and the only handle a caller
  ever gets on the object. A file uploaded before files were a tenant's keeps the caller name it was uploaded
  under in that segment.
- **`sha256` (required)**: `string`
- **`size_bytes` (required)**: `integer`
- **`uploaded_at` (required)**: `string`

<a id="scalar-example-86"></a>

**Generated example:**

```json
{
  "ref": "",
  "name": "",
  "size_bytes": 1,
  "media_type": "",
  "sha256": "",
  "uploaded_at": "",
  "expires_at": ""
}
```

<a id="scalar-schema-githubinstallationlist"></a>

### GitHubInstallationList

**Type:** `object`, no additional properties

The Saram GitHub App's installations linked to this tenant.

- **`installations` (required)**: array of [GitHubInstallationRecord](#scalar-schema-githubinstallationrecord)

  By account. Empty until somebody links one: the App then writes for this tenant nowhere.

<a id="scalar-example-87"></a>

**Generated example:**

```json
{
  "installations": [
    {
      "installation_id": 1,
      "account": "",
      "linked_by": "",
      "linked_at": ""
    }
  ]
}
```

<a id="scalar-schema-githubinstallationrecord"></a>

### GitHubInstallationRecord

**Type:** `object`, no additional properties

One installation of the Saram GitHub App linked to this tenant: the App writes there for this tenant only.

- **`account` (required)**: `string`

  The GitHub user or organisation the App is installed on, lowercased: the owner half of a run spec's
  `connectors.github.repo` that this installation writes to.
- **`installation_id` (required)**: `integer`

  GitHub's id for the installation.
- **`linked_at` (required)**: `string`, format: `date-time`
- **`linked_by` (required)**: `string`

  Who linked it: the person and the GitHub user who came back from installing it, or Saram's operator.

<a id="scalar-example-88"></a>

**Generated example:**

```json
{
  "installation_id": 1,
  "account": "",
  "linked_by": "",
  "linked_at": ""
}
```

<a id="scalar-schema-goals"></a>

### Goals

**Type:** `object`, no additional properties

What the persona wants out of the visit, in their own terms: the `primary` goal, anything `secondary` they also want if they get that far, and how much they want it (`intensity`). All three go into the persona's briefing beside the mission's goal; what counts as success for the run is the mission's `success_criteria`.

- **`intensity` (required)**: `string`, possible values: `"low", "medium", "high"`

  Schema: `Intensity`.

  How much they want it; interacts with tolerance
- **`primary` (required)**: `string`
- **`secondary`**: `(array of string) | null`

<a id="scalar-example-89"></a>

**Generated example:**

```json
{
  "primary": "",
  "secondary": [
    ""
  ],
  "intensity": "low"
}
```

<a id="scalar-schema-httpvalidationerror"></a>

### HTTPValidationError

**Type:** `object`

- **`detail`**: array of [ValidationError](#scalar-schema-validationerror)

<a id="scalar-example-90"></a>

**Generated example:**

```json
{
  "detail": [
    {
      "loc": [
        ""
      ],
      "msg": "",
      "type": "",
      "input": null,
      "ctx": {}
    }
  ]
}
```

<a id="scalar-schema-helpseeking"></a>

### HelpSeeking

**Type:** `string`, possible values: `"never", "after_error", "eager"`

Uses tooltips, help links, search

<a id="scalar-schema-input"></a>

### Input

**Type:** `string`, possible values: `"mouse", "keyboard", "touch"`

What the persona works the page with. `mouse`: a computer with a mouse and a keyboard. `keyboard`: tabs between fields and presses Enter rather than reach for the mouse. `touch`: a phone, tapping and swiping. This shapes the persona's habits; whether the browser itself is a touch device is `constraints.device`.

<a id="scalar-schema-intensity"></a>

### Intensity

**Type:** `string`, possible values: `"low", "medium", "high"`

How much they want it; interacts with tolerance

<a id="scalar-schema-issue"></a>

### Issue

**Type:** `object`, no additional properties

A product defect the run found: what the product did wrong, where, in how many of the sessions that got there, with the evidence and the steps that reproduce it. `claim.verification.status` says what checked it, and only a `verified` issue - `reproducible: true` - may be filed as a ticket on its own. `description` is objective; `persona_rationale` is the persona's voice.

- **`affected_sessions` (required)**: `array of string`
- **`confidence` (required)**: `number`, minimum: `0`, maximum: `1`
- **`description` (required)**: `string`
- **`evidence` (required)**: [Evidence1](#scalar-schema-evidence1)

  What the issue rests on: the steps where sessions met it (`step_refs`) and, when the run kept them, screenshots, a clip of the session's video, a snapshot of the page, and the network requests and console messages around it.
- **`id` (required)**: `string`
- **`location` (required)**: [Location1](#scalar-schema-location1)

  Where the defect is: the page (`route`), the element in words (`element_description`) or as a selector (`selector`), and the screen size it was seen at (`viewport`, WIDTHxHEIGHT).
- **`reproducible` (required)**: `boolean`
- **`severity` (required)**: `string`, possible values: `"low", "medium", "high", "critical"`

  Schema: `Severity`.

  How much the defect costs the people who meet it, from least to most: `low`, `medium`, `high`, `critical`. Rated by the judge from the sessions, and lowered one step when the replay did not reproduce the issue and it stands as persona-specific friction. The GitHub connector files only issues at or above its `min_severity_for_issue`.
- **`type` (required)**: `string`

  Schema: `Type`. Known values, open (more may be added): `blocker`, `friction`, `confusion`, `bug`, `contradiction`.

  Defect classes only. A moment that worked well is an opportunity of kind `delight`, never an issue: it has no failure for reproduction to replay.
- **`affected_count`**: `integer | null`
- **`affected_fraction`**: `number | null`, minimum: `0`, maximum: `1`
- **`affected_personas`**: `(array of string) | null`
- **`baseline_status`**: `string | null`

  Schema: `BaselineStatus`. Known values, open (more may be added): `new`, `persisting`, `resolved`, `unknown`.

  How this finding compares with the run named as the baseline. `new`: the baseline reached the same page and did not have it (for an issue, only one that is `reproducible`). `persisting`: the baseline has it too. `unknown`: nothing could say - no baseline was compared, the baseline never reached the page, or an issue the baseline does not have was not verified. `resolved` is never stamped on a finding of this run: a baseline issue this run no longer has is counted in `summary.issues_resolved_vs_baseline`. More values may be added; read one you do not know as `unknown`.
- **`claim`**: [Claim](#scalar-schema-claim) | `null`

  What this issue asserts, and what checked it. A replay matching a failure marker at a step does not say the marker was the thing the title is about, and in practice it often is not; since 0.5 that match is `verification.status: marker_matched`, and `verified` is reserved for a claim a second check (`check`) found supported by a verbatim quote from the cited steps and whose failure is the product's (`attribution`). An issue with no `claim` block, or with none of `verification`, is `unverified`: never read the absence of this object as a confirmation.
- **`connector_refs`**: `object | null`

  **Additional properties:**

  `string`
- **`contradiction`**: [Contradiction](#scalar-schema-contradiction) | `null`

  Populated for type=contradiction: the two statements/surfaces that conflict, each pinned to a step.
- **`first_seen_step`**: `integer | null`
- **`persona_rationale`**: `string | null`
- **`persona_specific`**: `boolean | null`
- **`preconditions`**: `(array of string) | null`
- **`reached_sessions`**: `(array of string) | null`
- **`repro_steps`**: array of [ReproStep](#scalar-schema-reprostep) | `null`
- **`reproduction`**: [Reproduction1](#scalar-schema-reproduction1) | `null`

  Whether Saram replayed the issue without the persona, and what the replay showed: whether one was `attempted`, its `result`, how many `attempts` it took, and `notes` on it. A replay that saw the failure again is not on its own enough to file a ticket; `reproducible` says whether this issue may be.
- **`suggested_owner`**: `string | null`
- **`tester_error`**: [TesterError](#scalar-schema-testererror) | `null`

  What the persona did that explains this failure. Present exactly when `claim.attribution` is `tester`, and such an issue is in `tester_errors[]`, never in `issues[]`. The quote is the evidence line that shows the persona's action - the input echo, the step head with its args, a diary line, a browser line - copied exactly from the ledger and gated the same way a diary quote is; a `tester` answer whose quote does not gate falls back to `undetermined` and this block is not written.
- **`title`**: `string | null`, maxLength: `120`

<a id="scalar-example-91"></a>

**Generated example:**

```json
{
  "id": "",
  "type": "blocker",
  "severity": "low",
  "reproducible": true,
  "reproduction": {
    "attempted": true,
    "result": "reproduced",
    "attempts": 1,
    "notes": ""
  },
  "persona_specific": true,
  "claim": {
    "action_step": {
      "session_id": "",
      "step_id": 1
    },
    "expected": "",
    "observed": "",
    "verification": {
      "status": "verified",
      "method": "marker",
      "markers": [
        ""
      ],
      "note": ""
    },
    "attribution": "product",
    "check": {
      "verdict": "supported",
      "quote": "",
      "quote_step": {
        "session_id": "",
        "step_id": 1
      },
      "reason": "",
      "checked_by": "judge"
    }
  },
  "tester_error": {
    "kind": "mistyped_credential",
    "quote": "",
    "quote_step": {
      "session_id": "",
      "step_id": 1
    },
    "decided_by": "code",
    "reason": ""
  },
  "affected_sessions": [
    ""
  ],
  "affected_personas": [
    ""
  ],
  "affected_count": 1,
  "affected_fraction": 0,
  "reached_sessions": [
    ""
  ],
  "first_seen_step": 1,
  "location": {
    "route": "",
    "selector": "",
    "element_description": "",
    "viewport": ""
  },
  "title": "",
  "description": "",
  "persona_rationale": "",
  "contradiction": {
    "a": {
      "step_id": 1,
      "session_id": "",
      "text": ""
    },
    "b": {
      "step_id": 1,
      "session_id": "",
      "text": ""
    }
  },
  "evidence": {
    "step_refs": [
      {
        "session_id": "",
        "step_id": 1
      }
    ],
    "screenshots": [
      ""
    ],
    "video": {
      "ref": "",
      "ts_start_s": 1,
      "ts_end_s": 1
    },
    "dom_snapshot": "",
    "network": [
      {
        "additionalProperty": "anything"
      }
    ],
    "console": [
      {
        "additionalProperty": "anything"
      }
    ]
  },
  "repro_steps": [
    {
      "action": "",
      "target": "",
      "value": "",
      "replace": true,
      "expect": ""
    }
  ],
  "preconditions": [
    ""
  ],
  "baseline_status": "new",
  "confidence": 0,
  "suggested_owner": "",
  "connector_refs": {
    "additionalProperty": ""
  }
}
```

<a id="scalar-schema-issuedetail"></a>

### IssueDetail

**Type:** `object`, no additional properties

One issue with its reading and its evidence refs beside it: `GET /runs/{id}/issues/{issue_id}` and the MCP
`get_issue` answer.

`issue` is the contract object verbatim and `reading` is the entry `IssueList.reading[]` carries for the same
issue, computed by the same function, so a surface that shows one issue - an issue page, or an agent that
calls `get_issue` first - reads whether it may be filed alone, and how to read its persona-voiced fields,
without a second call and cannot disagree with the list about them.

- **`evidence_refs` (required)**: [EvidenceRefs](#scalar-schema-evidencerefs)

  The store URIs on one issue, gathered where a consumer that wants to *fetch* evidence looks for them.

  `screenshots` are what the MCP `get_screenshot` tool accepts. The other two are not - it serves images
  only - and are fetched over `GET /api/v1/blobs?ref=` with the same key, which is why they are named here
  rather than left for a caller to dig out of `issue.evidence`.
- **`issue` (required)**: [Issue](#scalar-schema-issue)

  A product defect the run found: what the product did wrong, where, in how many of the sessions that got there, with the evidence and the steps that reproduce it. `claim.verification.status` says what checked it, and only a `verified` issue - `reproducible: true` - may be filed as a ticket on its own. `description` is objective; `persona_rationale` is the persona's voice.
- **`reading` (required)**: [IssueReading](#scalar-schema-issuereading)

  How one issue of `IssueList.issues` may be acted on: whether it may be filed as a ticket on its own, and
  which of its fields are the persona's voice rather than observed behaviour.
- **`run_id` (required)**: `string`

<a id="scalar-example-92"></a>

**Generated example:**

```json
{
  "run_id": "",
  "issue": {
    "id": "",
    "type": "blocker",
    "severity": "low",
    "reproducible": true,
    "reproduction": {
      "attempted": true,
      "result": "reproduced",
      "attempts": 1,
      "notes": ""
    },
    "persona_specific": true,
    "claim": {
      "action_step": {
        "session_id": "",
        "step_id": 1
      },
      "expected": "",
      "observed": "",
      "verification": {
        "status": "verified",
        "method": "marker",
        "markers": [
          ""
        ],
        "note": ""
      },
      "attribution": "product",
      "check": {
        "verdict": "supported",
        "quote": "",
        "quote_step": {
          "session_id": "",
          "step_id": 1
        },
        "reason": "",
        "checked_by": "judge"
      }
    },
    "tester_error": {
      "kind": "mistyped_credential",
      "quote": "",
      "quote_step": {
        "session_id": "",
        "step_id": 1
      },
      "decided_by": "code",
      "reason": ""
    },
    "affected_sessions": [
      ""
    ],
    "affected_personas": [
      ""
    ],
    "affected_count": 1,
    "affected_fraction": 0,
    "reached_sessions": [
      ""
    ],
    "first_seen_step": 1,
    "location": {
      "route": "",
      "selector": "",
      "element_description": "",
      "viewport": ""
    },
    "title": "",
    "description": "",
    "persona_rationale": "",
    "contradiction": {
      "a": {
        "step_id": 1,
        "session_id": "",
        "text": ""
      },
      "b": {
        "step_id": 1,
        "session_id": "",
        "text": ""
      }
    },
    "evidence": {
      "step_refs": [
        {
          "session_id": "",
          "step_id": 1
        }
      ],
      "screenshots": [
        ""
      ],
      "video": {
        "ref": "",
        "ts_start_s": 1,
        "ts_end_s": 1
      },
      "dom_snapshot": "",
      "network": [
        {
          "additionalProperty": "anything"
        }
      ],
      "console": [
        {
          "additionalProperty": "anything"
        }
      ]
    },
    "repro_steps": [
      {
        "action": "",
        "target": "",
        "value": "",
        "replace": true,
        "expect": ""
      }
    ],
    "preconditions": [
      ""
    ],
    "baseline_status": "new",
    "confidence": 0,
    "suggested_owner": "",
    "connector_refs": {
      "additionalProperty": ""
    }
  },
  "reading": {
    "issue_id": "",
    "may_file_ticket_alone": true,
    "subjective_fields": [
      ""
    ],
    "advisory": ""
  },
  "evidence_refs": {
    "screenshots": [
      ""
    ],
    "dom_snapshot": "",
    "video": ""
  }
}
```

<a id="scalar-schema-issuelist"></a>

### IssueList

**Type:** `object`, no additional properties

A run's issues: product defects the run found, each with its evidence, and beside them how each may be
acted on (`reading`). Only an issue whose reading says `may_file_ticket_alone` is a confirmed bug.

- **`issues` (required)**: array of [Issue](#scalar-schema-issue)
- **`reading` (required)**: array of [IssueReading](#scalar-schema-issuereading)

  One entry per issue, in the same order and naming its `issue_id`. Always present, so a consumer reads
  the gate rather than re-deriving it.
- **`run_id` (required)**: `string`
- **`status` (required)**: `string`

  The run's status. An empty `issues` is only a result once this is terminal.

<a id="scalar-example-93"></a>

**Generated example:**

```json
{
  "run_id": "",
  "status": "",
  "issues": [
    {
      "id": "",
      "type": "blocker",
      "severity": "low",
      "reproducible": true,
      "reproduction": {
        "attempted": true,
        "result": "reproduced",
        "attempts": 1,
        "notes": ""
      },
      "persona_specific": true,
      "claim": {
        "action_step": {
          "session_id": "",
          "step_id": 1
        },
        "expected": "",
        "observed": "",
        "verification": {
          "status": "verified",
          "method": "marker",
          "markers": [
            ""
          ],
          "note": ""
        },
        "attribution": "product",
        "check": {
          "verdict": "supported",
          "quote": "",
          "quote_step": {
            "session_id": "",
            "step_id": 1
          },
          "reason": "",
          "checked_by": "judge"
        }
      },
      "tester_error": {
        "kind": "mistyped_credential",
        "quote": "",
        "quote_step": {
          "session_id": "",
          "step_id": 1
        },
        "decided_by": "code",
        "reason": ""
      },
      "affected_sessions": [
        ""
      ],
      "affected_personas": [
        ""
      ],
      "affected_count": 1,
      "affected_fraction": 0,
      "reached_sessions": [
        ""
      ],
      "first_seen_step": 1,
      "location": {
        "route": "",
        "selector": "",
        "element_description": "",
        "viewport": ""
      },
      "title": "",
      "description": "",
      "persona_rationale": "",
      "contradiction": {
        "a": {
          "step_id": 1,
          "session_id": "",
          "text": ""
        },
        "b": {
          "step_id": 1,
          "session_id": "",
          "text": ""
        }
      },
      "evidence": {
        "step_refs": [
          {
            "session_id": "",
            "step_id": 1
          }
        ],
        "screenshots": [
          ""
        ],
        "video": {
          "ref": "",
          "ts_start_s": 1,
          "ts_end_s": 1
        },
        "dom_snapshot": "",
        "network": [
          {
            "additionalProperty": "anything"
          }
        ],
        "console": [
          {
            "additionalProperty": "anything"
          }
        ]
      },
      "repro_steps": [
        {
          "action": "",
          "target": "",
          "value": "",
          "replace": true,
          "expect": ""
        }
      ],
      "preconditions": [
        ""
      ],
      "baseline_status": "new",
      "confidence": 0,
      "suggested_owner": "",
      "connector_refs": {
        "additionalProperty": ""
      }
    }
  ],
  "reading": [
    {
      "issue_id": "",
      "may_file_ticket_alone": true,
      "subjective_fields": [
        ""
      ],
      "advisory": ""
    }
  ]
}
```

<a id="scalar-schema-issuereading"></a>

### IssueReading

**Type:** `object`, no additional properties

How one issue of `IssueList.issues` may be acted on: whether it may be filed as a ticket on its own, and
which of its fields are the persona's voice rather than observed behaviour.

- **`issue_id` (required)**: `string`
- **`may_file_ticket_alone` (required)**: `boolean`

  True only when reproduction cleared this issue to be filed as a confirmed bug on its own. False means
  report it with its evidence and mark it unconfirmed.
- **`subjective_fields` (required)**: `array of string`

  Fields on the issue that are the persona's voice rather than observed behaviour. Quote, never state.
- **`advisory`**: `string | null`

  How to read those fields, present only when there are any.

<a id="scalar-example-94"></a>

**Generated example:**

```json
{
  "issue_id": "",
  "may_file_ticket_alone": true,
  "subjective_fields": [
    ""
  ],
  "advisory": ""
}
```

<a id="scalar-schema-judgedecision"></a>

### JudgeDecision

**Type:** `string`, possible values: `"positive", "negative", "undecided"`

The decision the judge returned, before the gate discarded it. The judge's and not the persona's: no diary line at the steps it cites supports it. Named apart from `verdict.decision` so that nothing reading a row by field name takes it for the persona's.

<a id="scalar-schema-kind"></a>

### Kind

**Type:** `string`

Known values, open (more may be added): `unmet_expectation`, `missing_capability`, `value_gap`, `trust_gap`, `delight`.

What sort of feedback this is. `unmet_expectation`: the product did not do what the personas expected of it. `missing_capability`: something they looked for is not there. `value_gap`: they judged what it offers not worth what it asks of them. `trust_gap`: something made them hesitate to trust it. `delight`: a moment that worked well. More kinds may be added; read one you do not know as unknown.

**Example:**

```json
"unmet_expectation"
```

**Example:**

```json
"missing_capability"
```

**Example:**

```json
"value_gap"
```

**Example:**

```json
"trust_gap"
```

**Example:**

```json
"delight"
```

<a id="scalar-schema-kind1"></a>

### Kind1

**Type:** `string`

Known values, open (more may be added): `mistyped_credential`, `mistyped_input`, `wrong_element`, `placeholder_as_value`, `ignored_instruction`, `other`.

`mistyped_credential`: typed an email or password that differs from the one the account gave it (decided by code, comparing the typed text with the account; no secret enters a prompt or this document). `mistyped_input`: typed something other than what the brief or the screen asked for. `wrong_element`: acted on an element other than the one the title is about, or a non-interactive one. `placeholder_as_value`: read placeholder text as a pre-filled value and tried to clear or fix it. `ignored_instruction`: skipped something the screen visibly asked for first. `other`: something else the persona did that explains it. Not a tester error, by rule: a persona misreading the product's intent when a reasonable customer would misread it the same way - that is `confusion`, and it is the product's. A classification: a later kind is additive.

**Example:**

```json
"mistyped_credential"
```

**Example:**

```json
"mistyped_input"
```

**Example:**

```json
"wrong_element"
```

**Example:**

```json
"placeholder_as_value"
```

**Example:**

```json
"ignored_instruction"
```

**Example:**

```json
"other"
```

<a id="scalar-schema-livestatus"></a>

### LiveStatus

**Type:** `object`, no additional properties

The run's state as its workflow reports it while the run is open. Absent on a closed run.

- **`status` (required)**: `string`
- **`paused`**: `boolean`, default: `false`
- **`paused_reason`**: `string | null`

  Why: `signal` for a human pause, `budget_cost` when `budget.total_cost_usd` was reached. Either way
  only a `resume` releases the run.
- **`sessions`**: `object`

  Session count per state while the run is open: pending (the run has not started it yet), running,
  waiting\_for\_seat, and each artifact result. A session the run has started is counted in running once it has a
  browser seat and in waiting\_for\_seat while it is queued for one - taken from each session's own
  `waiting_for_seat`, so the two add up to the sessions in flight. A session whose live state cannot be read
  is counted running.

  **Additional properties:**

  `integer`

<a id="scalar-example-95"></a>

**Generated example:**

```json
{
  "status": "",
  "paused": false,
  "paused_reason": "",
  "sessions": {
    "additionalProperty": 1
  }
}
```

<a id="scalar-schema-location"></a>

### Location

**Type:** `object`

Where in the product the feedback is about: the page (`route`) and, when the personas pointed at one, the element in words (`element_description`).

- **`element_description`**: `string | null`
- **`route`**: `string | null`

<a id="scalar-example-96"></a>

**Generated example:**

```json
{
  "route": "",
  "element_description": ""
}
```

<a id="scalar-schema-location1"></a>

### Location1

**Type:** `object`, no additional properties

Where the defect is: the page (`route`), the element in words (`element_description`) or as a selector (`selector`), and the screen size it was seen at (`viewport`, WIDTHxHEIGHT).

- **`route` (required)**: `string`
- **`element_description`**: `string | null`
- **`selector`**: `string | null`
- **`viewport`**: `string | null`

<a id="scalar-example-97"></a>

**Generated example:**

```json
{
  "route": "",
  "selector": "",
  "element_description": "",
  "viewport": ""
}
```

<a id="scalar-schema-me"></a>

### Me

**Type:** `object`, no additional properties

Who this credential is, which tenant it spends and reads on, and what it may do.

The first call a dashboard shell makes: it decides which tenant's name is in the header, whether the
members and keys screens are offered, and whether the "start a run" button is there at all. It is
deliberately answerable by *every* credential - a person's token, a minted key, a break-glass entry, an
ungated request - so that one screen works against every way of reaching this API.

- **`scopes` (required)**: `array of string`

  What this credential may actually do, already resolved: a person's role expanded to the scopes it allows, a minted key's own scopes, and the full set for a credential that carries none (a break-glass entry or an ungated request). Never an empty list - read it as the answer, not as what was stored. An operator key minted for Saram's own operations carries operator scopes instead, ops:read and on the operator page's key ops:list, which no tenant can hold, and nothing beside them.
- **`subject` (required)**: `string`

  What runs.created\_by will record for this credential: usr\_... for a person, key\_... for a minted key, operator for a break-glass entry or an ungated request.
- **`tenant` (required)**: [MeTenant](#scalar-schema-metenant)

  The tenant the request's credential belongs to, as `GET /api/v1/me` reports it.

  Everything but `id` and `slug` is optional because the gate can answer without the row: a break-glass
  entry and an ungated request are the `fleet` tenant by construction, and on a machine with no database
  there is nothing to read the name and the plan out of.
- **`caller`**: `string | null`

  The human-readable label this credential is logged and attributed under: an email or the provider subject for a person, cookai/ci for a minted key, the bare name for a break-glass entry. Absent only when no gate is configured at all.
- **`expires_at`**: `string | null`, format: `date-time`

  When this minted key stops getting through the gate, the expires\_at it was minted with. Absent for a key minted without one, for a person, whose sign-in the identity provider ends, and for a break-glass entry or an ungated request. Read here because a key that may not list keys, an operator key among them, can still ask when its own lapses.
- **`role`**: `string | null`

  owner, admin, member or viewer for a person; absent for a machine credential, which carries scopes instead.
- **`user`**: [MeUser](#scalar-schema-meuser) | `null`

  The person behind an access token. Absent from `GET /api/v1/me` when the credential is a machine's.

<a id="scalar-example-98"></a>

**Generated example:**

```json
{
  "subject": "",
  "caller": "",
  "tenant": {
    "id": "",
    "slug": "",
    "name": "",
    "plan": "",
    "status": "",
    "house_models": true
  },
  "role": "",
  "scopes": [
    ""
  ],
  "expires_at": "",
  "user": {
    "id": "",
    "email": "",
    "display_name": ""
  }
}
```

<a id="scalar-schema-metenant"></a>

### MeTenant

**Type:** `object`, no additional properties

The tenant the request's credential belongs to, as `GET /api/v1/me` reports it.

Everything but `id` and `slug` is optional because the gate can answer without the row: a break-glass
entry and an ungated request are the `fleet` tenant by construction, and on a machine with no database
there is nothing to read the name and the plan out of.

- **`id` (required)**: `string`
- **`slug` (required)**: `string`
- **`house_models`**: `boolean | null`

  True when Saram has agreed to carry this tenant's model spend: your runs may name a Saram-held credential for the actor, the judge and the reproduction check even though your plan is a bring-your-own-keys one, and your steps still bill at your plan's price. It is set by Saram per tenant and by no request; absent means the tenant row was not read.
- **`name`**: `string | null`
- **`plan`**: `string | null`
- **`status`**: `string | null`

<a id="scalar-example-99"></a>

**Generated example:**

```json
{
  "id": "",
  "slug": "",
  "name": "",
  "plan": "",
  "status": "",
  "house_models": true
}
```

<a id="scalar-schema-meuser"></a>

### MeUser

**Type:** `object`, no additional properties

The person behind an access token. Absent from `GET /api/v1/me` when the credential is a machine's.

- **`id` (required)**: `string`
- **`display_name`**: `string | null`
- **`email`**: `string | null`

  Absent until the identity provider tells us one: an AuthKit access token carries no email claim unless the deployment adds one through a JWT template, and the address otherwise arrives with the user webhook.

<a id="scalar-example-100"></a>

**Generated example:**

```json
{
  "id": "",
  "email": "",
  "display_name": ""
}
```

<a id="scalar-schema-method"></a>

### Method

**Type:** `string`

Known values, open (more may be added): `marker`, `assertion`, `manual`.

Which check ran, when one did; absent when `status` is `unverified`. `marker`: a replay compared the failure marker at `action_step` - name it in `markers`. A marker matching is evidence the step failed again, not that it failed for the reason the title gives, so on its own it produces `marker_matched`, and `verified` only with `check.verdict: supported` and `attribution: product`. `assertion`: a state assertion checked the claim itself (reserved; not produced yet). `manual`: a human checked it. A classification: nothing acts on it - every surface shows `status` - so a reader on an older release buckets an instrument it does not know as `unknown`, and adding one is additive.

**Example:**

```json
"marker"
```

**Example:**

```json
"assertion"
```

**Example:**

```json
"manual"
```

<a id="scalar-schema-monthlycap"></a>

### MonthlyCap

**Type:** `object`, no additional properties

The monthly cap a run was refused by: the month, what it has billed, and the cap it has reached.

- **`billed_usd` (required)**: `number`

  What this month has billed so far, to the cent: the billable\_usd GET /api/v1/usage answers.
- **`cap_usd` (required)**: `number`

  The tenant's monthly cap, which billed\_usd has reached: no run starts until it is raised or the month turns.
- **`period` (required)**: `string`

  The calendar month the cap is counted over, YYYY-MM, in UTC.

<a id="scalar-example-101"></a>

**Generated example:**

```json
{
  "period": "",
  "billed_usd": 1,
  "cap_usd": 1
}
```

<a id="scalar-schema-network"></a>

### Network

**Type:** `string`, possible values: `"fast", "regular_4g", "slow_3g", "offline_flaky"`

The connection the browser runs on. `fast` (the default): not throttled. `regular_4g` and `slow_3g`: bandwidth limited and latency added, like those mobile connections. `offline_flaky` is not supported yet, and a session that asks for it does not start.

<a id="scalar-schema-opportunity"></a>

### Opportunity

**Type:** `object`, no additional properties

Behavior-grounded persona feedback that is not a defect: an unmet expectation, a missing capability, a value or trust gap, or a moment of delight. Scored by consensus across the sessions that reached the area, never by reproduction, and never a ticket on its own. `description` is the objective account; `persona_rationale` and `suggested_change` are opinion.

- **`affected_sessions` (required)**: `array of string`
- **`consensus` (required)**: `number`, minimum: `0`, maximum: `1`
- **`description` (required)**: `string`
- **`evidence` (required)**: [Evidence](#scalar-schema-evidence)

  What the feedback rests on: the steps where personas raised it (`step_refs`), lines from their diaries at those steps, each checked to appear there verbatim (`quotes`), and screenshots of what they were looking at.
- **`id` (required)**: `string`
- **`kind` (required)**: `string`

  Schema: `Kind`. Known values, open (more may be added): `unmet_expectation`, `missing_capability`, `value_gap`, `trust_gap`, `delight`.

  What sort of feedback this is. `unmet_expectation`: the product did not do what the personas expected of it. `missing_capability`: something they looked for is not there. `value_gap`: they judged what it offers not worth what it asks of them. `trust_gap`: something made them hesitate to trust it. `delight`: a moment that worked well. More kinds may be added; read one you do not know as unknown.
- **`affected_personas`**: `(array of string) | null`
- **`baseline_status`**: `string | null`

  Schema: `BaselineStatus`. Known values, open (more may be added): `new`, `persisting`, `resolved`, `unknown`.

  How this finding compares with the run named as the baseline. `new`: the baseline reached the same page and did not have it (for an issue, only one that is `reproducible`). `persisting`: the baseline has it too. `unknown`: nothing could say - no baseline was compared, the baseline never reached the page, or an issue the baseline does not have was not verified. `resolved` is never stamped on a finding of this run: a baseline issue this run no longer has is counted in `summary.issues_resolved_vs_baseline`. More values may be added; read one you do not know as `unknown`.
- **`connector_refs`**: `object | null`

  **Additional properties:**

  `string`
- **`location`**: [Location](#scalar-schema-location) | `null`

  Where in the product the feedback is about: the page (`route`) and, when the personas pointed at one, the element in words (`element_description`).
- **`persona_rationale`**: `string | null`
- **`reached_sessions`**: `(array of string) | null`
- **`solicited_by`**: `string | null`
- **`suggested_change`**: `string | null`
- **`title`**: `string | null`, maxLength: `120`

<a id="scalar-example-102"></a>

**Generated example:**

```json
{
  "id": "",
  "kind": "unmet_expectation",
  "title": "",
  "description": "",
  "persona_rationale": "",
  "suggested_change": "",
  "affected_sessions": [
    ""
  ],
  "affected_personas": [
    ""
  ],
  "reached_sessions": [
    ""
  ],
  "consensus": 0,
  "solicited_by": "",
  "location": {
    "route": "",
    "element_description": ""
  },
  "evidence": {
    "step_refs": [
      {
        "session_id": "",
        "step_id": 1
      }
    ],
    "quotes": [
      ""
    ],
    "screenshots": [
      ""
    ]
  },
  "baseline_status": "new",
  "connector_refs": {
    "additionalProperty": ""
  }
}
```

<a id="scalar-schema-opportunitylist"></a>

### OpportunityList

**Type:** `object`, no additional properties

A run's opportunities: behaviour-grounded persona feedback that is not a defect - unmet expectations,
missing capabilities, value gaps, delight - scored by how many personas share it. Never a ticket on its own.

- **`advisory` (required)**: `string`

  How to read this whole answer: behaviour-grounded persona feedback, consensus-scored, never a ticket on its
  own.
- **`display` (required)**: [DisplaySplit](#scalar-schema-displaysplit)

  The run's shown/folded split, present even when the run raised nothing: the floors are the answer to
  "why is this list short", and they are the same whether or not anything cleared them.
- **`opportunities` (required)**: array of [Opportunity](#scalar-schema-opportunity)
- **`run_id` (required)**: `string`
- **`status` (required)**: `string`

  The run's status. An empty `opportunities` is only a result once this is terminal.
- **`subjective_fields` (required)**: `array of string`

  Fields on each opportunity that are opinion. `description` is deliberately not among them.

<a id="scalar-example-103"></a>

**Generated example:**

```json
{
  "run_id": "",
  "status": "",
  "opportunities": [
    {
      "id": "",
      "kind": "unmet_expectation",
      "title": "",
      "description": "",
      "persona_rationale": "",
      "suggested_change": "",
      "affected_sessions": [
        ""
      ],
      "affected_personas": [
        ""
      ],
      "reached_sessions": [
        ""
      ],
      "consensus": 0,
      "solicited_by": "",
      "location": {
        "route": "",
        "element_description": ""
      },
      "evidence": {
        "step_refs": [
          {
            "session_id": "",
            "step_id": 1
          }
        ],
        "quotes": [
          ""
        ],
        "screenshots": [
          ""
        ]
      },
      "baseline_status": "new",
      "connector_refs": {
        "additionalProperty": ""
      }
    }
  ],
  "advisory": "",
  "subjective_fields": [
    ""
  ],
  "display": {
    "note": "",
    "min_consensus": 1,
    "min_affected_sessions": 1,
    "strong_consensus": 1,
    "shown": [
      ""
    ],
    "folded": [
      ""
    ]
  }
}
```

<a id="scalar-schema-paymentrequired"></a>

### PaymentRequired

**Type:** `object`, no additional properties

No run is started, because the tenant's plan cannot pay for it: `POST /runs`' 402 body, and the structured
content of the `start_run` tool's refusal for the same reason.

- **`detail` (required)**: `string`

  Why, in a sentence: the month's billing against the monthly cap on a plan that bills steps, or the run's Usage ceiling against the balance on a plan that prices Usage, with what to change.
- **`monthly_cap`**: [MonthlyCap](#scalar-schema-monthlycap) | `null`

  The numbers behind the detail when the month has billed the tenant's monthly cap, on a plan that bills steps: the month, what it has billed and the cap. Absent when the refusal is the run gate's.
- **`usage_ceiling`**: [UsageCeiling](#scalar-schema-usageceiling) | `null`

  The numbers behind the detail when the balance does not cover the run's Usage ceiling: the usage\_ceiling POST /api/v1/runs/validate answers for the same spec at the same moment, with covered false. Absent when the refusal is the monthly cap's.

<a id="scalar-example-104"></a>

**Generated example:**

```json
{
  "detail": "",
  "usage_ceiling": {
    "sessions": 1,
    "steps_per_session": 1,
    "wall_clock_s_per_session": 1,
    "usage_per_session": 1,
    "usage": 1,
    "usage_usd": 1,
    "usage_price_usd": 1,
    "available_usd": 1,
    "covered": true,
    "steps_per_session_that_fit": 1,
    "enforced": true
  },
  "monthly_cap": {
    "period": "",
    "billed_usd": 1,
    "cap_usd": 1
  }
}
```

<a id="scalar-schema-persona"></a>

### Persona

**Type:** `object`, no additional properties

A structured persona: a sampling strategy over the UI state space plus a tolerance function. Two kinds of fields: a structured core the run enforces and the dashboard diffs (`tolerance`, `constraints`, `action_policy`), and prose behavior fields the model reads (`backstory`, `vocabulary`, `facts`, `conversation`, `verdict_prompt`). Both drive what the persona does. `voice` affects narration only.

- **`action_policy` (required)**: [ActionPolicy](#scalar-schema-actionpolicy)

  How the persona works a page: how much of it they read, what they work it with, whether they wander or head straight for the goal, and how they treat defaults, help and errors. Each value becomes a line of the persona's briefing.
- **`constraints` (required)**: [Constraints](#scalar-schema-constraints)

  The browser the persona uses: the kind of device, the screen size, the connection, the language, accessibility needs and the signed-in state they start in. Most of these set up the browser session rather than the persona's briefing. A replay of an issue runs with the same ones, and the issue lists them in `preconditions`.
- **`goals` (required)**: [Goals](#scalar-schema-goals)

  What the persona wants out of the visit, in their own terms: the `primary` goal, anything `secondary` they also want if they get that far, and how much they want it (`intensity`). All three go into the persona's briefing beside the mission's goal; what counts as success for the run is the mission's `success_criteria`.
- **`id` (required)**: `string`, pattern: `^[a-z0-9_]+$`
- **`priors` (required)**: [Priors](#scalar-schema-priors)

  What the persona knows before they arrive: how well they know this product, the language of its industry and technology in general, the expectations they bring from other products, and what they were told beforehand. Each goes into the persona's briefing and shapes what they understand, expect and notice.
- **`tolerance` (required)**: [Tolerance](#scalar-schema-tolerance)

  How much the persona puts up with before giving up. Saram counts, step by step, the steps without visible progress, the errors and slow responses, and the moments with no clear next action, and ends the session `abandoned` the moment one crosses its threshold here; the model playing the persona cannot argue with the count. The persona is also told these limits in their own terms.
- **`version` (required)**: `integer`, minimum: `1`
- **`backstory`**: `string | null`
- **`conversation`**: [Conversation](#scalar-schema-conversation) | `null`

  How the persona talks to an in-product assistant or chat surface. Matters when the target is itself an AI product.
- **`facts`**: `object | null`

  **Additional properties:**

  `string`
- **`name`**: `string | null`
- **`pack`**: `string | null`
- **`provenance`**: [Provenance](#scalar-schema-provenance) | `null`

  How this persona was grounded. Generators fill this.
- **`tags`**: `(array of string) | null`
- **`verdict_prompt`**: `string | null`
- **`vocabulary`**: [Vocabulary](#scalar-schema-vocabulary) | `null`

  Terms the persona does and does not understand. More specific than the literacy enums in `priors`; the renderer tells the actor to say so when an unknown term appears rather than guess.
- **`voice`**: [Voice](#scalar-schema-voice) | `null`

  Narration style only. It never influences what the persona does.

<a id="scalar-example-105"></a>

**Generated example:**

```json
{
  "id": "",
  "version": 1,
  "name": "",
  "pack": "",
  "tags": [
    ""
  ],
  "backstory": "",
  "vocabulary": {
    "knows": [
      ""
    ],
    "unknown": [
      ""
    ]
  },
  "facts": {
    "additionalProperty": ""
  },
  "conversation": {
    "phrasing": "plain_speech",
    "asks_before_hunting": true,
    "pushes_back_on_generic": true
  },
  "verdict_prompt": "",
  "goals": {
    "primary": "",
    "secondary": [
      ""
    ],
    "intensity": "low"
  },
  "priors": {
    "product_familiarity": "none",
    "domain_literacy": "low",
    "technical_literacy": "low",
    "mental_models": [
      ""
    ],
    "prior_instructions": [
      ""
    ]
  },
  "action_policy": {
    "reading": "reads",
    "input": "mouse",
    "exploration": "explores",
    "trust_defaults": "low",
    "help_seeking": "never",
    "error_response": "retry_same"
  },
  "tolerance": {
    "patience_steps": 1,
    "error_threshold": 0,
    "latency_sensitivity_ms": 0,
    "confusion_threshold": 1,
    "attempts_per_subtask": 1,
    "abandon_behavior": "narrates_reason"
  },
  "constraints": {
    "device": "desktop",
    "viewport": "",
    "network": "fast",
    "accessibility": [
      "screen_reader"
    ],
    "locale": "en-US",
    "auth_state": "anonymous"
  },
  "voice": {
    "style": "",
    "verbosity": "terse"
  },
  "provenance": {
    "generator": "",
    "sources": [
      ""
    ],
    "generated_at": "",
    "notes": ""
  }
}
```

<a id="scalar-schema-personalist"></a>

### PersonaList

**Type:** `object`, no additional properties

The personas a run can name. `GET /api/v1/personas/{address}` answers one in full.

- **`personas` (required)**: array of [PersonaSummary](#scalar-schema-personasummary)

<a id="scalar-example-106"></a>

**Generated example:**

```json
{
  "personas": [
    {
      "address": "",
      "id": "",
      "version": 1,
      "name": "",
      "pack": "",
      "tags": [
        ""
      ]
    }
  ]
}
```

<a id="scalar-schema-personasummary"></a>

### PersonaSummary

**Type:** `object`, no additional properties

One persona a run can name, by its address, with its name, pack and tags.

- **`address` (required)**: `string`

  `id@version`, what a run spec and a `sessions.persona_id` carry.
- **`id` (required)**: `string`
- **`version` (required)**: `integer`
- **`name`**: `string | null`
- **`pack`**: `string | null`
- **`tags`**: `array of string`

<a id="scalar-example-107"></a>

**Generated example:**

```json
{
  "address": "",
  "id": "",
  "version": 1,
  "name": "",
  "pack": "",
  "tags": [
    ""
  ]
}
```

<a id="scalar-schema-phrasing"></a>

### Phrasing

**Type:** `string`, possible values: `"plain_speech", "commands", "mixed"`

Asks like they would ask a person, vs. writes instructions

<a id="scalar-schema-planlimits"></a>

### PlanLimits

**Type:** `object`, no additional properties

What the tenant's plan allows, as the plan stands now.

Each limit is `null` when the plan sets none, and then the platform's own bound applies instead.

- **`automation`**: `boolean`, default: `true`

  Whether your plan includes Saram's built-in automation: filing a run's issues on GitHub and commenting on the pull request (connectors.github), the run-finished webhook (connectors.webhook), and the CI action that posts a run spec with them. False on Free and true on Pro. On a plan without it, a run spec naming either connector is refused when it is validated or started, never run without it; the API and MCP are open on every plan.
- **`evidence_retention_days`**: `integer | null`

  How many days your plan keeps a run's evidence - its videos, screenshots and step traces - counted from the run's creation: 14 on Free, 90 on Pro. A run keeps the longer of this and what your plan kept when the run was created, and says the date it applies as evidence\_expires\_at; the run's findings are kept after it. Null when your plan sets no number, and then this deployment's own number applies.
- **`max_concurrent_sessions`**: `integer | null`

  How many sessions of one run your plan runs at the same time: 2 on Free, 8 on Pro. A run whose budget.max\_concurrent\_sessions asks for more runs this many at once, and a run that names none runs as many as the browser pool has seats, up to this. Null when your plan sets no limit.
- **`max_members`**: `integer | null`

  How many people your workspace holds, every role counted: 2 on Free, 10 on Pro. An invitation accepted once members\_used has reached it does not become a member, and the person is told why when they sign in; nobody already in is removed. Null when your plan sets no limit.

<a id="scalar-example-108"></a>

**Generated example:**

```json
{
  "max_concurrent_sessions": 1,
  "evidence_retention_days": 1,
  "max_members": 1,
  "automation": true
}
```

<a id="scalar-schema-plannedsession"></a>

### PlannedSession

**Type:** `object`, no additional properties

One session `POST /runs` would create for this spec, planned the way the run itself plans, so the dry run
cannot promise sessions the run would not make.

- **`entry_url` (required)**: `string`

  The mission's entry URL (or the target's) with this session's partition on it (`?saram_sid=`).
- **`mission_id` (required)**: `string`
- **`persona_id` (required)**: `string`
- **`session_id` (required)**: `string`

  `<run_id>.sess_NN_<persona slug>`. When the spec names no run, the prefix reads `<run_id>` literally -
  `POST /runs` is what mints the run id, and it is part of the session id. That is the same case as
  `SpecValidation.run_id` being absent.
- **`account_secret_ref`**: `string | null`

  The seed account this session would be handed. Absent when the target's seed accounts ran out, and then the
  persona is told they have no login.

<a id="scalar-example-109"></a>

**Generated example:**

```json
{
  "session_id": "",
  "persona_id": "",
  "mission_id": "",
  "entry_url": "",
  "account_secret_ref": ""
}
```

<a id="scalar-schema-priors"></a>

### Priors

**Type:** `object`, no additional properties

What the persona knows before they arrive: how well they know this product, the language of its industry and technology in general, the expectations they bring from other products, and what they were told beforehand. Each goes into the persona's briefing and shapes what they understand, expect and notice.

- **`domain_literacy`**: `string | null`, possible values: `"low", "medium", "high"`

  Schema: `DomainLiteracy`.

  How fluent the persona is in the language of the product's industry. `low`: does not know its terms and does not pretend to. `medium`: knows enough to follow along. `high`: fluent, and notices when a term is used loosely.
- **`mental_models`**: `(array of string) | null`
- **`prior_instructions`**: `(array of string) | null`
- **`product_familiarity`**: `string | null`, possible values: `"none", "low", "medium", "high"`

  Schema: `ProductFamiliarity`.

  How well the persona knows this product before the session. `none`: never used it and not sure what it is. `low`: seen it once and remembers almost nothing. `medium`: knows this kind of product, not this one in particular. `high`: knows it well and expects things where they left them.
- **`technical_literacy`**: `string | null`, possible values: `"low", "medium", "high"`

  Schema: `TechnicalLiteracy`.

  How comfortable the persona is with technology. `low`: not technical; keys, tokens and APIs mean nothing to them. `medium`: finds their way around an app's settings without being technical. `high`: technical, and notices when a key or a token is handled badly.

<a id="scalar-example-110"></a>

**Generated example:**

```json
{
  "product_familiarity": "none",
  "domain_literacy": "low",
  "technical_literacy": "low",
  "mental_models": [
    ""
  ],
  "prior_instructions": [
    ""
  ]
}
```

<a id="scalar-schema-productfamiliarity"></a>

### ProductFamiliarity

**Type:** `string`, possible values: `"none", "low", "medium", "high"`

How well the persona knows this product before the session. `none`: never used it and not sure what it is. `low`: seen it once and remembers almost nothing. `medium`: knows this kind of product, not this one in particular. `high`: knows it well and expects things where they left them.

<a id="scalar-schema-provenance"></a>

### Provenance

**Type:** `object`, no additional properties

How this persona was grounded. Generators fill this.

- **`generated_at`**: `string | null`, format: `date-time`
- **`generator`**: `string | null`
- **`notes`**: `string | null`
- **`sources`**: `(array of string) | null`

<a id="scalar-example-111"></a>

**Generated example:**

```json
{
  "generator": "",
  "sources": [
    ""
  ],
  "generated_at": "",
  "notes": ""
}
```

<a id="scalar-schema-providerrun"></a>

### ProviderRun

**Type:** `object`, no additional properties

One run's model spend on your own provider key, estimated at list price.

- **`model_usd_byo` (required)**: `number`
- **`run_id` (required)**: `string`

<a id="scalar-example-112"></a>

**Generated example:**

```json
{
  "run_id": "",
  "model_usd_byo": 1
}
```

<a id="scalar-schema-providerspend"></a>

### ProviderSpend

**Type:** `object`, no additional properties

What your model provider billed you for this month's runs, estimated: never Saram's charge, and never to be
added to it (section 2.5). The heading a screen puts over it is "Your model provider (billed by them, not by
Saram)".

- **`complete`**: `boolean`, default: `false`

  Whether it covers every model caller in a run: the persona loop, the judge and the reproduction check, each under its own role. A run metered before the judge was priced (2026-09-25) carries the persona loop's spend alone, so a month reaching back past that is a floor for those runs.
- **`model_usd_byo`**: `number`, default: `0`

  Model spend on your own provider keys, estimated at the provider's list price: their invoice, not a charge from Saram. A ChatGPT login's calls are included in that plan and are not priced here.
- **`runs`**: array of [ProviderRun](#scalar-schema-providerrun)

  Per run with any spend, oldest first.

<a id="scalar-example-113"></a>

**Generated example:**

```json
{
  "model_usd_byo": 0,
  "complete": false,
  "runs": [
    {
      "run_id": "",
      "model_usd_byo": 1
    }
  ]
}
```

<a id="scalar-schema-quotestep"></a>

### QuoteStep

**Type:** `object`, no additional properties

The step whose ledger block the quote came from.

- **`session_id` (required)**: `string`
- **`step_id` (required)**: `integer`

<a id="scalar-example-114"></a>

**Generated example:**

```json
{
  "session_id": "",
  "step_id": 1
}
```

<a id="scalar-schema-reading"></a>

### Reading

**Type:** `string`, possible values: `"reads", "skims", "ignores"`

How much on-screen text they consume

<a id="scalar-schema-refillskip"></a>

### RefillSkip

**Type:** `object`, no additional properties

A refill that was due and was not charged: the balance less what runs hold was under `refill_below_usd`
with auto-refill on, and Saram did not charge the card. Nothing is retried until the next settlement or run
finds a refill due again, and once the balance is gone new runs are refused.

- **`at` (required)**: `string`, format: `date-time`

  When the latest skipped refill was decided. Each skip moves it.
- **`detail` (required)**: `string`

  The same in a sentence: for the budget, what was already charged and what would still fit.
- **`reason` (required)**: `string`, possible values: `"monthly_budget", "no_card"`

  `monthly_budget`: the refill would take this month's top-ups, manual and automatic, past monthly\_budget\_usd, so it waits for a higher budget or the next calendar month. `no_card`: there is no card on file to charge, so it waits for one to be saved through POST /billing/setup.

<a id="scalar-example-115"></a>

**Generated example:**

```json
{
  "at": "",
  "reason": "monthly_budget",
  "detail": ""
}
```

<a id="scalar-schema-reprostep"></a>

### ReproStep

**Type:** `object`

One step of a replay a person or a coding agent can run without the persona, in order from an `open` of the entry page. `action` is a browser action such as `click`, `type`, `key`, `scroll`, `back`, `reload`, `upload` or `wait`; `target` is what it acts on (the address for `open`, `x,y` screen coordinates for a `click`); `value` is what it types, presses or scrolls by; `expect` is what should hold after it, such as the address the page should be on.

- **`action` (required)**: `string`
- **`expect`**: `string | null`
- **`replace`**: `boolean | null`
- **`target`**: `string | null`
- **`value`**: `string | null`

<a id="scalar-example-116"></a>

**Generated example:**

```json
{
  "action": "",
  "target": "",
  "value": "",
  "replace": true,
  "expect": ""
}
```

<a id="scalar-schema-reproduction"></a>

### Reproduction

**Type:** `object`, no additional properties

The counts behind `confirmation_rate`, so a reader can see its denominator instead of trusting it. Funnel stages and readings of one set of verdicts, not a partition: `candidates` are the per-session findings the judge raised, merging folds them into `merged`, reproduction ruled on `evaluated` of those and confirmed `confirmed`. The two overlaps are deliberate. A `flaky` finding is in `evaluated` (the experiment ran) and in `inconclusive` (it settled nothing). `excluded` is a subset of the unconfirmed - the ones dropped for having no persona voice left once the replay did not confirm them - and they are exactly the findings this artifact no longer carries, so `merged - excluded` is `issues_total`.

- **`candidates` (required)**: `integer`
- **`confirmed` (required)**: `integer`
- **`evaluated` (required)**: `integer`
- **`excluded` (required)**: `integer`
- **`inconclusive` (required)**: `integer`
- **`merged` (required)**: `integer`

<a id="scalar-example-117"></a>

**Generated example:**

```json
{
  "candidates": 1,
  "merged": 1,
  "evaluated": 1,
  "confirmed": 1,
  "inconclusive": 1,
  "excluded": 1
}
```

<a id="scalar-schema-reproduction1"></a>

### Reproduction1

**Type:** `object`, no additional properties

Whether Saram replayed the issue without the persona, and what the replay showed: whether one was `attempted`, its `result`, how many `attempts` it took, and `notes` on it. A replay that saw the failure again is not on its own enough to file a ticket; `reproducible` says whether this issue may be.

- **`attempted`**: `boolean | null`
- **`attempts`**: `integer | null`
- **`notes`**: `string | null`
- **`result`**: `string | null`

  Schema: `Result1`. Known values, open (more may be added): `reproduced`, `not_reproduced`, `flaky`, `skipped`.

  What the replay showed. `reproduced`: the failure happened again. `not_reproduced`: it did not. `flaky`: the attempts disagreed. `skipped`: no replay ran. More values may be added; read one you do not know as unknown.

<a id="scalar-example-118"></a>

**Generated example:**

```json
{
  "attempted": true,
  "result": "reproduced",
  "attempts": 1,
  "notes": ""
}
```

<a id="scalar-schema-result1"></a>

### Result1

**Type:** `string`

Known values, open (more may be added): `reproduced`, `not_reproduced`, `flaky`, `skipped`.

What the replay showed. `reproduced`: the failure happened again. `not_reproduced`: it did not. `flaky`: the attempts disagreed. `skipped`: no replay ran. More values may be added; read one you do not know as unknown.

**Example:**

```json
"reproduced"
```

**Example:**

```json
"not_reproduced"
```

**Example:**

```json
"flaky"
```

**Example:**

```json
"skipped"
```

<a id="scalar-schema-retestattempt"></a>

### RetestAttempt

**Type:** `object`, no additional properties

One replay attempt of a retest: the session it ran in, what it expected at the judged step, and what it
saw.

- **`attempt_id` (required)**: `string`
- **`expected` (required)**: `object`

  The outcome signature the run recorded at the judged step: `url_after` and the failure `markers`.
- **`issue_id` (required)**: `string`
- **`outcome` (required)**: `string`

  `reproduced`, `not_reproduced`, `path_diverged`, `target_moved` or `error`.
- **`session_id` (required)**: `string`

  The throwaway body session the attempt ran in: `<run_id>.<retest_id>_<issue_id>_a<n>`, never one the run's
  own reproduction used, so its evidence sits beside the original's rather than over it.
- **`notes`**: `string`, default: `""`
- **`observed`**: `object | null`
- **`screenshot_refs`**: `array of string`
- **`surface_trace_ref`**: `string | null`

<a id="scalar-example-119"></a>

**Generated example:**

```json
{
  "attempt_id": "",
  "issue_id": "",
  "session_id": "",
  "outcome": "",
  "expected": {
    "additionalProperty": "anything"
  },
  "observed": {
    "additionalProperty": "anything"
  },
  "notes": "",
  "surface_trace_ref": "",
  "screenshot_refs": [
    ""
  ]
}
```

<a id="scalar-schema-retestlist"></a>

### RetestList

**Type:** `object`, no additional properties

Every retest asked for on one issue, newest first.

- **`issue_id` (required)**: `string`
- **`retests` (required)**: array of [RetestRecord](#scalar-schema-retestrecord)

  Newest first.
- **`run_id` (required)**: `string`

<a id="scalar-example-120"></a>

**Generated example:**

```json
{
  "run_id": "",
  "issue_id": "",
  "retests": [
    {
      "retest_id": "",
      "run_id": "",
      "issue_id": "",
      "status": "pending",
      "entry_url": "",
      "requested_by": "",
      "workflow_id": "",
      "result": "reproduced",
      "verification": {
        "status": "verified",
        "method": "marker",
        "markers": [
          ""
        ],
        "note": ""
      },
      "check": {
        "verdict": "supported",
        "quote": "",
        "quote_step": {
          "session_id": "",
          "step_id": 1
        },
        "reason": "",
        "checked_by": "judge"
      },
      "attempts": [
        {
          "attempt_id": "",
          "issue_id": "",
          "session_id": "",
          "outcome": "",
          "expected": {
            "additionalProperty": "anything"
          },
          "observed": {
            "additionalProperty": "anything"
          },
          "notes": "",
          "surface_trace_ref": "",
          "screenshot_refs": [
            ""
          ]
        }
      ],
      "gate_discards": 1,
      "gate_ref": "",
      "error": "",
      "created_at": "",
      "started_at": "",
      "finished_at": ""
    }
  ]
}
```

<a id="scalar-schema-retestrecord"></a>

### RetestRecord

**Type:** `object`, no additional properties

One retest of one issue: a later replay a person asked for, kept beside what the run concluded.

The issue itself - `GET /runs/{id}/issues/{issue_id}` - still says what the run's own reproduction found,
and so does the artifact: a retest never rewrites the run's record. Every retest is its own record.

- **`created_at` (required)**: `string`, format: `date-time`
- **`issue_id` (required)**: `string`
- **`retest_id` (required)**: `string`
- **`run_id` (required)**: `string`
- **`status` (required)**: `string`, possible values: `"pending", "running", "completed", "failed"`

  `pending` until the replay has a pool seat (it queues for one like any session), `running` while it
  replays, then `completed` with a `result`, or `failed` with an `error` and no result.
- **`workflow_id` (required)**: `string`
- **`attempts`**: array of [RetestAttempt](#scalar-schema-retestattempt)
- **`check`**: [Check](#scalar-schema-check) | `null`

  The claim check, present only when a marker matched and it was asked.
- **`entry_url`**: `string | null`

  The deployment this replay opened, when it was not the run's own.
- **`error`**: `string | null`

  Why a `failed` retest has no result, in a sentence for the caller: the target's reset hook failing or
  refused before a replay, quoted with `target: ` in front; that Saram could not read the target's credential;
  or that Saram could not start or finish the retest. Never a finding about the product. Absent on every other
  status.
- **`finished_at`**: `string | null`, format: `date-time`
- **`gate_discards`**: `integer | null`

  How many claims this replay's gate threw away: claim-check quotes the cited evidence does not contain, each
  of which leaves `check` `ungrounded`, and attempts whose path diverged before the judged step.
  Zero is the expected value. Absent until `status` is `completed`, and on retests completed before this was
  recorded.
- **`gate_ref`**: `string | null`

  The records behind `gate_discards`, fetched through `GET /api/v1/blobs`: `{"run_id", "retest_id",
  "issue_id", "discards"}`, one discard per claim or attempt with its `session_id`, `kind`, `reason` and a
  `payload` naming the `issue_id` and the `quote` or the step the path diverged at. This retest's own, never the
  run's reproduction's. Written even when empty, so an absent ref is a retest that has not completed, never
  "nothing thrown away".
- **`requested_by`**: `string | null`

  The subject of the credential that asked: `key_...`, a person's user id, or `operator`.
- **`result`**: `string | null`

  Schema: `Result1`. Known values, open (more may be added): `reproduced`, `not_reproduced`, `flaky`, `skipped`.

  What this replay concluded, in `issue.reproduction.result`'s words. `not_reproduced`: the failure the run saw
  at the judged step did not happen again on either attempt - the answer to "is it fixed?" that is yes as far as
  this replay can tell, and not a proof the product works. `reproduced`: it happened again on both. `flaky`: on
  one of two. `skipped`: the replay could not rule (no browser pool, a session it could not open, a control that
  moved). Absent until `status` is `completed`.
- **`started_at`**: `string | null`, format: `date-time`
- **`verification`**: [Verification](#scalar-schema-verification) | `null`

  The `claim.verification` block this replay would have written onto the issue: `verified` only when the
  failure happened again and the claim check supported the title.

<a id="scalar-example-121"></a>

**Generated example:**

```json
{
  "retest_id": "",
  "run_id": "",
  "issue_id": "",
  "status": "pending",
  "entry_url": "",
  "requested_by": "",
  "workflow_id": "",
  "result": "reproduced",
  "verification": {
    "status": "verified",
    "method": "marker",
    "markers": [
      ""
    ],
    "note": ""
  },
  "check": {
    "verdict": "supported",
    "quote": "",
    "quote_step": {
      "session_id": "",
      "step_id": 1
    },
    "reason": "",
    "checked_by": "judge"
  },
  "attempts": [
    {
      "attempt_id": "",
      "issue_id": "",
      "session_id": "",
      "outcome": "",
      "expected": {
        "additionalProperty": "anything"
      },
      "observed": {
        "additionalProperty": "anything"
      },
      "notes": "",
      "surface_trace_ref": "",
      "screenshot_refs": [
        ""
      ]
    }
  ],
  "gate_discards": 1,
  "gate_ref": "",
  "error": "",
  "created_at": "",
  "started_at": "",
  "finished_at": ""
}
```

<a id="scalar-schema-retestrequest"></a>

### RetestRequest

**Type:** `object`, no additional properties

The optional body of `POST /runs/{id}/issues/{issue_id}/retest`.

- **`entry_url`**: `string | null`, maxLength: `2048`, pattern: `^https?://[^/?#\s]+`

  Where the replay opens instead of the run's own entry url - a new deployment of the same app, such as a preview. Every url the run recorded on its own origin is replayed on this url's origin, path for path, so the replay compares like with like; a path the new deployment no longer has reads as the path diverging, not as a fix. Absent replays against the run's own target.

<a id="scalar-example-122"></a>

**Generated example:**

```json
{
  "entry_url": ""
}
```

<a id="scalar-schema-rundeleted"></a>

### RunDeleted

**Type:** `object`, no additional properties

A run its owner deleted: its evidence, its recordings, and its record - the run, its sessions, issues,
verdicts, opportunities, retests and artifact - are gone, and every route now answers the run with the 404
an unknown run gets. Asking again answers this same body, so a retried delete is safe.

What is kept is what the run was billed: the usage ledger outlives a deleted run as it outlives expired
evidence, so a month's usage still counts it.

- **`deleted_at` (required)**: `string`, format: `date-time`

  When it was deleted: the first time it was asked, however often since.
- **`objects_deleted` (required)**: `integer`

  How many stored objects went with it - traces, screenshots, video, checkpoints and the run's own documents. 0 for a run that had not recorded anything.
- **`run_id` (required)**: `string`

  The run that was deleted.

<a id="scalar-example-123"></a>

**Generated example:**

```json
{
  "run_id": "",
  "deleted_at": "",
  "objects_deleted": 1
}
```

<a id="scalar-schema-rundetail"></a>

### RunDetail

**Type:** `object`, no additional properties

One run in full: everything `RunSummary` carries, plus its budgets, the files it hands its personas, its
live state while it is open, and every session.

- **`created_at` (required)**: `string`, format: `date-time`
- **`evidence_expires_at` (required)**: `string`, format: `date-time`

  When this run's step-by-step evidence is deleted: `created_at` plus the days its plan keeps evidence - 14 on
  Free, 90 on Pro, this deployment's default on a plan that sets none - or the days its plan kept when the run
  was created, whichever is longer, so a plan change never moves this date earlier and an upgrade moves it later.
  The screenshots, the video, the agent and surface step-event streams, the checkpoints and the per-session
  observation documents go at this date, in every bucket they live in. Earlier when they went earlier: a run
  whose recordings the storage had already removed is found by the retention sweep, which takes the rest of its
  evidence and records when, and that moment is this date. The run's *record* is kept and has no
  expiry: this response, its issues, its verdicts, its opportunities, the spec it ran and the assembled
  artifact. A reader that wants a screenshot after that date must keep the bytes, not the ref.
- **`run_id` (required)**: `string`
- **`run_spec_ref` (required)**: `string`
- **`status` (required)**: `string`

  `pending`, `running`, `observing`, then one terminal value. `completed` means at least one session
  reached an ending its persona got to (`completed`, `abandoned`, `blocked`, `budget_exhausted`), so there
  are results to read. `failed` covers both a run that broke - a rejected spec, a reset hook or the
  observer - and a run whose every session `errored`, which came back with no persona outcome at all;
  `status_reason` says which. `cancelled` is a `cancel` signal. Poll until the value is terminal, and treat
  only `completed` as results-ready.
- **`target` (required)**: `string`
- **`actor_cost_usd`**: `number | null`

  Of `cost_usd`, the persona loop's model calls: the sum of the priced sessions' `cost_usd`. Absent when no
  session's model had a price.
- **`artifact_ref`**: `string | null`
- **`baseline_run_id`**: `string | null`
- **`billable_usd`**: `number | null`

  What this run costs you, to the cent: `billed_steps` at your plan's step price - or your own agreed
  price - in force when the run ran. This is Saram's charge; `cost_usd` beside it is model spend and is not.
  LIVE with `billed_steps` while the run is open, so it is what the run has cost you so far. Absent means
  nobody has priced your tenant, which is not the same as free - never read a missing price as $0. Your plan's
  included steps and monthly fee are not in it: they are properties of a month, and the invoice line with the
  allowance applied is this run's `billable_usd` on `GET /api/v1/usage`.
- **`billed_steps`**: `integer`, default: `0`

  What this run is charged for: the steps its personas took - one browser action each - never the
  reproduction replay's, which are included at no charge. LIVE: a session is recorded when
  it ends, so while the run is open this is the finished sessions' counts plus each unfinished
  session's last `heartbeat_step`, and it climbs until the run does end.
- **`budgets`**: `object`
- **`caller`**: `string | null`

  Who started this run, in words: the caller name the API key gate identified at `POST /runs` - the
  operator's own name for a break-glass entry, a name like `acme/ci` for an API key. Absent when nobody was
  identified, which is the gate being off, a run an operator started in-process, or a run created before runs
  recorded their caller; the three are deliberately indistinguishable rather than dressed up with an invented
  name. Attribution, beside the authorization `tenant_id` carries.
- **`confirmation_rate`**: `number | null`

  Confirmed issues over the issues reproduction ruled on. Available as soon as reproduction finishes, which
  is before `summary.confirmation_rate` exists; the two are the same number computed from the same lineage.
- **`cost_usd`**: `number | null`

  What this run's model calls cost: `actor_cost_usd` and `judge_cost_usd` together. NOT what Saram charges
  you - `billable_usd` is that: on your own keys this is your provider's bill, and on Saram's models it is Saram's
  own cost, shown so your Saram usage and your provider's invoice add up. Written when the run finishes. Absent
  means nothing priced the models, which is not the same as free. On a run finished before the judge was priced
  it is the persona loop's alone, and `judge_cost_usd` is absent beside it.
- **`created_by`**: `string | null`

  Which credential started it: `key_...` for an API key, `operator` for a break-glass entry or a run an
  operator started in-process. The id of the key rather than its name, so rotating a key leaves the runs it
  started still pointing at it.
- **`evidence_expired`**: `boolean`, default: `false`

  Whether that date has passed. `GET /api/v1/blobs` on this run's session evidence answers 404 once it
  has, and that 404 means "expired", not "never existed" - which is why the date is published beside it.
- **`files`**: array of [RunFile](#scalar-schema-runfile)

  The files the run hands its personas, in the spec's order: its `files[]`, without the rest of the spec.
  Empty for a run that names none.
- **`finished_at`**: `string | null`, format: `date-time`
- **`held_usd`**: `number | null`

  What this run still reserves from your balance: the most its spec's budgets can use (the Usage ceiling),
  less each finished session's share. Released when the run's last session is settled. Absent when the run
  holds nothing - it has finished, or the run gate did not hold for it.
- **`judge_cost_usd`**: `number | null`

  Of `cost_usd`, the observer's model calls: the judge's reading of each session, attribution, the merge,
  and the reproduction's claim check and tie-break - a failed attempt included, because it was paid for. On
  today's models this is often the larger half. A retest you ask for later is not in it. Absent when none of
  those calls had a price, and on a run finished before they were priced.
- **`live`**: [LiveStatus](#scalar-schema-livestatus) | `null`

  The run's state as its workflow reports it while the run is open. Absent on a closed run.
- **`packs`**: `array of string`

  The distinct persona packs this run's sessions were drawn from, sorted: the cohorts it covers. A run whose
  personas have no pack lists none rather than an empty cohort.
- **`refunded_usd`**: `number | null`

  Of this run's Usage, the dollars given back because a session failed on Saram's side (the pool, a lost
  worker, a setup step in Saram's code: fault `saram`). Not in `usage_usd`. Absent wherever `usage_usd` is.
- **`repro_attempts`**: `integer | null`
- **`repro_gate_discards`**: `integer | null`

  How many things reproduction's gate threw away, of two kinds: claim-check quotes the cited evidence does
  not contain, each of which left its issue `ungrounded`, and replay attempts whose path diverged
  before the judged step, each of which ruled on nothing and left its issue `skipped`. Read beside
  `confirmation_rate`: that says how much of what the judge said survived the replay, this says how often the
  replay's own checker cited loosely or its path stopped being walkable. Zero is the expected value. Absent
  until reproduction has run, and on runs reproduced before this was recorded.
- **`repro_gate_ref`**: `string | null`

  The records behind `repro_gate_discards`, fetched through `GET /api/v1/blobs`: `{"run_id", "discards"}`,
  one discard each with its `session_id`, `kind`, `reason` and a `payload` naming the `issue_id`. A
  `claim_quote` payload carries the `quote`; a `path_diverged` one carries the `attempt`, the
  `attempt_session_id`, the original `step_id` it diverged at (null for the entry url), that step's `action`
  and `expect`, and the `url` the replay was on instead. Written even when empty, so an absent ref is
  reproduction that has not run, never "nothing thrown away".
- **`session_counts`**: `object`

  Session count per status for this run: pending, running, the artifact results, or cancelled.

  **Additional properties:**

  `integer`
- **`sessions`**: array of [SessionRow](#scalar-schema-sessionrow)
- **`sessions_per_hour`**: `number | null`

  Sessions that ran a step, per hour of the execution phase (run start to last session finish).
- **`started_at`**: `string | null`, format: `date-time`
- **`status_reason`**: `string | null`

  Why a run ended `failed` or `cancelled`, in a sentence for the caller: the spec's refusal or the target's
  reset hook's, quoted; that Saram could not start the run, or could not finish judging its sessions; that every
  session errored; or `cancelled by signal`. Absent on a `completed` run.
- **`summary`**: [Summary](#scalar-schema-summary) | `null`

  The artifact's `summary` block, once the artifact is assembled.
- **`target_name`**: `string | null`
- **`tenant_id`**: `string | null`

  Who owns this run, and so who may read it: every route filters by the tenant the request's key belongs
  to, and a run outside it is a 404 rather than a 403. Always your own tenant, which is what makes it safe to
  publish - a response can only ever carry the id you are already authenticated as. Absent on runs created
  before runs recorded their tenant; those are readable by this deployment's own fleet tenant and by nobody
  else.
- **`usage`**: `number | null`

  This run's Usage: persona steps weighted by how long each took, summed over its sessions, to 0.001. LIVE
  while `usage_state` is `live`: settled sessions read what the ledger debited, and a session in flight its last
  heartbeat. Final once `usage_state` is `settled` or `refunded`, when it is the credit ledger's own figure.
  Absent on a finished run none of whose sessions was measured, which is not the same as none used.
- **`usage_state`**: `string | null`, possible values: `"live", "held", "settled", "refunded"`

  How final `usage` and `usage_usd` are. `held`: the run is open and holds `held_usd`, and no step is
  measured yet. `live`: the run is open, or has just finished and is being settled - the figures are so far and
  still rise. `settled`: every session is settled and the figures are the credit ledger's. `refunded`: settled,
  and every dollar of its Usage was given back because it failed on Saram's side.
- **`usage_usd`**: `number | null`

  What this run's Usage costs you in dollars, at your plan's Usage price when each session ran: charged from
  your prepaid balance, refunds already taken off - so a run that failed on Saram's side reads 0 and the money
  given back is `refunded_usd`. Moves with `usage` while the run is live. This is Saram's charge on a plan that
  prices Usage (Free, Pro); your model provider's bill is never in it. Absent when nothing prices this run's
  Usage - a plan billed in steps, where `billable_usd` is the charge - which is not the same as free.
- **`workflow_id`**: `string | null`

<a id="scalar-example-124"></a>

**Generated example:**

```json
{
  "run_id": "",
  "status": "",
  "status_reason": "",
  "target": "",
  "target_name": "",
  "baseline_run_id": "",
  "tenant_id": "",
  "created_by": "",
  "caller": "",
  "confirmation_rate": 1,
  "repro_attempts": 1,
  "repro_gate_discards": 1,
  "repro_gate_ref": "",
  "billed_steps": 0,
  "billable_usd": 1,
  "cost_usd": 1,
  "actor_cost_usd": 1,
  "judge_cost_usd": 1,
  "usage": 1,
  "usage_usd": 1,
  "refunded_usd": 1,
  "held_usd": 1,
  "usage_state": "live",
  "sessions_per_hour": 1,
  "artifact_ref": "",
  "summary": {
    "personas_run": 1,
    "missions": 1,
    "sessions": 1,
    "completed": 1,
    "abandoned": 1,
    "blocked": 1,
    "errored": 1,
    "budget_exhausted": 0,
    "issues_total": 1,
    "issues_reproducible": 1,
    "issues_marker_matched": 1,
    "tester_errors": 1,
    "issues_new_vs_baseline": 1,
    "issues_resolved_vs_baseline": 1,
    "issues_persisting_vs_baseline": 1,
    "opportunities_total": 1,
    "verdicts": {
      "positive": 1,
      "negative": 1,
      "undecided": 1,
      "top_reasons": [
        ""
      ]
    },
    "confirmation_rate": 0,
    "reproduction": {
      "candidates": 1,
      "merged": 1,
      "evaluated": 1,
      "confirmed": 1,
      "inconclusive": 1,
      "excluded": 1
    },
    "gate_discards": 0,
    "path_diversity": 0,
    "cost_usd": 1,
    "actor_cost_usd": 0,
    "judge_cost_usd": 0,
    "wall_clock_s": 1
  },
  "session_counts": {
    "additionalProperty": 1
  },
  "packs": [
    ""
  ],
  "created_at": "",
  "started_at": "",
  "finished_at": "",
  "evidence_expires_at": "",
  "evidence_expired": false,
  "workflow_id": "",
  "budgets": {
    "additionalProperty": "anything"
  },
  "run_spec_ref": "",
  "files": [
    {
      "ref": "",
      "name": "",
      "description": "",
      "assign": {
        "personas": [
          ""
        ],
        "missions": [
          ""
        ]
      }
    }
  ],
  "live": {
    "status": "",
    "paused": false,
    "paused_reason": "",
    "sessions": {
      "additionalProperty": 1
    }
  },
  "sessions": [
    {
      "session_id": "",
      "persona_id": "",
      "pack": "",
      "tags": [
        ""
      ],
      "mission_id": "",
      "status": "",
      "result": "",
      "end_reason": "",
      "fault": "none",
      "steps": 1,
      "end_step": 1,
      "abandon_step": 1,
      "tokens_in": 1,
      "tokens_out": 1,
      "tokens_cache_read": 0,
      "tokens_cache_write": 0,
      "model": "",
      "cost_usd": 1,
      "price_source": "",
      "wall_clock_s": 1,
      "uncertain_steps": [
        1
      ],
      "resumed_at_steps": [
        1
      ],
      "verdict_decision": "",
      "outcome_read": "",
      "observer_error": "",
      "verdict_discarded": {
        "judge_decision": "positive",
        "gate_reason": ""
      },
      "heartbeat_step": 1,
      "last_heartbeat_at": "",
      "heartbeat_usage": 1,
      "usage": 1,
      "usage_usd": 1,
      "refunded_usd": 1,
      "waiting_for_seat": true,
      "waiting_since": "",
      "workflow_id": "",
      "browser_session_id": "",
      "agent_trace_ref": "",
      "surface_trace_ref": "",
      "video_ref": "",
      "video_url": "",
      "recorded_under_mask": false,
      "created_at": "",
      "finished_at": ""
    }
  ]
}
```

<a id="scalar-schema-runfile"></a>

### RunFile

**Type:** `object`, no additional properties

One file a run hands its personas: the run spec's `files[]` entry as the run was started with it.

Served on `RunDetail` so the run page can list a run's handouts without the spec document itself, which also
carries its providers, connectors and seed-account refs. Read from the run's own row, never from the store:
the spec is immutable once the run is created, and the run row keeps the copy that was validated.

- **`description` (required)**: `string`

  What the persona was told about it, verbatim.
- **`name` (required)**: `string`

  What the persona calls it, and the filename the target receives. Unique within the run.
- **`ref` (required)**: `string`

  `saram-file://<tenant_id>/<file_id>`, as `FileRecord.ref`. The file may since have been deleted or have
  expired: this is what the run was handed, not what the tenant still has.
- **`assign`**: [RunFileAssign](#scalar-schema-runfileassign) | `null`

  Which sessions carry it. Absent means every session.

<a id="scalar-example-125"></a>

**Generated example:**

```json
{
  "ref": "",
  "name": "",
  "description": "",
  "assign": {
    "personas": [
      ""
    ],
    "missions": [
      ""
    ]
  }
}
```

<a id="scalar-schema-runfileassign"></a>

### RunFileAssign

**Type:** `object`, no additional properties

Which of a run's sessions carry a file: those matching every list given (`files[].assign` in the run spec).

- **`missions`**: `(array of string) | null`

  Mission ids. Absent means any mission.
- **`personas`**: `(array of string) | null`

  Persona addresses, as the spec's `personas` names them. Absent means any persona.

<a id="scalar-example-126"></a>

**Generated example:**

```json
{
  "personas": [
    ""
  ],
  "missions": [
    ""
  ]
}
```

<a id="scalar-schema-runlist"></a>

### RunList

**Type:** `object`, no additional properties

Your runs, newest first.

- **`runs` (required)**: array of [RunSummary](#scalar-schema-runsummary)

<a id="scalar-example-127"></a>

**Generated example:**

```json
{
  "runs": [
    {
      "run_id": "",
      "status": "",
      "status_reason": "",
      "target": "",
      "target_name": "",
      "baseline_run_id": "",
      "tenant_id": "",
      "created_by": "",
      "caller": "",
      "confirmation_rate": 1,
      "repro_attempts": 1,
      "repro_gate_discards": 1,
      "repro_gate_ref": "",
      "billed_steps": 0,
      "billable_usd": 1,
      "cost_usd": 1,
      "actor_cost_usd": 1,
      "judge_cost_usd": 1,
      "usage": 1,
      "usage_usd": 1,
      "refunded_usd": 1,
      "held_usd": 1,
      "usage_state": "live",
      "sessions_per_hour": 1,
      "artifact_ref": "",
      "summary": {
        "personas_run": 1,
        "missions": 1,
        "sessions": 1,
        "completed": 1,
        "abandoned": 1,
        "blocked": 1,
        "errored": 1,
        "budget_exhausted": 0,
        "issues_total": 1,
        "issues_reproducible": 1,
        "issues_marker_matched": 1,
        "tester_errors": 1,
        "issues_new_vs_baseline": 1,
        "issues_resolved_vs_baseline": 1,
        "issues_persisting_vs_baseline": 1,
        "opportunities_total": 1,
        "verdicts": {
          "positive": 1,
          "negative": 1,
          "undecided": 1,
          "top_reasons": [
            ""
          ]
        },
        "confirmation_rate": 0,
        "reproduction": {
          "candidates": 1,
          "merged": 1,
          "evaluated": 1,
          "confirmed": 1,
          "inconclusive": 1,
          "excluded": 1
        },
        "gate_discards": 0,
        "path_diversity": 0,
        "cost_usd": 1,
        "actor_cost_usd": 0,
        "judge_cost_usd": 0,
        "wall_clock_s": 1
      },
      "session_counts": {
        "additionalProperty": 1
      },
      "packs": [
        ""
      ],
      "created_at": "",
      "started_at": "",
      "finished_at": "",
      "evidence_expires_at": "",
      "evidence_expired": false
    }
  ]
}
```

<a id="scalar-schema-runsignalled"></a>

### RunSignalled

**Type:** `object`, no additional properties

A pause, resume or cancel delivered to a run.

- **`run_id` (required)**: `string`

  The run the signal was delivered to.
- **`signal` (required)**: `string`

  The signal delivered: pause, resume or cancel.

<a id="scalar-example-128"></a>

**Generated example:**

```json
{
  "run_id": "",
  "signal": ""
}
```

<a id="scalar-schema-runsummary"></a>

### RunSummary

**Type:** `object`, no additional properties

A run as `GET /api/v1/runs` lists it: where it stands, what it found in summary, and what it cost and
billed. `GET /api/v1/runs/{run_id}` answers the same fields and more (`RunDetail`).

- **`created_at` (required)**: `string`, format: `date-time`
- **`evidence_expires_at` (required)**: `string`, format: `date-time`

  When this run's step-by-step evidence is deleted: `created_at` plus the days its plan keeps evidence - 14 on
  Free, 90 on Pro, this deployment's default on a plan that sets none - or the days its plan kept when the run
  was created, whichever is longer, so a plan change never moves this date earlier and an upgrade moves it later.
  The screenshots, the video, the agent and surface step-event streams, the checkpoints and the per-session
  observation documents go at this date, in every bucket they live in. Earlier when they went earlier: a run
  whose recordings the storage had already removed is found by the retention sweep, which takes the rest of its
  evidence and records when, and that moment is this date. The run's *record* is kept and has no
  expiry: this response, its issues, its verdicts, its opportunities, the spec it ran and the assembled
  artifact. A reader that wants a screenshot after that date must keep the bytes, not the ref.
- **`run_id` (required)**: `string`
- **`status` (required)**: `string`

  `pending`, `running`, `observing`, then one terminal value. `completed` means at least one session
  reached an ending its persona got to (`completed`, `abandoned`, `blocked`, `budget_exhausted`), so there
  are results to read. `failed` covers both a run that broke - a rejected spec, a reset hook or the
  observer - and a run whose every session `errored`, which came back with no persona outcome at all;
  `status_reason` says which. `cancelled` is a `cancel` signal. Poll until the value is terminal, and treat
  only `completed` as results-ready.
- **`target` (required)**: `string`
- **`actor_cost_usd`**: `number | null`

  Of `cost_usd`, the persona loop's model calls: the sum of the priced sessions' `cost_usd`. Absent when no
  session's model had a price.
- **`artifact_ref`**: `string | null`
- **`baseline_run_id`**: `string | null`
- **`billable_usd`**: `number | null`

  What this run costs you, to the cent: `billed_steps` at your plan's step price - or your own agreed
  price - in force when the run ran. This is Saram's charge; `cost_usd` beside it is model spend and is not.
  LIVE with `billed_steps` while the run is open, so it is what the run has cost you so far. Absent means
  nobody has priced your tenant, which is not the same as free - never read a missing price as $0. Your plan's
  included steps and monthly fee are not in it: they are properties of a month, and the invoice line with the
  allowance applied is this run's `billable_usd` on `GET /api/v1/usage`.
- **`billed_steps`**: `integer`, default: `0`

  What this run is charged for: the steps its personas took - one browser action each - never the
  reproduction replay's, which are included at no charge. LIVE: a session is recorded when
  it ends, so while the run is open this is the finished sessions' counts plus each unfinished
  session's last `heartbeat_step`, and it climbs until the run does end.
- **`caller`**: `string | null`

  Who started this run, in words: the caller name the API key gate identified at `POST /runs` - the
  operator's own name for a break-glass entry, a name like `acme/ci` for an API key. Absent when nobody was
  identified, which is the gate being off, a run an operator started in-process, or a run created before runs
  recorded their caller; the three are deliberately indistinguishable rather than dressed up with an invented
  name. Attribution, beside the authorization `tenant_id` carries.
- **`confirmation_rate`**: `number | null`

  Confirmed issues over the issues reproduction ruled on. Available as soon as reproduction finishes, which
  is before `summary.confirmation_rate` exists; the two are the same number computed from the same lineage.
- **`cost_usd`**: `number | null`

  What this run's model calls cost: `actor_cost_usd` and `judge_cost_usd` together. NOT what Saram charges
  you - `billable_usd` is that: on your own keys this is your provider's bill, and on Saram's models it is Saram's
  own cost, shown so your Saram usage and your provider's invoice add up. Written when the run finishes. Absent
  means nothing priced the models, which is not the same as free. On a run finished before the judge was priced
  it is the persona loop's alone, and `judge_cost_usd` is absent beside it.
- **`created_by`**: `string | null`

  Which credential started it: `key_...` for an API key, `operator` for a break-glass entry or a run an
  operator started in-process. The id of the key rather than its name, so rotating a key leaves the runs it
  started still pointing at it.
- **`evidence_expired`**: `boolean`, default: `false`

  Whether that date has passed. `GET /api/v1/blobs` on this run's session evidence answers 404 once it
  has, and that 404 means "expired", not "never existed" - which is why the date is published beside it.
- **`finished_at`**: `string | null`, format: `date-time`
- **`held_usd`**: `number | null`

  What this run still reserves from your balance: the most its spec's budgets can use (the Usage ceiling),
  less each finished session's share. Released when the run's last session is settled. Absent when the run
  holds nothing - it has finished, or the run gate did not hold for it.
- **`judge_cost_usd`**: `number | null`

  Of `cost_usd`, the observer's model calls: the judge's reading of each session, attribution, the merge,
  and the reproduction's claim check and tie-break - a failed attempt included, because it was paid for. On
  today's models this is often the larger half. A retest you ask for later is not in it. Absent when none of
  those calls had a price, and on a run finished before they were priced.
- **`packs`**: `array of string`

  The distinct persona packs this run's sessions were drawn from, sorted: the cohorts it covers. A run whose
  personas have no pack lists none rather than an empty cohort.
- **`refunded_usd`**: `number | null`

  Of this run's Usage, the dollars given back because a session failed on Saram's side (the pool, a lost
  worker, a setup step in Saram's code: fault `saram`). Not in `usage_usd`. Absent wherever `usage_usd` is.
- **`repro_attempts`**: `integer | null`
- **`repro_gate_discards`**: `integer | null`

  How many things reproduction's gate threw away, of two kinds: claim-check quotes the cited evidence does
  not contain, each of which left its issue `ungrounded`, and replay attempts whose path diverged
  before the judged step, each of which ruled on nothing and left its issue `skipped`. Read beside
  `confirmation_rate`: that says how much of what the judge said survived the replay, this says how often the
  replay's own checker cited loosely or its path stopped being walkable. Zero is the expected value. Absent
  until reproduction has run, and on runs reproduced before this was recorded.
- **`repro_gate_ref`**: `string | null`

  The records behind `repro_gate_discards`, fetched through `GET /api/v1/blobs`: `{"run_id", "discards"}`,
  one discard each with its `session_id`, `kind`, `reason` and a `payload` naming the `issue_id`. A
  `claim_quote` payload carries the `quote`; a `path_diverged` one carries the `attempt`, the
  `attempt_session_id`, the original `step_id` it diverged at (null for the entry url), that step's `action`
  and `expect`, and the `url` the replay was on instead. Written even when empty, so an absent ref is
  reproduction that has not run, never "nothing thrown away".
- **`session_counts`**: `object`

  Session count per status for this run: pending, running, the artifact results, or cancelled.

  **Additional properties:**

  `integer`
- **`sessions_per_hour`**: `number | null`

  Sessions that ran a step, per hour of the execution phase (run start to last session finish).
- **`started_at`**: `string | null`, format: `date-time`
- **`status_reason`**: `string | null`

  Why a run ended `failed` or `cancelled`, in a sentence for the caller: the spec's refusal or the target's
  reset hook's, quoted; that Saram could not start the run, or could not finish judging its sessions; that every
  session errored; or `cancelled by signal`. Absent on a `completed` run.
- **`summary`**: [Summary](#scalar-schema-summary) | `null`

  The artifact's `summary` block, once the artifact is assembled.
- **`target_name`**: `string | null`
- **`tenant_id`**: `string | null`

  Who owns this run, and so who may read it: every route filters by the tenant the request's key belongs
  to, and a run outside it is a 404 rather than a 403. Always your own tenant, which is what makes it safe to
  publish - a response can only ever carry the id you are already authenticated as. Absent on runs created
  before runs recorded their tenant; those are readable by this deployment's own fleet tenant and by nobody
  else.
- **`usage`**: `number | null`

  This run's Usage: persona steps weighted by how long each took, summed over its sessions, to 0.001. LIVE
  while `usage_state` is `live`: settled sessions read what the ledger debited, and a session in flight its last
  heartbeat. Final once `usage_state` is `settled` or `refunded`, when it is the credit ledger's own figure.
  Absent on a finished run none of whose sessions was measured, which is not the same as none used.
- **`usage_state`**: `string | null`, possible values: `"live", "held", "settled", "refunded"`

  How final `usage` and `usage_usd` are. `held`: the run is open and holds `held_usd`, and no step is
  measured yet. `live`: the run is open, or has just finished and is being settled - the figures are so far and
  still rise. `settled`: every session is settled and the figures are the credit ledger's. `refunded`: settled,
  and every dollar of its Usage was given back because it failed on Saram's side.
- **`usage_usd`**: `number | null`

  What this run's Usage costs you in dollars, at your plan's Usage price when each session ran: charged from
  your prepaid balance, refunds already taken off - so a run that failed on Saram's side reads 0 and the money
  given back is `refunded_usd`. Moves with `usage` while the run is live. This is Saram's charge on a plan that
  prices Usage (Free, Pro); your model provider's bill is never in it. Absent when nothing prices this run's
  Usage - a plan billed in steps, where `billable_usd` is the charge - which is not the same as free.

<a id="scalar-example-129"></a>

**Generated example:**

```json
{
  "run_id": "",
  "status": "",
  "status_reason": "",
  "target": "",
  "target_name": "",
  "baseline_run_id": "",
  "tenant_id": "",
  "created_by": "",
  "caller": "",
  "confirmation_rate": 1,
  "repro_attempts": 1,
  "repro_gate_discards": 1,
  "repro_gate_ref": "",
  "billed_steps": 0,
  "billable_usd": 1,
  "cost_usd": 1,
  "actor_cost_usd": 1,
  "judge_cost_usd": 1,
  "usage": 1,
  "usage_usd": 1,
  "refunded_usd": 1,
  "held_usd": 1,
  "usage_state": "live",
  "sessions_per_hour": 1,
  "artifact_ref": "",
  "summary": {
    "personas_run": 1,
    "missions": 1,
    "sessions": 1,
    "completed": 1,
    "abandoned": 1,
    "blocked": 1,
    "errored": 1,
    "budget_exhausted": 0,
    "issues_total": 1,
    "issues_reproducible": 1,
    "issues_marker_matched": 1,
    "tester_errors": 1,
    "issues_new_vs_baseline": 1,
    "issues_resolved_vs_baseline": 1,
    "issues_persisting_vs_baseline": 1,
    "opportunities_total": 1,
    "verdicts": {
      "positive": 1,
      "negative": 1,
      "undecided": 1,
      "top_reasons": [
        ""
      ]
    },
    "confirmation_rate": 0,
    "reproduction": {
      "candidates": 1,
      "merged": 1,
      "evaluated": 1,
      "confirmed": 1,
      "inconclusive": 1,
      "excluded": 1
    },
    "gate_discards": 0,
    "path_diversity": 0,
    "cost_usd": 1,
    "actor_cost_usd": 0,
    "judge_cost_usd": 0,
    "wall_clock_s": 1
  },
  "session_counts": {
    "additionalProperty": 1
  },
  "packs": [
    ""
  ],
  "created_at": "",
  "started_at": "",
  "finished_at": "",
  "evidence_expires_at": "",
  "evidence_expired": false
}
```

<a id="scalar-schema-sessionlist"></a>

### SessionList

**Type:** `object`, no additional properties

A run's sessions, one per persona per mission.

- **`run_id` (required)**: `string`
- **`sessions` (required)**: array of [SessionRow](#scalar-schema-sessionrow)

<a id="scalar-example-130"></a>

**Generated example:**

```json
{
  "run_id": "",
  "sessions": [
    {
      "session_id": "",
      "persona_id": "",
      "pack": "",
      "tags": [
        ""
      ],
      "mission_id": "",
      "status": "",
      "result": "",
      "end_reason": "",
      "fault": "none",
      "steps": 1,
      "end_step": 1,
      "abandon_step": 1,
      "tokens_in": 1,
      "tokens_out": 1,
      "tokens_cache_read": 0,
      "tokens_cache_write": 0,
      "model": "",
      "cost_usd": 1,
      "price_source": "",
      "wall_clock_s": 1,
      "uncertain_steps": [
        1
      ],
      "resumed_at_steps": [
        1
      ],
      "verdict_decision": "",
      "outcome_read": "",
      "observer_error": "",
      "verdict_discarded": {
        "judge_decision": "positive",
        "gate_reason": ""
      },
      "heartbeat_step": 1,
      "last_heartbeat_at": "",
      "heartbeat_usage": 1,
      "usage": 1,
      "usage_usd": 1,
      "refunded_usd": 1,
      "waiting_for_seat": true,
      "waiting_since": "",
      "workflow_id": "",
      "browser_session_id": "",
      "agent_trace_ref": "",
      "surface_trace_ref": "",
      "video_ref": "",
      "video_url": "",
      "recorded_under_mask": false,
      "created_at": "",
      "finished_at": ""
    }
  ]
}
```

<a id="scalar-schema-sessionrow"></a>

### SessionRow

**Type:** `object`, no additional properties

One session of a run: one persona on one mission, in its own browser. What it ended as and whose fault
an error was, the tokens, cost and Usage it took, where it has got to while it runs, and the refs to its
step-by-step traces and video.

- **`created_at` (required)**: `string`, format: `date-time`
- **`end_step` (required)**: `integer`
- **`mission_id` (required)**: `string`
- **`persona_id` (required)**: `string`
- **`session_id` (required)**: `string`
- **`status` (required)**: `string`

  `pending`, `running`, an artifact result, or `cancelled`. Nothing reports `running` today: a session is
  recorded once, when it ends, so a session in flight still reads `pending` - see `heartbeat_step`, and
  `waiting_for_seat` for one that is queued for a browser seat rather than on a step.
- **`steps` (required)**: `integer`
- **`tokens_in` (required)**: `integer`

  The whole prompt plus the reply: `tokens_in` already includes both cache halves below.
- **`tokens_out` (required)**: `integer`
- **`wall_clock_s` (required)**: `number`
- **`abandon_step`**: `integer | null`
- **`agent_trace_ref`**: `string | null`
- **`browser_session_id`**: `string | null`
- **`cost_usd`**: `number | null`

  What this session's model calls cost. Absent when nothing prices its model; never 0 to mean "unknown".
- **`end_reason`**: `string | null`

  How the session ended: the loop's word for an ending the persona reached (`success_criteria_met`,
  `patience_exhausted`, `budget_steps`), and on an `errored` one a sentence that says what kind of failure it
  was - the target's, the model provider's (with the provider's own message), or Saram's - with `fault` beside
  it.
- **`fault`**: `string | null`, possible values: `"none", "saram", "provider", "target"`

  Whose failure ended the session. `none` for every ending the persona or the loop reached, and for a
  session a person cancelled; on an `errored` one, `saram` (the harness: the pool, a setup step, a lost
  worker), `provider` (the model API: an HTTP error or a transport failure after the retries, or a reply the
  loop could not act on) or `target` (the product: its entry page did not load, or its reset hook refused the
  session). What decides whether an errored session is charged. `null` while the session is open, and on a
  session recorded before this was classified - never read it as `none`.
- **`finished_at`**: `string | null`, format: `date-time`
- **`heartbeat_step`**: `integer | null`

  LIVE. The `step_id` the persona last reported, read live: a session is recorded once, when it ends, so
  this is the only field that says where a session in flight has got to. Best effort - absent on a finished
  session, and on a running one whose live state cannot be read. Compare it with the steps page's
  `last_step_id` to see how far the readable trace lags the browser: the trace records a step after the browser
  answers, so a step in flight shows here first.
- **`heartbeat_usage`**: `number | null`

  LIVE. This session's Usage so far, to 0.001, from the step times on its last heartbeat: persona steps
  weighted by how long each one took, measured the way the session's Usage will be when it ends. A report, not a
  charge. Best effort, as `heartbeat_step` - and also absent for a session on a worker that does not report step
  times.
- **`last_heartbeat_at`**: `string | null`, format: `date-time`

  When that heartbeat reached the server: how stale `heartbeat_step` is. Absent whenever it is.
- **`model`**: `string | null`
- **`observer_error`**: `string | null`

  Why nobody read this session, when nobody did, in Saram's words: the judge failure that left the session read
  as `unclear` - the model provider's status and own message when the provider refused, the `providers` entry's
  refusal when it did not resolve, Saram's sentence otherwise. Absent on every session a judge pass read,
  including one it found nothing in - which is the only way a reader tells a clean session from one no model ever
  saw.
- **`outcome_read`**: `string | null`

  What the judge read the session's outcome as, independent of what the loop reported.
- **`pack`**: `string | null`

  The persona's pack as the registry had it when the run was snapshotted - the session's cohort. Absent
  for a persona that declares none, and for a session recorded before sessions carried one.
- **`price_source`**: `string | null`

  Which price table produced `cost_usd`: this deployment's own, `env` (a bare model id's row) or `env/<kind>`
  (a row for the model on one provider kind), else `genai-prices/<version>/<provider>`.
- **`recorded_under_mask`**: `boolean`, default: `false`

  Whether the browser pool recorded this session under its mask: every field the persona typed into, every
  one-time-code field and every `data-saram-mask` element painted over in every screenshot and video frame it
  stored. Only such a session of yours can be shared by link while `ShareList.mask_required` is `true`: a share of
  any other is refused with a 409, so read the two together before offering one. `false` for every session
  recorded before the pool masked, and by a pool that does not.
- **`refunded_usd`**: `number | null`

  What was given back for this session because it failed on Saram's side (fault `saram`): its whole debit. 0
  otherwise. Absent wherever `usage_usd` is.
- **`result`**: `string | null`

  The artifact result once the loop reported; absent before that and on a session that never started.
- **`resumed_at_steps`**: `(array of integer) | null`

  The first step of each resumed browser execution, one per resume: the re-orienting `look` the loop
  takes when its activity is retried. `null` and `[]` read as above. A browser checkpoint carries a url and
  a storage state and nothing else, so each of these is where a new execution picked the experiment up, not
  where the same page carried on.
- **`surface_trace_ref`**: `string | null`
- **`tags`**: `array of string`

  The persona's tags at snapshot time, beside `pack`.
- **`tokens_cache_read`**: `integer`, default: `0`

  Of `tokens_in`, read from the prompt cache - billed at a tenth of the input rate on Anthropic.
- **`tokens_cache_write`**: `integer`, default: `0`

  Of `tokens_in`, written to the prompt cache.
- **`uncertain_steps`**: `(array of integer) | null`

  The steps of this session whose effect on the product nothing can attest: the call was dispatched, the
  response was lost, and the resume reconciled against the pool's step id rather than re-issuing it. `null`
  means nobody looked - a session recorded before the harness produced this, or one whose activity was lost
  before it or its checkpoint could report - and `[]` means the loop checked and every step is attested.
  Never read `null` as "none": it is the same three-way reading as `artifact.outcomes[].uncertain_steps`.
- **`usage`**: `number | null`

  This session's Usage, to 0.001: the credit ledger's figure once it is settled, measured from its recorded
  step times before that, and on a session in flight its last heartbeat (`heartbeat_usage`). Absent on a session
  that has not taken a step, and on one recorded without step times.
- **`usage_usd`**: `number | null`

  What this session's Usage costs you, net of any refund (`refunded_usd`). Absent when nothing prices it: a
  plan billed in steps.
- **`verdict_decision`**: `string | null`

  The judge's verdict decision once it has read the session: positive, negative, or undecided.
- **`verdict_discarded`**: [VerdictDiscarded](#scalar-schema-verdictdiscarded) | `null`

  NOT A VERDICT. The answer the judge gave about this session that the quote gate threw away, and why: set only
  when `verdict_decision` is absent on a session a judge read. Absent when the judge returned no verdict, which is
  the difference it is here for - both leave no decision. The decision is the judge's with no line of the persona's
  diary behind it, so it is never the persona's verdict and is never counted as one. Absent on a session observed
  before the control plane recorded it, whose gate ledger is the only record.
- **`video_ref`**: `string | null`
- **`video_url`**: `string | null`
- **`waiting_for_seat`**: `boolean | null`

  LIVE. `true` while this session is queued for a browser seat: the run has started it, but every seat in the
  browser pool is taken, so it has no browser and takes no steps until one frees - a run with more sessions than
  the pool has seats shows this on the rest, and they start in turn without spending an attempt. `false` while
  the session is not waiting: setting up, on a seat, or paused. Read live, best effort as `heartbeat_step` -
  absent on a finished session, on one the run has not started yet (its `max_concurrent_sessions` holds it
  back), and on one whose live state cannot be read. A session resuming after a
  pause or a pool failure queues the same way, so it can be `true` beside a `heartbeat_step`.
- **`waiting_since`**: `string | null`, format: `date-time`

  When the session was queued for the seat it is waiting for: how long it has waited. Absent whenever
  `waiting_for_seat` is not `true`.
- **`workflow_id`**: `string | null`

<a id="scalar-example-131"></a>

**Generated example:**

```json
{
  "session_id": "",
  "persona_id": "",
  "pack": "",
  "tags": [
    ""
  ],
  "mission_id": "",
  "status": "",
  "result": "",
  "end_reason": "",
  "fault": "none",
  "steps": 1,
  "end_step": 1,
  "abandon_step": 1,
  "tokens_in": 1,
  "tokens_out": 1,
  "tokens_cache_read": 0,
  "tokens_cache_write": 0,
  "model": "",
  "cost_usd": 1,
  "price_source": "",
  "wall_clock_s": 1,
  "uncertain_steps": [
    1
  ],
  "resumed_at_steps": [
    1
  ],
  "verdict_decision": "",
  "outcome_read": "",
  "observer_error": "",
  "verdict_discarded": {
    "judge_decision": "positive",
    "gate_reason": ""
  },
  "heartbeat_step": 1,
  "last_heartbeat_at": "",
  "heartbeat_usage": 1,
  "usage": 1,
  "usage_usd": 1,
  "refunded_usd": 1,
  "waiting_for_seat": true,
  "waiting_since": "",
  "workflow_id": "",
  "browser_session_id": "",
  "agent_trace_ref": "",
  "surface_trace_ref": "",
  "video_ref": "",
  "video_url": "",
  "recorded_under_mask": false,
  "created_at": "",
  "finished_at": ""
}
```

<a id="scalar-schema-severity"></a>

### Severity

**Type:** `string`, possible values: `"low", "medium", "high", "critical"`

How much the defect costs the people who meet it, from least to most: `low`, `medium`, `high`, `critical`. Rated by the judge from the sessions, and lowered one step when the replay did not reproduce the issue and it stands as persona-specific friction. The GitHub connector files only issues at or above its `min_severity_for_issue`.

<a id="scalar-schema-sharecreated"></a>

### ShareCreated

**Type:** `object`, no additional properties

A new public link to one session's replay, and the one and only sight of its URL.

Anyone holding `url` can watch the session's recording, which hides only what the browser pool masked when it
recorded it (`masked`): whatever else the browser showed is in it. Only a hash of the link's token is stored, so
no route shows the URL again; a lost link is replaced by minting another and revoking this one with
`POST /api/v1/shares/{id}/revoke`.

- **`capped_by_evidence` (required)**: `boolean`

  True when the run's evidence lifetime, and not the days asked for, set expires\_at: the link ends with the recording.
- **`created_at` (required)**: `string`, format: `date-time`
- **`created_by` (required)**: `string`

  key\_... for an API key, usr\_... for a person, operator for the operator.
- **`expires_at` (required)**: `string`, format: `date-time`
- **`id` (required)**: `string`

  shr\_...: the link's id, for listing and revoking it. Not the link itself.
- **`masked` (required)**: `boolean`

  True when the browser pool recorded the session under its mask: the value of every field the persona typed into, every one-time-code field and every data-saram-mask element are painted over in every frame. False only for a session of Saram's own, which is shared unmasked.
- **`run_id` (required)**: `string`
- **`session_id` (required)**: `string`
- **`url` (required)**: `string`

  The public link. Shown once, here, and never again by any route. Anyone who has it can watch this session until it expires or is revoked, with no sign-in.
- **`warning` (required)**: `string`

  What anyone holding the link will see, what in the recording is masked and what is not, and how to revoke it. Show it to whoever posts the link.

<a id="scalar-example-132"></a>

**Generated example:**

```json
{
  "id": "",
  "url": "",
  "run_id": "",
  "session_id": "",
  "created_at": "",
  "created_by": "",
  "expires_at": "",
  "capped_by_evidence": true,
  "masked": true,
  "warning": ""
}
```

<a id="scalar-schema-sharelist"></a>

### ShareList

**Type:** `object`, no additional properties

A run's public links, revoked and expired ones included, newest first.

- **`mask_required` (required)**: `boolean`

  Whether a session of yours is shared only when the browser pool recorded it under its mask (SessionRow\.recorded\_under\_mask): a share of any other is refused with a 409. True for every customer; false only for Saram's own sessions, which are shared unmasked.
- **`public_shares` (required)**: `boolean`

  Whether your tenant may publish sessions at all. Off, a new link is refused with a 403 and no existing link plays; their rows are kept and they play again, until their own expiry, once it is back on.
- **`run_id` (required)**: `string`
- **`shares` (required)**: array of [ShareSummary](#scalar-schema-sharesummary)

<a id="scalar-example-133"></a>

**Generated example:**

```json
{
  "run_id": "",
  "public_shares": true,
  "mask_required": true,
  "shares": [
    {
      "id": "",
      "run_id": "",
      "session_id": "",
      "token_hint": "",
      "state": "live",
      "created_at": "",
      "created_by": "",
      "caller": "",
      "expires_at": "",
      "revoked_at": "",
      "revoked_by": "",
      "view_count": 1,
      "last_viewed_at": ""
    }
  ]
}
```

<a id="scalar-schema-sharesummary"></a>

### ShareSummary

**Type:** `object`, no additional properties

One public link to a session's replay, as the list and the revocation answer it. Never the link itself.

- **`created_at` (required)**: `string`, format: `date-time`
- **`created_by` (required)**: `string`
- **`expires_at` (required)**: `string`, format: `date-time`
- **`id` (required)**: `string`
- **`run_id` (required)**: `string`
- **`session_id` (required)**: `string`
- **`state` (required)**: `string`, possible values: `"live", "expired", "revoked"`

  live plays, expired and revoked do not. Read it beside ShareList.public\_shares: while your tenant's sharing is off no link plays, whatever its state.
- **`token_hint` (required)**: `string`

  The link's last four characters, to tell your links apart.
- **`view_count` (required)**: `integer`

  How many times the replay was opened. Nothing about who opened it is kept.
- **`caller`**: `string | null`

  Who minted it, in words, when the credential had a name.
- **`last_viewed_at`**: `string | null`, format: `date-time`
- **`revoked_at`**: `string | null`, format: `date-time`
- **`revoked_by`**: `string | null`

  Who revoked it: any principal of your tenant that may write runs can.

<a id="scalar-example-134"></a>

**Generated example:**

```json
{
  "id": "",
  "run_id": "",
  "session_id": "",
  "token_hint": "",
  "state": "live",
  "created_at": "",
  "created_by": "",
  "caller": "",
  "expires_at": "",
  "revoked_at": "",
  "revoked_by": "",
  "view_count": 1,
  "last_viewed_at": ""
}
```

<a id="scalar-schema-sharewrite"></a>

### ShareWrite

**Type:** `object`, no additional properties

The optional body of `POST /runs/{run_id}/sessions/{session_id}/shares`.

- **`expires_in_days`**: `integer | null`, minimum: `1`, maximum: `90`

  How many days the link plays, 1 to 90; 30 when omitted. Never past the run's evidence\_expires\_at, whatever you ask for: a link ends when the recording it plays is deleted. There is no link that does not expire, and no extending one: mint a new link instead.

<a id="scalar-example-135"></a>

**Generated example:**

```json
{
  "expires_in_days": 1
}
```

<a id="scalar-schema-specvalidation"></a>

### SpecValidation

**Type:** `object`, no additional properties

`POST /api/v1/runs/validate`: what `POST /runs` would make of this spec, without making it.

Everything `POST /runs` would refuse is the same 422 here: the contract, the cross-references
inside `providers`, and the caller's own model provider - including a ChatGPT login the gateway no longer
holds. A 200 means the spec is accepted as written.

- **`sessions` (required)**: array of [PlannedSession](#scalar-schema-plannedsession)
- **`spec` (required)**: `object`

  The spec as the run would store it.
- **`cost_ceiling`**: [CostCeiling](#scalar-schema-costceiling) | `null`

  What the run would cost: the most it can bill on your plan (usd), with a one-line basis and who bills its model calls. A ceiling, not a forecast. On every plan; absent only for a caller this deployment has no tenant for.
- **`run_id`**: `string | null`

  The run id the spec carries. Absent means `POST /runs` would assign one.
- **`usage_ceiling`**: [UsageCeiling](#scalar-schema-usageceiling) | `null`

  The most Usage this run can use and the balance it is held against. Absent when this deployment's run gate is off, or your plan is not billed in Usage.
- **`warnings`**: `array of string`

  What the dry run can say about the spec but does not refuse it for, one sentence each, the ones about your ChatGPT login first. Today: providers entries on a ChatGPT login whose access token has lapsed (the gateway refreshes it on the run's first model call, and the run fails if that refresh is refused) or whose gateway this API could not ask; a providers entry whose model this platform's price table has no price for, which is either a model too new for the table or a misspelt id that fails at the run's first model call; and a budget.max\_concurrent\_sessions above what your plan runs at once, which the run honours by running fewer sessions at a time.

<a id="scalar-example-136"></a>

**Generated example:**

```json
{
  "run_id": "",
  "spec": {
    "additionalProperty": "anything"
  },
  "sessions": [
    {
      "session_id": "",
      "persona_id": "",
      "mission_id": "",
      "entry_url": "",
      "account_secret_ref": ""
    }
  ],
  "warnings": [
    ""
  ],
  "cost_ceiling": {
    "usd": 1,
    "billed_in": "usage",
    "plan": "",
    "price_usd": 1,
    "sessions": 1,
    "steps_per_session": 1,
    "wall_clock_s_per_session": 1,
    "usage_per_session": 1,
    "usage": 1,
    "basis": "",
    "model_calls": ""
  },
  "usage_ceiling": {
    "sessions": 1,
    "steps_per_session": 1,
    "wall_clock_s_per_session": 1,
    "usage_per_session": 1,
    "usage": 1,
    "usage_usd": 1,
    "usage_price_usd": 1,
    "available_usd": 1,
    "covered": true,
    "steps_per_session_that_fit": 1,
    "enforced": true
  }
}
```

<a id="scalar-schema-startedrun"></a>

### StartedRun

**Type:** `object`, no additional properties

A run accepted and started. Follow it by its `run_id`: `GET /api/v1/runs/{run_id}`, or the MCP tools
`get_run_status` and `wait_for_run`.

- **`run_id` (required)**: `string`

  The run's id: the handle every other run route and tool takes.
- **`workflow_id` (required)**: `string`

  The id of the workflow executing the run, for support requests.

<a id="scalar-example-137"></a>

**Generated example:**

```json
{
  "run_id": "",
  "workflow_id": ""
}
```

<a id="scalar-schema-status"></a>

### Status

**Type:** `string`, possible values: `"verified", "marker_matched", "inconclusive", "unverified"`

Best-supported first. `verified`: a replay matched the failure marker at `action_step` AND the claim check answered `supported` with a verbatim quote from the cited steps (`check`) AND the failure is attributed to the product (`attribution`) - or `method` is `assertion` or `manual` and the same two conditions hold. This and only this may file a ticket alone, and `reproducible` is true exactly when this reads `verified`. `marker_matched` (0.5): a replay matched the failure marker at `action_step`; the claim as titled is unchecked, unsupported, or the check could not quote. The step failed again; whether it failed for the stated reason is not established, so it never files alone. `inconclusive`: a check ran and settled nothing (the marker did not appear again, the two attempts disagreed) - not a refutation. `unverified`: nothing checked it, because there was nothing to check, reproduction never ruled, or the finding is a tester error. A wrong total or a select that resets is `unverified` by construction, not disproven. Default and safe reading when the block is absent. This enum drives an action - it is the ticket-filing gate, like `issue.severity` - so it stays closed for readers too and adding a member is a breaking change: a gate must refuse a value it cannot interpret rather than guess at one, which is why `marker_matched` made 0.5 a new family.

<a id="scalar-schema-stepref"></a>

### StepRef

**Type:** `object`

One step of one session: the session's id and the step's number within it. A finding points at the steps it rests on, and that session's step events say what happened at each.

- **`session_id` (required)**: `string`
- **`step_id` (required)**: `integer`

<a id="scalar-example-138"></a>

**Generated example:**

```json
{
  "session_id": "",
  "step_id": 1
}
```

<a id="scalar-schema-steptiming"></a>

### StepTiming

**Type:** `object`, no additional properties

How long the browser took over one step, in milliseconds. Each is absent when the browser did not
measure it.

- **`action_to_settle_ms`**: `integer | null`
- **`longest_task_ms`**: `integer | null`
- **`navigation_ms`**: `integer | null`

<a id="scalar-example-139"></a>

**Generated example:**

```json
{
  "action_to_settle_ms": 1,
  "longest_task_ms": 1,
  "navigation_ms": 1
}
```

<a id="scalar-schema-steptokens"></a>

### StepTokens

**Type:** `object`, no additional properties

The model tokens behind one step's decision: prompt, reply, and the part of the prompt read from the
cache. Each is absent when the provider did not report it.

- **`cached`**: `integer | null`
- **`input`**: `integer | null`
- **`output`**: `integer | null`

<a id="scalar-example-140"></a>

**Generated example:**

```json
{
  "input": 1,
  "output": 1,
  "cached": 1
}
```

<a id="scalar-schema-stepspage"></a>

### StepsPage

**Type:** `object`, no additional properties

A session's steps, aligned. Readable while the session runs, not only after it ends.

One step is written as the persona takes it, so a caller polling `?after=<last_step_id>` tails a live
session: each call returns the steps written since the last one, and `status` says whether more are coming.
`heartbeat_step` beside it is where the browser has got to, which is at or one ahead of `last_step_id` while
a step is in flight.

- **`mission_id` (required)**: `string`
- **`persona_id` (required)**: `string`
- **`run_id` (required)**: `string`
- **`session_id` (required)**: `string`
- **`status` (required)**: `string`

  `in_flight` while the session has not been recorded yet: the steps below are what the persona and the
  browser have written so far, both halves joined, and more will follow. The one thing a live page lacks is the
  session video, which is stored when the browser closes: `video_ref` and each step's window are there,
  `video_url` is absent until the page is final. A step whose surface half is missing on a live page is one
  the browser has not answered yet or a real gap, as on a final one. Otherwise the session's own
  status (`completed`, `abandoned`, `blocked`, `errored`, `budget_exhausted`, `cancelled`), and the page is
  final.
- **`steps` (required)**: array of [AlignedStep](#scalar-schema-alignedstep)
- **`total_steps` (required)**: `integer`

  Steps in the whole trace, before the `from`/`to` window - not the count of `steps`.
- **`agent_trace_ref`**: `string | null`
- **`first_step_id`**: `integer | null`
- **`from_step`**: `integer | null`

  The inclusive lower bound in effect, whether it came from `from` or `after` + 1.
- **`heartbeat_step`**: `integer | null`

  LIVE. The `step_id` the persona last reported: where the browser has got to, which is at or one ahead of
  `last_step_id` while a step is in flight - a step is written after the browser answers, so a step in flight
  shows here first. Only asked for while `status` is `in_flight`: absent on a final page, and on a live one
  whose live state cannot be read.
- **`last_heartbeat_at`**: `string | null`, format: `date-time`

  When that heartbeat reached the server: how stale `heartbeat_step` is. Absent whenever it is.
- **`last_step_id`**: `integer | null`

  The last step in the whole trace. On an `in_flight` page this is what a tailing caller passes back as
  `after` to get the next batch; absent means the session has written no step yet.
- **`surface_trace_ref`**: `string | null`
- **`to_step`**: `integer | null`
- **`video_ref`**: `string | null`
- **`video_url`**: `string | null`
- **`waiting_for_seat`**: `boolean | null`

  LIVE. `true` while the session is queued for a browser seat, which is why a live page can have no steps at
  all: every seat in the pool is taken, and it takes no steps until one frees. A session resuming after a pause
  or a pool failure queues the same way, so it can be `true` on a page that has steps. Only asked for while
  `status` is `in_flight`: absent on a final page, and on a live one whose live state cannot be read.
- **`waiting_since`**: `string | null`, format: `date-time`

  When the session was queued for the seat it is waiting for: how long it has waited. Absent whenever
  `waiting_for_seat` is not `true`.
- **`workflow_id`**: `string | null`

  The session's workflow id, as `SessionRow.workflow_id`: what `heartbeat_step` was read off.

<a id="scalar-example-141"></a>

**Generated example:**

```json
{
  "run_id": "",
  "session_id": "",
  "persona_id": "",
  "mission_id": "",
  "status": "",
  "heartbeat_step": 1,
  "last_heartbeat_at": "",
  "waiting_for_seat": true,
  "waiting_since": "",
  "workflow_id": "",
  "from_step": 1,
  "to_step": 1,
  "total_steps": 1,
  "first_step_id": 1,
  "last_step_id": 1,
  "agent_trace_ref": "",
  "surface_trace_ref": "",
  "video_ref": "",
  "video_url": "",
  "steps": [
    {
      "step_id": 1,
      "has_agent": true,
      "has_surface": true,
      "terminal": true,
      "ts_agent": "",
      "action_type": "",
      "target_description": "",
      "action_args": {
        "additionalProperty": "anything"
      },
      "narration": "",
      "reasoning_summary": "",
      "perceived_state": "",
      "observation_mode": "",
      "tolerance_state": {
        "additionalProperty": "anything"
      },
      "latency_ms": 1,
      "tokens": {
        "input": 1,
        "output": 1,
        "cached": 1
      },
      "model": "",
      "end_reason": "",
      "result": "",
      "field": {
        "additionalProperty": "anything"
      },
      "ts_surface": "",
      "url": "",
      "route": "",
      "viewport": "",
      "inputs": [
        {
          "additionalProperty": "anything"
        }
      ],
      "files_attached": [
        {
          "additionalProperty": "anything"
        }
      ],
      "dialogs": [
        {
          "additionalProperty": "anything"
        }
      ],
      "downloads": [
        {
          "additionalProperty": "anything"
        }
      ],
      "network_failures": [
        {
          "additionalProperty": "anything"
        }
      ],
      "console_errors": [
        {
          "additionalProperty": "anything"
        }
      ],
      "dom_mutation_count": 1,
      "timing": {
        "action_to_settle_ms": 1,
        "longest_task_ms": 1,
        "navigation_ms": 1
      },
      "screenshot_ref": "",
      "screenshot_url": "",
      "video_ref": "",
      "video_url": "",
      "ts_start_s": 1,
      "ts_end_s": 1
    }
  ]
}
```

<a id="scalar-schema-subscriptionstate"></a>

### SubscriptionState

**Type:** `object`, no additional properties

The tenant's Saram Pro subscription as Stripe last reported it, mirrored so the billing screen can say
"Pro renews on Oct 1" or "Pro ends on Oct 1" without asking Stripe on every read.

- **`cancel_at_period_end` (required)**: `boolean`

  True after Pro was cancelled (DELETE /billing/subscription): it stays Pro until current\_period\_end, then the tenant moves to Free. POST /billing/subscription takes the cancellation back.
- **`status` (required)**: `string`

  Stripe's: `incomplete` until the first invoice is paid, then `active`; `past_due` when a renewal failed; `canceled` once it has ended, `incomplete_expired` for a checkout nobody paid.
- **`current_period_end`**: `string | null`, format: `date-time`

  When the month already paid for ends: the renewal date, or the date Pro ends when cancel\_at\_period\_end is true. Absent until Stripe has said.

<a id="scalar-example-142"></a>

**Generated example:**

```json
{
  "status": "",
  "cancel_at_period_end": true,
  "current_period_end": ""
}
```

<a id="scalar-schema-summary"></a>

### Summary

**Type:** `object`, no additional properties

Counts over the run. The result buckets (completed, abandoned, blocked, errored, budget\_exhausted) partition `sessions`: one bucket per value of outcomes\[].result, every session in exactly one, so they sum to `sessions`. `verdicts` has the same denominator.

- **`abandoned` (required)**: `integer`
- **`blocked` (required)**: `integer`
- **`completed` (required)**: `integer`
- **`issues_total` (required)**: `integer`
- **`personas_run` (required)**: `integer`
- **`sessions` (required)**: `integer`
- **`actor_cost_usd`**: `number | null`, minimum: `0`
- **`budget_exhausted`**: `integer | null`, default: `0`
- **`confirmation_rate`**: `number | null`, minimum: `0`, maximum: `1`
- **`cost_usd`**: `number | null`
- **`errored`**: `integer | null`
- **`gate_discards`**: `integer | null`, default: `0`, minimum: `0`
- **`issues_marker_matched`**: `integer | null`
- **`issues_new_vs_baseline`**: `integer | null`
- **`issues_persisting_vs_baseline`**: `integer | null`
- **`issues_reproducible`**: `integer | null`
- **`issues_resolved_vs_baseline`**: `integer | null`
- **`judge_cost_usd`**: `number | null`, minimum: `0`
- **`missions`**: `integer | null`
- **`opportunities_total`**: `integer | null`
- **`path_diversity`**: `number | null`, minimum: `0`, maximum: `1`
- **`reproduction`**: [Reproduction](#scalar-schema-reproduction) | `null`

  The counts behind `confirmation_rate`, so a reader can see its denominator instead of trusting it. Funnel stages and readings of one set of verdicts, not a partition: `candidates` are the per-session findings the judge raised, merging folds them into `merged`, reproduction ruled on `evaluated` of those and confirmed `confirmed`. The two overlaps are deliberate. A `flaky` finding is in `evaluated` (the experiment ran) and in `inconclusive` (it settled nothing). `excluded` is a subset of the unconfirmed - the ones dropped for having no persona voice left once the replay did not confirm them - and they are exactly the findings this artifact no longer carries, so `merged - excluded` is `issues_total`.
- **`tester_errors`**: `integer | null`
- **`verdicts`**: [Verdicts](#scalar-schema-verdicts) | `null`

  Cohort-level read of persona verdicts. Subjective, derived from outcomes\[].verdict. positive + negative + undecided = sessions: a session with no grounded verdict is undecided, not absent. That includes a session whose judge answered and had the answer discarded by the quote gate: its row names it in `outcomes[].verdict_discarded`, and it is still counted here as undecided, because this tally counts only verdicts grounded in the persona's words.
- **`wall_clock_s`**: `integer | null`

<a id="scalar-example-143"></a>

**Generated example:**

```json
{
  "personas_run": 1,
  "missions": 1,
  "sessions": 1,
  "completed": 1,
  "abandoned": 1,
  "blocked": 1,
  "errored": 1,
  "budget_exhausted": 0,
  "issues_total": 1,
  "issues_reproducible": 1,
  "issues_marker_matched": 1,
  "tester_errors": 1,
  "issues_new_vs_baseline": 1,
  "issues_resolved_vs_baseline": 1,
  "issues_persisting_vs_baseline": 1,
  "opportunities_total": 1,
  "verdicts": {
    "positive": 1,
    "negative": 1,
    "undecided": 1,
    "top_reasons": [
      ""
    ]
  },
  "confirmation_rate": 0,
  "reproduction": {
    "candidates": 1,
    "merged": 1,
    "evaluated": 1,
    "confirmed": 1,
    "inconclusive": 1,
    "excluded": 1
  },
  "gate_discards": 0,
  "path_diversity": 0,
  "cost_usd": 1,
  "actor_cost_usd": 0,
  "judge_cost_usd": 0,
  "wall_clock_s": 1
}
```

<a id="scalar-schema-technicalliteracy"></a>

### TechnicalLiteracy

**Type:** `string`, possible values: `"low", "medium", "high"`

How comfortable the persona is with technology. `low`: not technical; keys, tokens and APIs mean nothing to them. `medium`: finds their way around an app's settings without being technical. `high`: technical, and notices when a key or a token is handled badly.

<a id="scalar-schema-testererror"></a>

### TesterError

**Type:** `object`, no additional properties

What the persona did that explains this failure. Present exactly when `claim.attribution` is `tester`, and such an issue is in `tester_errors[]`, never in `issues[]`. The quote is the evidence line that shows the persona's action - the input echo, the step head with its args, a diary line, a browser line - copied exactly from the ledger and gated the same way a diary quote is; a `tester` answer whose quote does not gate falls back to `undetermined` and this block is not written.

- **`kind` (required)**: `string`

  Schema: `Kind1`. Known values, open (more may be added): `mistyped_credential`, `mistyped_input`, `wrong_element`, `placeholder_as_value`, `ignored_instruction`, `other`.

  `mistyped_credential`: typed an email or password that differs from the one the account gave it (decided by code, comparing the typed text with the account; no secret enters a prompt or this document). `mistyped_input`: typed something other than what the brief or the screen asked for. `wrong_element`: acted on an element other than the one the title is about, or a non-interactive one. `placeholder_as_value`: read placeholder text as a pre-filled value and tried to clear or fix it. `ignored_instruction`: skipped something the screen visibly asked for first. `other`: something else the persona did that explains it. Not a tester error, by rule: a persona misreading the product's intent when a reasonable customer would misread it the same way - that is `confusion`, and it is the product's. A classification: a later kind is additive.
- **`quote` (required)**: `string`
- **`decided_by`**: `string | null`

  Schema: `DecidedBy`. Known values, open (more may be added): `code`, `judge`.

  `code`: a mechanical comparison decided it (the credential check). `judge`: the observer's attribution model call under its own instructions decided it. A classification.
- **`quote_step`**: [QuoteStep](#scalar-schema-quotestep) | `null`

  The step whose ledger block the quote came from.
- **`reason`**: `string | null`

<a id="scalar-example-144"></a>

**Generated example:**

```json
{
  "kind": "mistyped_credential",
  "quote": "",
  "quote_step": {
    "session_id": "",
    "step_id": 1
  },
  "decided_by": "code",
  "reason": ""
}
```

<a id="scalar-schema-testererrorlist"></a>

### TesterErrorList

**Type:** `object`, no additional properties

The run's tester errors: findings the harness attributed to its own persona, never to the product.

Issue-shaped - the same id, evidence and screenshots an issue carries - and never an issue: not in
`IssueList`, not in `issues_total`, not reproduced, never a ticket. They are served so a customer can see
why a session stalled on their own test setup (a mistyped credential, the wrong element) instead of reading
the stall as the product's.

- **`advisory` (required)**: `string`

  How to read this whole answer: harness-attributed, never a defect. What it may and may not be used
  for.
- **`classified` (required)**: `boolean`

  Whether the run's artifact sorts findings by whose failure they are at all. Empty with this false is an
  artifact from before the classification (contracts 0.5), or no artifact yet: nobody asked. Empty with this
  true is a run that asked and found none.
- **`reading` (required)**: array of [TesterErrorReading](#scalar-schema-testererrorreading)

  One entry per record, in the same order and naming its `tester_error_id`.
- **`run_id` (required)**: `string`
- **`status` (required)**: `string`

  The run's status. An empty `tester_errors` is only a result once this is terminal.
- **`subjective_fields` (required)**: `array of string`

  Fields on each record that are somebody's words rather than an observation: the persona's narration, and
  `tester_error.reason`, the harness's sentence about its own persona.
- **`tester_errors` (required)**: array of [Issue](#scalar-schema-issue)

  The artifact's `tester_errors[]`, each the contract's own issue object with its `tester_error` block.

<a id="scalar-example-145"></a>

**Generated example:**

```json
{
  "run_id": "",
  "status": "",
  "classified": true,
  "tester_errors": [
    {
      "id": "",
      "type": "blocker",
      "severity": "low",
      "reproducible": true,
      "reproduction": {
        "attempted": true,
        "result": "reproduced",
        "attempts": 1,
        "notes": ""
      },
      "persona_specific": true,
      "claim": {
        "action_step": {
          "session_id": "",
          "step_id": 1
        },
        "expected": "",
        "observed": "",
        "verification": {
          "status": "verified",
          "method": "marker",
          "markers": [
            ""
          ],
          "note": ""
        },
        "attribution": "product",
        "check": {
          "verdict": "supported",
          "quote": "",
          "quote_step": {
            "session_id": "",
            "step_id": 1
          },
          "reason": "",
          "checked_by": "judge"
        }
      },
      "tester_error": {
        "kind": "mistyped_credential",
        "quote": "",
        "quote_step": {
          "session_id": "",
          "step_id": 1
        },
        "decided_by": "code",
        "reason": ""
      },
      "affected_sessions": [
        ""
      ],
      "affected_personas": [
        ""
      ],
      "affected_count": 1,
      "affected_fraction": 0,
      "reached_sessions": [
        ""
      ],
      "first_seen_step": 1,
      "location": {
        "route": "",
        "selector": "",
        "element_description": "",
        "viewport": ""
      },
      "title": "",
      "description": "",
      "persona_rationale": "",
      "contradiction": {
        "a": {
          "step_id": 1,
          "session_id": "",
          "text": ""
        },
        "b": {
          "step_id": 1,
          "session_id": "",
          "text": ""
        }
      },
      "evidence": {
        "step_refs": [
          {
            "session_id": "",
            "step_id": 1
          }
        ],
        "screenshots": [
          ""
        ],
        "video": {
          "ref": "",
          "ts_start_s": 1,
          "ts_end_s": 1
        },
        "dom_snapshot": "",
        "network": [
          {
            "additionalProperty": "anything"
          }
        ],
        "console": [
          {
            "additionalProperty": "anything"
          }
        ]
      },
      "repro_steps": [
        {
          "action": "",
          "target": "",
          "value": "",
          "replace": true,
          "expect": ""
        }
      ],
      "preconditions": [
        ""
      ],
      "baseline_status": "new",
      "confidence": 0,
      "suggested_owner": "",
      "connector_refs": {
        "additionalProperty": ""
      }
    }
  ],
  "reading": [
    {
      "tester_error_id": "",
      "label": "",
      "may_file_ticket_alone": false
    }
  ],
  "advisory": "",
  "subjective_fields": [
    ""
  ]
}
```

<a id="scalar-schema-testererrorreading"></a>

### TesterErrorReading

**Type:** `object`, no additional properties

How one record of `TesterErrorList.tester_errors` reads: what the persona did, and that it is never a
ticket.

- **`label` (required)**: `string`

  What the persona did, in the words every surface uses: the sentence the local MCP server, the GitHub PR
  comment and the dashboard all show for this kind.
- **`tester_error_id` (required)**: `string`
- **`may_file_ticket_alone`**: `boolean`, const: `false`, default: `false`

  False for every record in this array, by construction. Stated rather than left to be inferred from an
  absent field, because a reader that has to work it out is a reader that might not.

<a id="scalar-example-146"></a>

**Generated example:**

```json
{
  "tester_error_id": "",
  "label": "",
  "may_file_ticket_alone": false
}
```

<a id="scalar-schema-tolerance"></a>

### Tolerance

**Type:** `object`, no additional properties

How much the persona puts up with before giving up. Saram counts, step by step, the steps without visible progress, the errors and slow responses, and the moments with no clear next action, and ends the session `abandoned` the moment one crosses its threshold here; the model playing the persona cannot argue with the count. The persona is also told these limits in their own terms.

- **`error_threshold` (required)**: `integer`, minimum: `0`
- **`patience_steps` (required)**: `integer`, minimum: `1`
- **`abandon_behavior`**: `string | null`, possible values: `"silent", "narrates_reason"`, default: `"narrates_reason"`

  Schema: `AbandonBehavior`.

  What the persona's diary says when they give up, on one task or on the whole session. `narrates_reason` (the default): they say why before they stop. `silent`: they simply stop.
- **`attempts_per_subtask`**: `integer | null`, minimum: `1`
- **`confusion_threshold`**: `integer | null`, minimum: `1`
- **`latency_sensitivity_ms`**: `integer | null`, minimum: `0`

<a id="scalar-example-147"></a>

**Generated example:**

```json
{
  "patience_steps": 1,
  "error_threshold": 0,
  "latency_sensitivity_ms": 0,
  "confusion_threshold": 1,
  "attempts_per_subtask": 1,
  "abandon_behavior": "narrates_reason"
}
```

<a id="scalar-schema-topuprecord"></a>

### TopUpRecord

**Type:** `object`, no additional properties

One top-up: where it stands, and while it is open, the secret the Payment Element pays it with.

`pending` (the invoice is being made), `open` (awaiting payment), then `paid` (credited to the balance),
`failed` (declined, or no invoice could be made), `requires_action` (an off-session charge the bank wants the
person to authenticate) or `void` (unpaid after a day). Credit is added once, when Stripe says the invoice is
paid - never on the browser's word. A top-up that ended unpaid says why in `failure_code` and
`failure_message`.

- **`amount_usd` (required)**: `number`
- **`created_at` (required)**: `string`, format: `date-time`
- **`id` (required)**: `string`
- **`origin` (required)**: `string`, possible values: `"manual", "auto"`
- **`status` (required)**: `string`, possible values: `"pending", "open", "paid", "failed", "requires_action", "void"`
- **`client_secret`**: `string | null`

  What the Payment Element confirms in the browser (stripe.confirmPayment / confirmSetup), where SCA happens. Present only while there is something to confirm. Saram never sees the card.
- **`failure_code`**: `string | null`

  Why a `failed`, `requires_action` or `void` top-up did not go through, for a screen to branch on. When the card was refused it is the bank's reason as Stripe names it - the `decline_code` (`generic_decline`, `insufficient_funds`, ...) or, when there is none, Stripe's error `code` (`card_declined`, `expired_card`, ...): another card may go through. Otherwise it is one of Saram's own: `requires_action` (the bank wants the person to confirm an automatic refill, which only a top-up by hand can do), `payment_failed` (an automatic refill's payment did not complete), `expired` (unpaid after a day), `voided` (the invoice was cancelled before it was paid) or `not_started` (the payment could not be started; try again later). Nothing is charged for any of them. Absent while in flight and once paid.
- **`failure_message`**: `string | null`

  The same, in a sentence written to be shown to the person: the bank's own words as Stripe gives them for the cardholder ('Your card was declined.'), or Saram's for its own codes.
- **`finished_at`**: `string | null`, format: `date-time`
- **`invoice_id`**: `string | null`

<a id="scalar-example-148"></a>

**Generated example:**

```json
{
  "id": "",
  "amount_usd": 1,
  "origin": "manual",
  "status": "pending",
  "invoice_id": "",
  "client_secret": "",
  "failure_code": "",
  "failure_message": "",
  "created_at": "",
  "finished_at": ""
}
```

<a id="scalar-schema-topupwrite"></a>

### TopUpWrite

**Type:** `object`, no additional properties

`POST /api/v1/billing/top-ups`: how much credit to buy, in whole dollars.

- **`amount_usd` (required)**: `integer`, minimum: `10`, maximum: `500`

  Whole dollars of prepaid credit: the invoice reads 'Saram usage credit, N x $1.00', with tax on top where it applies. Counts against the monthly budget, manual and automatic top-ups together.

<a id="scalar-example-149"></a>

**Generated example:**

```json
{
  "amount_usd": 10
}
```

<a id="scalar-schema-trustdefaults"></a>

### TrustDefaults

**Type:** `string`, possible values: `"low", "medium", "high"`

Accepts prefilled/default choices vs. checks everything

<a id="scalar-schema-type"></a>

### Type

**Type:** `string`

Known values, open (more may be added): `blocker`, `friction`, `confusion`, `bug`, `contradiction`.

Defect classes only. A moment that worked well is an opportunity of kind `delight`, never an issue: it has no failure for reproduction to replay.

**Example:**

```json
"blocker"
```

**Example:**

```json
"friction"
```

**Example:**

```json
"confusion"
```

**Example:**

```json
"bug"
```

**Example:**

```json
"contradiction"
```

<a id="scalar-schema-usageceiling"></a>

### UsageCeiling

**Type:** `object`, no additional properties

The most Usage a run on this spec can use, and the balance `POST /runs` would hold it against.

Computed the way `POST /runs` holds the ceiling, at the same moment's balance, so a run this says is covered
is one that starts - unless another run takes the balance between the two calls.

- **`available_usd` (required)**: `number`

  The balance less what the tenant's runs in flight already hold.
- **`covered` (required)**: `boolean`

  Whether `available_usd` covers `usage_usd`.
- **`enforced` (required)**: `boolean`

  Whether `POST /runs` refuses this spec with a 402 when `covered` is false. False while the gate runs in
  shadow, and always for Saram's own fleet.
- **`sessions` (required)**: `integer`

  Sessions the spec plans, one per persona per mission.
- **`steps_per_session` (required)**: `integer`
- **`steps_per_session_that_fit` (required)**: `integer`

  The largest `budget.steps_per_session` that `available_usd` covers with the rest of the spec as written;
  0 when not even one step per session does.
- **`usage` (required)**: `number`

  `sessions x usage_per_session`: the run's ceiling, which `POST /runs` holds against the balance.
- **`usage_per_session` (required)**: `number`

  The most Usage one session can use: every step at its most, or the wall clock spent in full.
- **`usage_price_usd` (required)**: `number`
- **`usage_usd` (required)**: `number`

  The ceiling in dollars, at the plan's price per Usage.
- **`wall_clock_s_per_session`**: `integer | null`

  The spec's own wall-clock budget. Absent when it names none, and then only the step budget bounds a
  session: every step is counted at the most seconds a step is charged for.

<a id="scalar-example-150"></a>

**Generated example:**

```json
{
  "sessions": 1,
  "steps_per_session": 1,
  "wall_clock_s_per_session": 1,
  "usage_per_session": 1,
  "usage": 1,
  "usage_usd": 1,
  "usage_price_usd": 1,
  "available_usd": 1,
  "covered": true,
  "steps_per_session_that_fit": 1,
  "enforced": true
}
```

<a id="scalar-schema-usagechargerun"></a>

### UsageChargeRun

**Type:** `object`, no additional properties

One run's Usage in the month, from the credit ledger: what it used and what that cost, refunds apart.

- **`refunded_usd` (required)**: `number`

  Given back for its sessions that failed on Saram's side; each one is also in refunds.
- **`run_id` (required)**: `string`
- **`sessions` (required)**: `integer`

  Its persona sessions settled this month.
- **`settled_at` (required)**: `string`, format: `date-time`

  When the run's last session was settled this month: the moment its Usage was charged.
- **`usage` (required)**: `number`

  The Usage its settled sessions used, refunded ones included, to 0.001.
- **`usage_usd` (required)**: `number`

  What that Usage cost you, net: refunded\_usd is already taken off.
- **`caller`**: `string | null`

  Who started it, as GET /runs reports it. Absent when nobody was identified.

<a id="scalar-example-151"></a>

**Generated example:**

```json
{
  "run_id": "",
  "caller": "",
  "settled_at": "",
  "sessions": 1,
  "usage": 1,
  "usage_usd": 1,
  "refunded_usd": 1
}
```

<a id="scalar-schema-usagecharges"></a>

### UsageCharges

**Type:** `object`, no additional properties

The month's prepaid Usage, from Saram's credit ledger: what your personas used and what it cost you, by run
and by persona, and the sessions that were not charged.

Only a plan that prices Usage (Free, Pro) has rows here; a plan billed in steps reports zeros, and its charge
is `billable_usd` beside this. A session is in the month it was settled in, which is the month it finished.

- **`personas`**: array of [UsagePersona](#scalar-schema-usagepersona)

  Per persona, most Usage first, then by persona\_id; deleted personas included.
- **`refunded_usd`**: `number`, default: `0`

  Given back this month for sessions that failed on Saram's side.
- **`refunds`**: array of [UsageRefund](#scalar-schema-usagerefund)

  Every session refunded this month, oldest refund first, then by run\_id and session\_id.
- **`runs`**: array of [UsageChargeRun](#scalar-schema-usagechargerun)

  Per run, oldest settlement first, then by run\_id.
- **`sessions`**: `integer`, default: `0`

  Persona sessions settled this month.
- **`usage`**: `number`, default: `0`

  The month's Usage, refunded sessions included, to 0.001.
- **`usage_price_usd`**: `number | null`

  USD per Usage on your plan, in force at the end of the month. Absent on a plan billed in steps. A session is charged at the price in force when it ran.
- **`usage_usd`**: `number`, default: `0`

  What the month's Usage cost you, net of refunds: the total a screen prints as Saram's charge. Your model provider's bill is never in it (see provider).

<a id="scalar-example-152"></a>

**Generated example:**

```json
{
  "usage_price_usd": 1,
  "usage": 0,
  "usage_usd": 0,
  "refunded_usd": 0,
  "sessions": 0,
  "runs": [
    {
      "run_id": "",
      "caller": "",
      "settled_at": "",
      "sessions": 1,
      "usage": 1,
      "usage_usd": 1,
      "refunded_usd": 1
    }
  ],
  "personas": [
    {
      "persona_id": "",
      "persona_label": "",
      "deleted": true,
      "runs": 1,
      "sessions": 1,
      "usage": 1,
      "usage_usd": 1,
      "refunded_usd": 1
    }
  ],
  "refunds": [
    {
      "run_id": "",
      "session_id": "",
      "persona_id": "",
      "persona_label": "",
      "fault": "saram",
      "usage": 1,
      "amount_usd": 1,
      "refunded_at": "",
      "note": ""
    }
  ]
}
```

<a id="scalar-schema-usagemeter"></a>

### UsageMeter

**Type:** `object`, no additional properties

One line of the table of meters: how much of one meter one model caller used in the period.

- **`meter` (required)**: `string`

  steps (one body tool call, the platform fee's unit), sessions (persona sessions that ran a step), browser\_seconds (pool time), model\_usd\_hosted (model spend on a Saram-held credential), model\_usd\_byo (the same on your own provider key, which the invoice charges at $0) or usage (persona steps weighted by how long each took, recorded per session; reported, not billed).
- **`quantity` (required)**: `number`

  In the meter's own unit: a count, seconds, or USD.
- **`role`**: `string | null`

  Which model caller the quantity is attributed to: actor, judge or reproduction. The counting meters are the actor's; a model meter is written per role that had anything priced.

<a id="scalar-example-153"></a>

**Generated example:**

```json
{
  "meter": "",
  "role": "",
  "quantity": 1
}
```

<a id="scalar-schema-usagepersona"></a>

### UsagePersona

**Type:** `object`, no additional properties

One persona's Usage in the month, under the name it had when its runs ran (section 2.3).

Grouped by `persona_id`, which carries the version: `dana_roofer_novice@1` and `@2` are two lines. A persona
that was renamed keeps the name its sessions were settled under; one that has since been deleted keeps its line
and says so, because the money it used is still money you spent.

- **`deleted` (required)**: `boolean`

  True when the persona no longer exists: checked when this report is read, so a line can say 'Dana (deleted)' and still carry every dollar its sessions cost.
- **`persona_id` (required)**: `string`

  The persona's address as the run named it: id, or id\@version.
- **`refunded_usd` (required)**: `number`

  Given back for its sessions that failed on Saram's side.
- **`runs` (required)**: `integer`

  How many runs it took part in this month.
- **`sessions` (required)**: `integer`

  Its sessions settled this month.
- **`usage` (required)**: `number`

  The Usage its sessions used, refunded ones included, to 0.001.
- **`usage_usd` (required)**: `number`

  What that cost you, net of refunds.
- **`persona_label`**: `string | null`

  Its display name as recorded when its latest session this month was settled - not as it reads now. Absent for a persona that declared no name; show persona\_id then.

<a id="scalar-example-154"></a>

**Generated example:**

```json
{
  "persona_id": "",
  "persona_label": "",
  "deleted": true,
  "runs": 1,
  "sessions": 1,
  "usage": 1,
  "usage_usd": 1,
  "refunded_usd": 1
}
```

<a id="scalar-schema-usagerefund"></a>

### UsageRefund

**Type:** `object`, no additional properties

One session that was not charged because it failed on Saram's side (section 2.4): debited, then refunded
whole, and both kept, so the Usage it would have cost stays visible.

- **`amount_usd` (required)**: `number`

  What was given back: the whole of the session's debit.
- **`refunded_at` (required)**: `string`, format: `date-time`
- **`run_id` (required)**: `string`
- **`session_id` (required)**: `string`
- **`usage` (required)**: `number`

  The Usage the session used before it failed, to 0.001.
- **`fault`**: `string`, const: `"saram"`, default: `"saram"`

  Whose failure it was. Always saram: the harness - the browser pool, a lost worker, a setup step in Saram's own code. A session your model provider or your product ended is charged, and is not here.
- **`note`**: `string | null`

  The refund's reason as the ledger recorded it.
- **`persona_id`**: `string | null`
- **`persona_label`**: `string | null`

  As recorded when the session was settled.

<a id="scalar-example-155"></a>

**Generated example:**

```json
{
  "run_id": "",
  "session_id": "",
  "persona_id": "",
  "persona_label": "",
  "fault": "saram",
  "usage": 1,
  "amount_usd": 1,
  "refunded_at": "",
  "note": ""
}
```

<a id="scalar-schema-usagereport"></a>

### UsageReport

**Type:** `object`, no additional properties

One month of your tenant's usage: the meters, the runs behind them, and the totals (`GET /api/v1/usage`).

Your own tenant and no other: the ledger is filtered by the tenant the request's key belongs to, so a
neighbour's run is not in these figures and cannot be. `GET /api/v1/usage.csv` is the same report as the
file an invoice is written from.

**One meter is billed and four are reported.** `billable_usd` is the month's bill: billed steps at
`step_price_usd`, less `included_steps`, plus `monthly_fee_usd`. The model spend, the session count and
the browser seconds are here so you can see what your keys spent and how long your personas were in your
product; none of them is multiplied by anything. The arithmetic is served rather than left to the
client, so a screen and an invoice cannot disagree.

- **`from` (required)**: `string`, format: `date-time`

  First moment of the period, inclusive (UTC).
- **`period` (required)**: `string`

  The calendar month these figures cover, YYYY-MM, in UTC.
- **`plan` (required)**: `string`

  pilot, byo or hosted: the price list this usage is invoiced under.
- **`tenant_id` (required)**: `string`
- **`tenant_name` (required)**: `string`
- **`tenant_slug` (required)**: `string`
- **`to` (required)**: `string`, format: `date-time`

  First moment of the next period, exclusive (UTC).
- **`billable_usd`**: `number`, default: `0`

  What Saram bills you for this month, to the cent: billed steps at the price in force when each run ran, less included\_steps, plus monthly\_fee\_usd. Model spend on your own keys is not in it.
- **`billed_steps`**: `integer`, default: `0`

  The month's billed steps: the persona loop's (role actor), never the reproduction replay's. This, not totals.steps, is what billable\_usd is a multiplication of.
- **`house_models`**: `boolean`, default: `false`

  True when Saram has agreed to carry this tenant's model spend, so runs on a bring-your-own-keys plan may sign their model calls with a Saram-held credential. It changes no figure here - the steps bill at the plan's price either way - and it is what explains a model\_usd\_hosted total that is not zero on such a plan.
- **`included_steps`**: `integer`, default: `0`

  Billed steps the plan's monthly fee covers before the step price applies.
- **`meters`**: array of [UsageMeter](#scalar-schema-usagemeter)

  Per meter and role, summed over the period's runs.
- **`model_usd_complete`**: `boolean`, default: `false`

  Whether the model meters cover every model caller in a run: the persona loop under role actor, the judge and the reproduction check under their own roles. A run metered before the judge was priced (2026-09-25) carries the persona loop's spend alone, so a month reaching back past that is a floor for those runs. The step meter is complete.
- **`monthly_cap_usd`**: `number | null`

  On a plan that bills steps (pilot): what this month may bill before a new run is refused with a 402. Absent means no cap. The refusal compares billable\_usd below against this figure. Not a limit on a plan that prices Usage (free, pro), whose runs are limited by the available balance instead.
- **`monthly_fee_usd`**: `number`, default: `0`

  The plan's monthly fee, included in billable\_usd.
- **`provider`**: [ProviderSpend](#scalar-schema-providerspend)

  Your model provider's estimated bill for the month: shown apart, never added to usage.usage\_usd.
- **`runs`**: array of [UsageRun](#scalar-schema-usagerun)

  The runs metered in the period, oldest first.
- **`step_price_usd`**: `number`, default: `0`

  USD per billed step under your plan, or your own agreed price where one was set. The price in force at the end of this period; a run is billed at the price in force when it ran, so across a mid-month price change this figure times billed\_steps is not billable\_usd, and the per-run billable\_usd figures say why.
- **`totals`**: [UsageTotals](#scalar-schema-usagetotals)

  Per meter, roles collapsed.
- **`usage`**: [UsageCharges](#scalar-schema-usagecharges)

  The month's prepaid Usage by run and by persona, with its refunds: Saram's charge on a plan that prices Usage. Read from the credit ledger, apart from the step meters above.

<a id="scalar-example-156"></a>

**Generated example:**

```json
{
  "period": "",
  "from": "",
  "to": "",
  "tenant_id": "",
  "tenant_slug": "",
  "tenant_name": "",
  "plan": "",
  "house_models": false,
  "meters": [
    {
      "meter": "",
      "role": "",
      "quantity": 1
    }
  ],
  "totals": {
    "steps": 0,
    "sessions": 0,
    "browser_seconds": 0,
    "model_usd_hosted": 0,
    "model_usd_byo": 0
  },
  "runs": [
    {
      "run_id": "",
      "caller": "",
      "occurred_at": "",
      "steps": 0,
      "billed_steps": 0,
      "sessions": 0,
      "browser_seconds": 0,
      "model_usd_hosted": 0,
      "model_usd_byo": 0,
      "billable_usd": 0
    }
  ],
  "model_usd_complete": false,
  "billed_steps": 0,
  "step_price_usd": 0,
  "included_steps": 0,
  "monthly_fee_usd": 0,
  "monthly_cap_usd": 1,
  "billable_usd": 0,
  "usage": {
    "usage_price_usd": 1,
    "usage": 0,
    "usage_usd": 0,
    "refunded_usd": 0,
    "sessions": 0,
    "runs": [
      {
        "run_id": "",
        "caller": "",
        "settled_at": "",
        "sessions": 1,
        "usage": 1,
        "usage_usd": 1,
        "refunded_usd": 1
      }
    ],
    "personas": [
      {
        "persona_id": "",
        "persona_label": "",
        "deleted": true,
        "runs": 1,
        "sessions": 1,
        "usage": 1,
        "usage_usd": 1,
        "refunded_usd": 1
      }
    ],
    "refunds": [
      {
        "run_id": "",
        "session_id": "",
        "persona_id": "",
        "persona_label": "",
        "fault": "saram",
        "usage": 1,
        "amount_usd": 1,
        "refunded_at": "",
        "note": ""
      }
    ]
  },
  "provider": {
    "model_usd_byo": 0,
    "complete": false,
    "runs": [
      {
        "run_id": "",
        "model_usd_byo": 1
      }
    ]
  }
}
```

<a id="scalar-schema-usagerun"></a>

### UsageRun

**Type:** `object`, no additional properties

One run's line on the invoice: its meters with the roles collapsed, because a line is money per run.

- **`occurred_at` (required)**: `string`, format: `date-time`

  When the run finished, which is the moment its usage is dated.
- **`run_id` (required)**: `string`
- **`billable_usd`**: `number`, default: `0`

  What this run adds to the month's bill: its billed steps, less any covered by the plan's included allowance, at the step price in force when it ran. The plan's monthly fee is not in it - that is on the month, not on a run.
- **`billed_steps`**: `integer`, default: `0`

  The steps this run is billed for: the persona loop's (role actor), never the reproduction replay's, which are included. Equal to steps until replay steps are counted.
- **`browser_seconds`**: `number`, default: `0`
- **`caller`**: `string | null`

  Who started it, as GET /runs reports it. Absent when nobody was identified.
- **`model_usd_byo`**: `number`, default: `0`
- **`model_usd_hosted`**: `number`, default: `0`
- **`sessions`**: `integer`, default: `0`
- **`steps`**: `integer`, default: `0`

<a id="scalar-example-157"></a>

**Generated example:**

```json
{
  "run_id": "",
  "caller": "",
  "occurred_at": "",
  "steps": 0,
  "billed_steps": 0,
  "sessions": 0,
  "browser_seconds": 0,
  "model_usd_hosted": 0,
  "model_usd_byo": 0,
  "billable_usd": 0
}
```

<a id="scalar-schema-usagetotals"></a>

### UsageTotals

**Type:** `object`, no additional properties

The period's totals per meter, roles collapsed: the figures a price list is applied to.

- **`browser_seconds`**: `number`, default: `0`
- **`model_usd_byo`**: `number`, default: `0`
- **`model_usd_hosted`**: `number`, default: `0`
- **`sessions`**: `integer`, default: `0`
- **`steps`**: `integer`, default: `0`

<a id="scalar-example-158"></a>

**Generated example:**

```json
{
  "steps": 0,
  "sessions": 0,
  "browser_seconds": 0,
  "model_usd_hosted": 0,
  "model_usd_byo": 0
}
```

<a id="scalar-schema-validationerror"></a>

### ValidationError

**Type:** `object`

- **`loc` (required)**: `array of (string | integer)`
- **`msg` (required)**: `string`
- **`type` (required)**: `string`
- **`ctx`**: `object`
- **`input`**: `any`

<a id="scalar-example-159"></a>

**Generated example:**

```json
{
  "loc": [
    ""
  ],
  "msg": "",
  "type": "",
  "input": null,
  "ctx": {}
}
```

<a id="scalar-schema-verbosity"></a>

### Verbosity

**Type:** `string`, possible values: `"terse", "normal", "verbose"`

How long the persona's diary entries run. `terse`: short. `normal`: as much as the moment deserves. `verbose`: in full, with the detail they noticed.

<a id="scalar-schema-verdict"></a>

### Verdict

**Type:** `object`, no additional properties

SUBJECTIVE. The persona's answer at session end to the run's verdict prompt - the run spec's `mission.verdict_prompt` when it has one, `persona.verdict_prompt` otherwise - grounded in diary quotes. A persona pack's question is about the product the pack was written for, so a run that points a pack at another target sets the mission's and the pack's is not applicable for that run. Which of the two this answers, and its text, are on the row: `outcomes[].verdict_prompt_source` and `outcomes[].verdict_prompt`, which sit there rather than here so that a session with no verdict still names the question that went unanswered. Consumers must tag it as subjective. `decision_text`, `reason` and `quotes` are quotation, verbatim from the diary at `step_refs`; `reason_paraphrase` is the only field written in the judge's voice, and a consumer that shows it must label it as the judge's, never as the persona's words.

- **`decision` (required)**: `string`, possible values: `"positive", "negative", "undecided"`

  Schema: `Decision`.

  Would keep paying / would recommend / would text 'it works' = positive
- **`reason` (required)**: `string`
- **`step_refs` (required)**: `array of integer`, minItems: `1`
- **`decision_text`**: `string | null`
- **`quotes`**: `(array of string) | null`
- **`reason_paraphrase`**: `string | null`

<a id="scalar-example-160"></a>

**Generated example:**

```json
{
  "decision": "positive",
  "decision_text": "",
  "reason": "",
  "reason_paraphrase": "",
  "quotes": [
    ""
  ],
  "step_refs": [
    1
  ]
}
```

<a id="scalar-schema-verdict1"></a>

### Verdict1

**Type:** `string`

Known values, open (more may be added): `supported`, `unsupported`, `ungrounded`, `skipped`.

`supported`: the checker answered yes with a quote that gates against the ledger text of the cited steps - the only verdict that lets `status` read `verified`. `unsupported`: the checker answered no; the title claims more than the quoted line shows. `ungrounded`: the checker answered yes but its quote is not in the ledger, so the answer is discarded the way an ungrounded diary quote is and recorded in the gate ledger. `skipped`: no marker matched, or the check did not run. A classification: a later verdict is additive.

**Example:**

```json
"supported"
```

**Example:**

```json
"unsupported"
```

**Example:**

```json
"ungrounded"
```

**Example:**

```json
"skipped"
```

<a id="scalar-schema-verdictdiscarded"></a>

### VerdictDiscarded

**Type:** `object`, no additional properties

NOT A VERDICT. The answer a judge gave about this session that the verbatim-quote gate threw away because it could not ground itself in the persona's words: none of its quotes is in the diary at the steps it cites, or none of the steps it cites is a step of the session. Present only on a row a judge read that has no `verdict`, so never beside `verdict` or `observer_error`. It is what tells a judge that answered and was not grounded apart from a persona who gave no answer: both rows carry no `verdict` and both count `undecided` in `summary.verdicts`, which counts grounded verdicts alone, and only this one says an answer was given. The decision is the judge's reading with nothing in the diary to support it, so a consumer never shows it as the persona's verdict, never counts it and never acts on it; what it points a reader at is the judge's citations. Nothing the judge quoted is carried here, because none of it is verifiably the persona's: the records, the dropped quotes among them, are in the session's gate ledger beside its observation, and the discard is counted in `summary.gate_discards`. Absent on a row with no `verdict` means nothing was discarded: the judge returned no answer, no judge read the session (`observer_error`), or no question was in play (`verdict_prompt_source` absent). An artifact written before 0.6.3 carries none either, and `contracts_version` is what tells those apart.

- **`gate_reason` (required)**: `string`
- **`judge_decision` (required)**: `string`, possible values: `"positive", "negative", "undecided"`

  Schema: `JudgeDecision`.

  The decision the judge returned, before the gate discarded it. The judge's and not the persona's: no diary line at the steps it cites supports it. Named apart from `verdict.decision` so that nothing reading a row by field name takes it for the persona's.

<a id="scalar-example-161"></a>

**Generated example:**

```json
{
  "judge_decision": "positive",
  "gate_reason": ""
}
```

<a id="scalar-schema-verdictlist"></a>

### VerdictList

**Type:** `object`, no additional properties

A run's persona verdicts: each session's answer to the run's verdict question, grounded in the persona's
own diary quotes, and the cohort's tally. Persona feedback, never a ticket on its own.

- **`advisory` (required)**: `string`

  How to read this whole answer: persona feedback, not defects. Never a ticket on its own.
- **`run_id` (required)**: `string`
- **`status` (required)**: `string`

  The run's status. An empty `verdicts` is only a result once this is terminal.
- **`subjective_fields` (required)**: `array of string`

  Fields on each row that are the persona's opinion - for a verdict, the verdict itself.
- **`verdicts` (required)**: array of [VerdictRow](#scalar-schema-verdictrow)
- **`summary`**: [Verdicts](#scalar-schema-verdicts) | `null`

  The artifact's cohort read (`summary.verdicts`).

<a id="scalar-example-162"></a>

**Generated example:**

```json
{
  "run_id": "",
  "status": "",
  "summary": {
    "positive": 1,
    "negative": 1,
    "undecided": 1,
    "top_reasons": [
      ""
    ]
  },
  "verdicts": [
    {
      "session_id": "",
      "persona_id": "",
      "mission_id": "",
      "result": "",
      "verdict_prompt": "",
      "verdict_prompt_source": "mission",
      "verdict": {
        "decision": "positive",
        "decision_text": "",
        "reason": "",
        "reason_paraphrase": "",
        "quotes": [
          ""
        ],
        "step_refs": [
          1
        ]
      },
      "verdict_discarded": {
        "judge_decision": "positive",
        "gate_reason": ""
      },
      "observer_error": ""
    }
  ],
  "advisory": "",
  "subjective_fields": [
    ""
  ]
}
```

<a id="scalar-schema-verdictpromptsource"></a>

### VerdictPromptSource

**Type:** `string`

Known values, open (more may be added): `mission`, `persona`.

Whose question `verdict` answers: the run spec's `mission.verdict_prompt`, or the persona pack's `persona.verdict_prompt` when the mission carried none. `mission` is also the record that the pack's own question was not applicable for this run, which is what makes an absent `verdict` beside it readable: the diary did not answer a question about the product actually under test, which is a run-spec problem, not a judge that could not ground itself (that one names its discarded answer in `verdict_discarded`). Absent means no verdict question was in play: neither the mission nor the pack carried one, so `verdict` is absent by construction and `summary.verdicts` counts the session `undecided` without that being anything the judge failed at. An artifact written before this release carries none either, and `contracts_version` is what tells the two apart. Absent beside `observer_error` is neither: no judge ran, so nobody resolved which question was in play.

**Example:**

```json
"mission"
```

**Example:**

```json
"persona"
```

<a id="scalar-schema-verdictrow"></a>

### VerdictRow

**Type:** `object`, no additional properties

SUBJECTIVE: one session's verdict as the artifact's `outcomes[]` carries it. Persona feedback, never a
ticket on its own.

- **`mission_id` (required)**: `string`
- **`persona_id` (required)**: `string`
- **`result` (required)**: `string`
- **`session_id` (required)**: `string`
- **`observer_error`**: `string | null`

  Why no judge read this session, when none did, in Saram's words (`artifact.outcomes[].observer_error`): its
  judge pass failed after its retries, so the absent `verdict` is a question nobody asked a model, not one the
  persona left unanswered. The model provider's status and own message when the provider refused, the `providers`
  entry's refusal when it did not resolve, Saram's sentence otherwise. Absent on every session a judge read, and
  on every row of an artifact written before contracts 0.6.1; one written before 0.6.3 carries the failure as it
  was recorded.
- **`verdict`**: [Verdict](#scalar-schema-verdict) | `null`

  SUBJECTIVE. The persona's answer at session end to the run's verdict prompt - the run spec's `mission.verdict_prompt` when it has one, `persona.verdict_prompt` otherwise - grounded in diary quotes. A persona pack's question is about the product the pack was written for, so a run that points a pack at another target sets the mission's and the pack's is not applicable for that run. Which of the two this answers, and its text, are on the row: `outcomes[].verdict_prompt_source` and `outcomes[].verdict_prompt`, which sit there rather than here so that a session with no verdict still names the question that went unanswered. Consumers must tag it as subjective. `decision_text`, `reason` and `quotes` are quotation, verbatim from the diary at `step_refs`; `reason_paraphrase` is the only field written in the judge's voice, and a consumer that shows it must label it as the judge's, never as the persona's words.
- **`verdict_discarded`**: [VerdictDiscarded](#scalar-schema-verdictdiscarded) | `null`

  NOT A VERDICT. The answer the judge gave that the quote gate threw away because none of it could be grounded in
  the persona's diary at the steps it cited, and why (`artifact.outcomes[].verdict_discarded`). Set only on a row
  with no `verdict`; absent there means nothing was discarded - the judge returned no answer, or no judge read the
  session (`observer_error`). Never show `judge_decision` as the persona's verdict and never count it: the cohort's
  `summary` counts this session `undecided`. Absent on every row of an artifact written before contracts 0.6.3.
- **`verdict_prompt`**: `string | null`

  The verdict question this session was asked, verbatim: the one `verdict` answers. Absent when no question
  was in play for the session, and on every row of an artifact written before the outcome carried it (0.4.1).
- **`verdict_prompt_source`**: `string | null`

  Schema: `VerdictPromptSource`. Known values, open (more may be added): `mission`, `persona`.

  Whose question `verdict` answers: the run spec's `mission.verdict_prompt`, or the persona pack's when the
  mission carried none. Present exactly when `verdict_prompt` is; `mission` beside an absent
  `verdict` is a persona who was asked the run's question and did not answer it - unless `verdict_discarded`
  says the judge answered for them and could not ground the answer.

<a id="scalar-example-163"></a>

**Generated example:**

```json
{
  "session_id": "",
  "persona_id": "",
  "mission_id": "",
  "result": "",
  "verdict_prompt": "",
  "verdict_prompt_source": "mission",
  "verdict": {
    "decision": "positive",
    "decision_text": "",
    "reason": "",
    "reason_paraphrase": "",
    "quotes": [
      ""
    ],
    "step_refs": [
      1
    ]
  },
  "verdict_discarded": {
    "judge_decision": "positive",
    "gate_reason": ""
  },
  "observer_error": ""
}
```

<a id="scalar-schema-verdicts"></a>

### Verdicts

**Type:** `object`, no additional properties

Cohort-level read of persona verdicts. Subjective, derived from outcomes\[].verdict. positive + negative + undecided = sessions: a session with no grounded verdict is undecided, not absent. That includes a session whose judge answered and had the answer discarded by the quote gate: its row names it in `outcomes[].verdict_discarded`, and it is still counted here as undecided, because this tally counts only verdicts grounded in the persona's words.

- **`negative`**: `integer | null`
- **`positive`**: `integer | null`
- **`top_reasons`**: `(array of string) | null`
- **`undecided`**: `integer | null`

<a id="scalar-example-164"></a>

**Generated example:**

```json
{
  "positive": 1,
  "negative": 1,
  "undecided": 1,
  "top_reasons": [
    ""
  ]
}
```

<a id="scalar-schema-verification"></a>

### Verification

**Type:** `object`, no additional properties

Whether the claim was checked, and by what. The two are separate questions and were conflated once already: `status` is the answer a reader acts on, `method` only says which instrument produced it. Consumers show `status`.

- **`status` (required)**: `string`, possible values: `"verified", "marker_matched", "inconclusive", "unverified"`

  Schema: `Status`.

  Best-supported first. `verified`: a replay matched the failure marker at `action_step` AND the claim check answered `supported` with a verbatim quote from the cited steps (`check`) AND the failure is attributed to the product (`attribution`) - or `method` is `assertion` or `manual` and the same two conditions hold. This and only this may file a ticket alone, and `reproducible` is true exactly when this reads `verified`. `marker_matched` (0.5): a replay matched the failure marker at `action_step`; the claim as titled is unchecked, unsupported, or the check could not quote. The step failed again; whether it failed for the stated reason is not established, so it never files alone. `inconclusive`: a check ran and settled nothing (the marker did not appear again, the two attempts disagreed) - not a refutation. `unverified`: nothing checked it, because there was nothing to check, reproduction never ruled, or the finding is a tester error. A wrong total or a select that resets is `unverified` by construction, not disproven. Default and safe reading when the block is absent. This enum drives an action - it is the ticket-filing gate, like `issue.severity` - so it stays closed for readers too and adding a member is a breaking change: a gate must refuse a value it cannot interpret rather than guess at one, which is why `marker_matched` made 0.5 a new family.
- **`markers`**: `(array of string) | null`
- **`method`**: `string | null`

  Schema: `Method`. Known values, open (more may be added): `marker`, `assertion`, `manual`.

  Which check ran, when one did; absent when `status` is `unverified`. `marker`: a replay compared the failure marker at `action_step` - name it in `markers`. A marker matching is evidence the step failed again, not that it failed for the reason the title gives, so on its own it produces `marker_matched`, and `verified` only with `check.verdict: supported` and `attribution: product`. `assertion`: a state assertion checked the claim itself (reserved; not produced yet). `manual`: a human checked it. A classification: nothing acts on it - every surface shows `status` - so a reader on an older release buckets an instrument it does not know as `unknown`, and adding one is additive.
- **`note`**: `string | null`

<a id="scalar-example-165"></a>

**Generated example:**

```json
{
  "status": "verified",
  "method": "marker",
  "markers": [
    ""
  ],
  "note": ""
}
```

<a id="scalar-schema-video"></a>

### Video

**Type:** `object`

A clip of a session's video that shows the issue: where the video is stored (`ref`), and where the clip starts and ends, in seconds from the start of the recording.

- **`ref`**: `string | null`
- **`ts_end_s`**: `number | null`
- **`ts_start_s`**: `number | null`

<a id="scalar-example-166"></a>

**Generated example:**

```json
{
  "ref": "",
  "ts_start_s": 1,
  "ts_end_s": 1
}
```

<a id="scalar-schema-vocabulary"></a>

### Vocabulary

**Type:** `object`, no additional properties

Terms the persona does and does not understand. More specific than the literacy enums in `priors`; the renderer tells the actor to say so when an unknown term appears rather than guess.

- **`knows`**: `(array of string) | null`
- **`unknown`**: `(array of string) | null`

<a id="scalar-example-167"></a>

**Generated example:**

```json
{
  "knows": [
    ""
  ],
  "unknown": [
    ""
  ]
}
```

<a id="scalar-schema-voice"></a>

### Voice

**Type:** `object`, no additional properties

Narration style only. It never influences what the persona does.

- **`style`**: `string | null`
- **`verbosity`**: `string | null`, possible values: `"terse", "normal", "verbose"`

  Schema: `Verbosity`.

  How long the persona's diary entries run. `terse`: short. `normal`: as much as the moment deserves. `verbose`: in full, with the detail they noticed.

<a id="scalar-example-168"></a>

**Generated example:**

```json
{
  "style": "",
  "verbosity": "terse"
}
```

<a id="scalar-schema-runspec"></a>

### RunSpec

**Type:** `object`, no additional properties

Input to the control plane. A run = target x persona set x missions x budget x baseline.

- **`budget` (required)**: `object`, no additional properties

  How far each session may go and how much the run may spend. A session ends at the first of its caps it reaches: `steps_per_session` steps (`end_reason` `budget_steps`), `tokens_per_session` model tokens billed, which is the prompt not read from cache plus the reply (`budget_tokens`), or `wall_clock_s_per_session` seconds (`budget_wall_clock`). `max_concurrent_sessions` is the most of the run's sessions that run at once, and your plan's own limit bounds it. `total_cost_usd` pauses the run once the sessions it has recorded have cost that much: no further session starts until it is resumed, and the ones already running finish.
  - **`steps_per_session` (required)**: `integer`, minimum: `1`
  - **`max_concurrent_sessions`**: `integer`, minimum: `1`
  - **`tokens_per_session`**: `integer`, minimum: `1`
  - **`total_cost_usd`**: `number`, minimum: `0`

    Soft cap; run pauses when reached
  - **`wall_clock_s_per_session`**: `integer`, minimum: `1`
- **`missions` (required)**: array of [RunSpecMission](#scalar-schema-runspecmission), minItems: `1`
- **`personas` (required)**: `array`, minItems: `1`

  **Items:**

  **One of:**
  - `string`

    persona id\@version from the registry, e.g. novice\_mobile\_skimmer\@2
  - [Persona](#scalar-schema-persona)

    A structured persona: a sampling strategy over the UI state space plus a tolerance function. Two kinds of fields: a structured core the run enforces and the dashboard diffs (`tolerance`, `constraints`, `action_policy`), and prose behavior fields the model reads (`backstory`, `vocabulary`, `facts`, `conversation`, `verdict_prompt`). Both drive what the persona does. `voice` affects narration only.
- **`target` (required)**: `object`, no additional properties

  The product under test: the URL every session opens on (a mission's own `entry_url` overrides it), a name for it, the tunnel to it when it runs on your own machine (`tunnel`), and, when it needs them, how a session gets past a gate in front of the whole deployment (`auth`) and how the product's state is reset and its test accounts handed out between sessions (`seed`).
  - **`entry_url` (required)**: `string`, format: `uri`
  - **`auth`**: `object`, no additional properties

    How a session gets past the target's own door: the gate in front of the whole deployment, not the product's sign-in. `auth` and `seed.accounts` are one mechanism seen from two ends and both resolve `secret-store` entries; which end a credential belongs at is decided by whether it is the target's or the persona's. **The target's** (one for the run, the same for every session, nothing a person would be shown): `basic` on a staging host, a deployment-protection bypass `header` on a Vercel or Cloudflare Access preview, a `cookie` the whole run shares. That is this object: the pool receives `secret_ref` as `session_create.target_auth_secret_ref` and applies the entry's `basic` and `headers` to requests to the target's site (`secret-store` `entry.basic`) and its `cookies` to the browser context, and the control plane sends the same entry's `basic`, `headers` and the `cookies` scoped to the hook on its POST to `seed.reset_hook`, which is behind the same door. **The persona's** (one per session, and a persona may be asked to type it): `seed.accounts`, one ref per session, where the pool applies the entry's `cookies` and the prompt renderer reads `email`, `password`, `inbox` and `notes`. A run can carry both - a staging host behind basic auth whose personas each sign in with their own account - and where the two name the same credential the account's wins. Closed by design: credentials arrive only as `secret_ref`, so a field this object does not list is an inline secret and every reader rejects it, not just the producer.
    - **`kind`**: `string`, possible values: `"none", "basic", "cookie", "header", "login_flow"`

      What the door is, for a reader of the spec. It selects nothing: the pool applies whatever the entry behind `secret_ref` carries, so `basic` and `header` on one entry are both applied whichever of the two this says. `none` is a target with no gate (`secret_ref` then says nothing and is omitted); `login_flow` is the one kind that is not a gate at all but a form the persona fills in, which is `seed.accounts` and `login_url`.
    - **`login_url`**: `string`, format: `uri`

      Where the product's own sign-in form is, with `kind: login_flow`. Descriptive only: the persona is given the account behind `seed.accounts` and finds the form itself, which is the behaviour under test, so nothing navigates here.
    - **`secret_ref`**: `string`

      Reference into the secret store; never inline credentials. Carried to the pool as `session_create.target_auth_secret_ref`, once per session, unchanged; the pool resolves it and applies `basic` and `headers` to requests to the target's site (`secret-store` `entry.basic`) and `cookies` on the context. The same ref for every session of the run - a per-session credential is `seed.accounts`, not this.
  - **`name`**: `string`
  - **`seed`**: `object`, no additional properties

    Environment provisioning. Every e2e product bleeds here; make it explicit. Closed by design (x-closed) for the same reason as `auth`: `accounts` are secret refs and nothing inline may ride beside them.
    - **`accounts`**: `array of string`

      Pool of test account secret\_refs, one per session, handed out in session order; sessions past the end of the list get none and the persona is told they have no login. The other half of `auth` (see its description for the split): the persona's credential rather than the target's. Each ref reaches the pool as `session_create.auth_secret_ref`, which applies the entry's `cookies` (and `basic`/`headers`, for the rare account that overrides the target's own), while the renderer tells the persona which credentials they 'have' (email, password) and which they do not (inbox access). Never inline.
    - **`feature_flags`**: `object`

      **Additional properties:**

      `boolean`
    - **`reset_hook`**: `string`, format: `uri`

      Endpoint to reset state before each session. The control plane POSTs it once per run with `{"feature_flags": ...}` (the whole target), before each session with `session_id` added (that session's partition), and before each reproduction replay attempt the same way. It sits behind the target's own door like every other url on the host, so the POST carries the entry behind `auth.secret_ref`: its `basic` as HTTP basic auth, its `headers` verbatim and, as one `Cookie` header, those of its `cookies` the browser would send to the hook's url - the same credential the pool opens the browser's door with. A cookie goes when its scope matches the hook as Playwright scopes it in the pool: a `url` names its host alone, that url's path up to its last `/`, and https only; otherwise `domain` (with a leading dot, every subdomain too) and `path` (a prefix ending at a `/`) do, and `secure` keeps it off plain http. A cookie scoped to another host or path is not sent, and neither is any cookie when `headers` already name `Cookie`. A hook that answers anything but 2xx fails the reset.
  - **`tunnel`**: `string`, pattern: `^tun_[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9a-f]{8}$`

    For a product running on your own machine: the id of the tunnel you opened to it (`tun_...`), which Saram's browsers reach it through and nothing else can. Not available yet: until Saram opens and binds tunnels, every spec naming one is refused with a 422 (`tunnels to your own machine are not available yet`) by `POST /api/v1/runs`, `POST /api/v1/runs/validate` and the `validate_spec` and `start_run` tools, before any run exists. Naming it binds that tunnel to this run, and the spec is refused unless the tunnel is one the tenant starting the run opened, is still open and is bound to no other run, and every URL the run opens on the target - `entry_url`, each mission's `entry_url` and `seed.reset_hook` - is on the tunnel's hostname. Saram puts the tunnel's own door in front of the run, so a spec naming a tunnel carries no `auth` of its own. The tunnel closes when the run ends.
- **`actor`**: `object`, no additional properties

  How the persona sees the page and which models drive it: the observation mode, the persona and judge models, when a screenshot is taken, and whether the session is recorded on video.
  - **`actor_model`**: `string`

    Model id for navigation steps
  - **`judge_model`**: `string`

    Model id for the observer
  - **`observation_mode`**: `string`, possible values: `"vision", "tree", "dom_diff", "hybrid"`, default: `"vision"`

    How the persona perceives the page. `vision`, the default and the only mode supported today: the persona sees a screenshot of the page when it chooses to look. `tree`, `dom_diff` and `hybrid` are reserved for modes that read the page's structure instead of, or beside, a screenshot, and nothing runs them yet: a spec naming one is refused with a 422 by `POST /api/v1/runs`, `POST /api/v1/runs/validate` and the `validate_spec` and `start_run` tools, before any run exists.
  - **`record_video`**: `boolean`, default: `true`
  - **`screenshot_policy`**: `string`, possible values: `"on_look", "every_step", "decision_points", "on_escalation"`, default: `"on_look"`

    `on_look`: the persona sees a screenshot only when it chooses to look at the page. `every_step`: a screenshot after every action is also kept in the session's trace for the judge.
- **`baseline_run_id`**: `string`

  Run to diff against, typically the last run on main
- **`connectors`**: `object`, no additional properties

  Where the run's results are delivered once its artifact is assembled: `github` files issues and comments on a pull request in your repository, and `webhook` sends one signed POST to a URL of yours. Both are optional; a run with neither is read through the API, the dashboard and the MCP tools.
  - **`github`**: `object`, no additional properties

    Deliver the run to the GitHub repository `repo` (`owner/name`). `create_issues` files a GitHub issue for each issue that reproduced, is not specific to one persona and is rated at least `min_severity_for_issue`; `pr_comment` posts one comment on the run on the pull request named in `trigger.pr`, created once and updated after that. Both are off by default, so a block that turns neither on writes nothing. `check_run` is accepted and nothing posts a check run yet. Delivery writes as Saram's GitHub App where it is installed on `repo`, and otherwise with `token_ref`.
    - **`check_run`**: `boolean`, default: `false`
    - **`create_issues`**: `boolean`, default: `false`
    - **`min_severity_for_issue`**: `string`, possible values: `"low", "medium", "high", "critical"`, default: `"medium"`

      The least severe issue `create_issues` files, from least to most: `low`, `medium` (the default), `high`, `critical`. An issue rated below it stays in the run's artifact and is not filed.
    - **`pr_comment`**: `boolean`, default: `false`
    - **`repo`**: `string`
    - **`token_ref`**: `string`, pattern: `^[a-z][a-z0-9+.-]*://.+$`

      Reference to a GitHub token that may write issues and comments on `repo`, never the token: `awssm://<secret name>` under the requesting tenant's own prefix (a credential of kind `github`, POST /api/v1/credentials), or `env://NAME` on an ungated local stack. The fallback, not the connector's identity: delivery authenticates as the deployment's GitHub App installation on `repo` and reads this only when the App is not installed there. With neither, every record the delivery would have written fails with that reason; no other credential is tried, so a run never writes to its repo as somebody else. Resolved by the worker that delivers, at delivery.
  - **`webhook`**: `object`, no additional properties

    One signed POST when the run has finished and its artifact is assembled: a small JSON summary (run id, status, session and issue counts, links to the run and the artifact), so a CI job or a deploy pipeline can act on a run without polling. Sent once, after the run's row is closed, by an activity that never fails the run: a receiver that answers 5xx, 429 or nothing is retried a bounded number of times and then given up on, and the outcome is recorded on the run. A run that is cancelled, or that fails before an artifact exists, sends nothing. Closed by design: the signing key arrives only as `secret_ref`, so a field this object does not list is an inline secret and every reader rejects it.
    - **`secret_ref` (required)**: `string`, pattern: `^[a-z][a-z0-9+.-]*://.+$`

      Reference to the HMAC-SHA256 key the body is signed with, never the key: `awssm://<secret name>` under the requesting tenant's own prefix (a credential of kind `webhook`, POST /api/v1/credentials), or `env://NAME` on an ungated local stack. Resolved by the worker that delivers, at delivery. The signature is `Saram-Signature: t=<unix seconds>,v1=<hex>`, the digest being HMAC-SHA256 over `<t>.<raw body>` - the scheme Stripe uses - so a receiver verifies the raw bytes it received and refuses a timestamp outside its own tolerance.
    - **`url` (required)**: `string`, format: `uri`

      Where the POST goes. `https` only; the control plane refuses a URL naming a loopback, private, link-local or otherwise non-public address, whether written as one or resolved to one at delivery, unless the deployment allows private hosts (a local stack), and then `http` is accepted for a loopback host too. Redirects are not followed.
  - **`webhook_url`**: `string`, format: `uri`

    Never read. Reserved since 0.3 and superseded by `webhook` (0.6.0), which carries the signing key a bare URL has no room for; kept so a spec that sets it still validates.
- **`files`**: `array`, maxItems: `10`

  Files the personas bring with them. Bytes never travel in the spec: upload each file with POST /api/v1/files first and name it here by the `ref` that answers. A file belongs to the tenant that uploaded it, so any key or member of that tenant may name it in a run and nobody else may.

  **Items:**

  no additional properties

  One file a persona brings with them: the uploaded file (`ref`), what the persona and the target call it (`name`), what the persona knows about it (`description`), and which sessions carry it (`assign`).
  - **`description` (required)**: `string`, minLength: `1`

    What the persona knows about it, in one sentence, briefed verbatim.
  - **`name` (required)**: `string`, pattern: `^[A-Za-z0-9][A-Za-z0-9 ._-]{0,127}$`

    What the persona calls it and what the target receives as the filename. Unique within the run.
  - **`ref` (required)**: `string`, pattern: `^saram-file://[a-z0-9][a-z0-9_-]{0,63}/f_[a-z2-7]{26}$`

    A file uploaded through POST /api/v1/files by the tenant starting the run: saram-file://\<tenant\_id>/\<file\_id>, where the owner segment is that tenant's id. A file uploaded before files were a tenant's carries the name of the caller that uploaded it there instead, and only that caller may name it.
  - **`assign`**: `object`, no additional properties

    Which sessions carry it: those matching every list given. Absent means every session.
    - **`missions`**: `array`, minItems: `1`

      **Items:**

      `string`, pattern: `^[a-z0-9_]+$`
    - **`personas`**: `array of string`, minItems: `1`
- **`providers`**: `object`, no additional properties

  Which model, on which provider or router, with which credential, every model call in this run uses. `entries` are named; `default` names the entry every role uses unless `roles` says otherwise; `roles` binds the three callers (the actor loop, the observer's judge for session judgment and run merge, the reproduction model check) to entry names. A role that resolves to no entry is a producer error. When this block is present it wins over `actor.actor_model` and `actor.judge_model`; when absent, those two and the worker's own process credential apply, which is every run written before 0.4.1. Resolved by the worker that makes the call, at the start of its activity, never by the API or a workflow.
  - **`entries` (required)**: `object`, minProperties: `1`

    **Additional properties:**

    [RunSpecProvider](#scalar-schema-runspecprovider)

    One model on one provider or router, in pydantic-ai's own terms: `model` is `<provider>:<model>` and the prefix decides how the rest is read. Closed by design: a credential arrives only as `credential_ref`, so a field this object does not list is an inline secret and every reader rejects it.
  - **`default`**: `string`

    Entry name. Absent with exactly one entry means that entry.
  - **`roles`**: `object`, no additional properties

    Which entry, by name, each of the run's three model callers uses: `actor`, the model that plays the persona; `judge`, the judge that reads each session and merges the run's findings; `reproduction`, the model check made when an issue is replayed. A role left out uses `default`.
    - **`actor`**: `string`
    - **`judge`**: `string`
    - **`reproduction`**: `string`
- **`run_id`**: `string`

  Assigned by the control plane if absent
- **`trigger`**: `object`, no additional properties

  Who or what started the run, and against which code: a record kept with the run, which changes nothing about what it does but in two places. A run billed to a ChatGPT subscription login is refused when `kind` is `ci`, and `pr` and `commit` are copied onto the artifact's `target`, where `pr` is the pull request the GitHub connector's comment goes on.
  - **`actor`**: `string`

    User, token, or CI job that triggered the run
  - **`commit`**: `string`
  - **`diff_ref`**: `string`

    Where the PR diff can be fetched, for diff-aware targeting. Accepted and recorded; nothing reads it yet.
  - **`kind`**: `string`, possible values: `"manual", "api", "ci"`

    How the run was started: `manual`, by a person at a keyboard; `api`, through the API or a coding agent's MCP tools, which is what `start_run` writes when the spec leaves it out; `ci`, by a CI job with nobody watching. The caller's own claim, recorded as given. `ci` is refused on a run billed to a ChatGPT subscription login, which serves only runs a person starts.
  - **`pr`**: `integer`

**Example:**

```json
{
  "target": {
    "entry_url": "https://example.com"
  },
  "personas": [
    "dana_first_timer@1"
  ],
  "missions": [
    {
      "id": "sign_up",
      "goal": "create an account",
      "success_criteria": [
        {
          "kind": "judge_assertion",
          "value": "the persona reached a signed-in page"
        }
      ]
    }
  ],
  "budget": {
    "steps_per_session": 30
  }
}
```

<a id="scalar-schema-runspecmission"></a>

### RunSpecMission

**Type:** `object`, no additional properties

One thing each persona is sent to do: a goal in the persona's own terms, how to tell it was achieved, and optionally where to start, what the judge should watch for and the question the persona answers at the end.

- **`goal` (required)**: `string`

  Stated in terms a persona would understand, e.g. 'buy one of the blue shirts'
- **`id` (required)**: `string`, pattern: `^[a-z0-9_]+$`
- **`success_criteria` (required)**: `array`, minItems: `1`

  What reaching the goal looks like. The persona is never shown these: it is given `goal` and `brief` and decides for itself when it is finished. Only a `url_matches` criterion is checked by machine; every other kind is context for the judge. See `kind`.

  **Items:**

  no additional properties

  One way to tell the goal was reached: what sort of evidence it is (`kind`) and what to look for (`value`). Only a `url_matches` criterion is checked by machine; see `kind`.
  - **`kind` (required)**: `string`, possible values: `"url_matches", "text_visible", "element_visible", "network_call", "judge_assertion"`

    What the criterion states and who checks it. `url_matches` is the only kind checked by machine: `value` is part of a URL, e.g. `/order/confirmed`, and when the persona says it is finished the session ends with `end_reason` `success_criteria_met` if `value` occurs in the last URL the browser was on, or `claimed_finished_unverified` if it does not (always, for a mission with no `url_matches` criterion). Nothing checks the other four: the judge that reads each session afterwards is shown every criterion as text, `<kind>=<value>`, beside the mission's goal, and nothing decides whether one was met. `text_visible`: text the page shows once the goal is reached. `element_visible`: an element on that page, in words or as a selector. `network_call`: a request the product makes when the goal is reached, e.g. `POST /api/orders`. `judge_assertion`: a plain-language statement about how the session ended, e.g. `the persona reached a signed-in page`. For a verified ending, give a mission a `url_matches` criterion beside any of these.
  - **`value` (required)**: `string`

    Read according to `kind`: part of a URL for `url_matches`, otherwise text for the judge.
- **`authored_by`**: `string`, possible values: `"human", "doppelganger", "planner"`, default: `"human"`

  Who wrote the mission: `human`, the default, for one a person wrote; `doppelganger` and `planner` for one an agent wrote on someone's behalf. A label only: it is kept with the run and changes nothing about how the mission is run.
- **`brief`**: `string`

  Optional context from the mission author to the persona (what they were told before starting)
- **`entry_url`**: `string`, format: `uri`

  Overrides target.entry\_url
- **`name`**: `string`
- **`verdict_prompt`**: `string`

  The question about THIS product that the persona answers in their final diary entry, asked after the persona's own `persona.verdict_prompt`. Set it whenever the personas were written for a different product than the one under test: a roofing persona asked 'is this going to bring you roofing jobs' has nothing to answer with after a shirt-shop session, and the verdict comes back null. When present, this is the question the judge answers and `persona.verdict_prompt` is not applicable for the run.
- **`watch_list`**: `array of string`

  Specific concerns the judge should look for, e.g. 'is the new discount widget discoverable'. The only place free opinions may be solicited: the persona itself is never asked to critique the product.

<a id="scalar-example-169"></a>

**Generated example:**

```json
{
  "id": "",
  "name": "",
  "entry_url": "",
  "goal": "",
  "success_criteria": [
    {
      "kind": "url_matches",
      "value": ""
    }
  ],
  "watch_list": [
    ""
  ],
  "authored_by": "human",
  "brief": "",
  "verdict_prompt": ""
}
```

<a id="scalar-schema-runspecprovider"></a>

### RunSpecProvider

**Type:** `object`, no additional properties

One model on one provider or router, in pydantic-ai's own terms: `model` is `<provider>:<model>` and the prefix decides how the rest is read. Closed by design: a credential arrives only as `credential_ref`, so a field this object does not list is an inline secret and every reader rejects it.

- **`model` (required)**: `string`, pattern: `^[a-z][a-z0-9-]*:.+$`

  pydantic-ai model string: anthropic:claude-opus-5, bedrock:us.anthropic.claude-opus-5, openai:gpt-5.6, openrouter:anthropic/claude-opus-5, azure:, google:gemini-3-pro, google-cloud:gemini-3-pro, litellm:.
- **`api_version`**: `string`

  azure: the Azure OpenAI api-version. Absent means OPENAI\_API\_VERSION on the worker.
- **`base_url`**: `string`, format: `uri`

  Endpoint override: a gateway (LiteLLM, a private OpenAI-compatible URL), an Azure resource endpoint, a Bedrock VPC endpoint. Absent means the provider's public endpoint.
- **`credential_ref`**: `string`, pattern: `^[a-z][a-z0-9+.-]*://.+$`

  Reference to the credential, never the credential: `env://NAME`, `awssm://<secret name>`. Absent means the provider's ambient credential (IRSA or instance role on bedrock, Application Default Credentials on google-cloud); for prefixes that have no ambient path, absent is a producer error at resolution. One secret holds one string; what the string is (API key, bearer token, service-account JSON) is fixed by the prefix.
- **`location`**: `string`

  google-cloud: the Vertex location, e.g. us-central1 or global.
- **`project`**: `string`

  google-cloud: the GCP project. Absent means the one Application Default Credentials carry.
- **`region`**: `string`

  bedrock: the AWS region the model is invoked in. Absent means AWS\_REGION / AWS\_DEFAULT\_REGION on the worker.

<a id="scalar-example-170"></a>

**Generated example:**

```json
{
  "model": "",
  "credential_ref": "",
  "base_url": "",
  "region": "",
  "project": "",
  "location": "",
  "api_version": ""
}
```
