# 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.

## Effective servers

- `https://api.saram.sh`

  Saram

## 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`

schema: `HTTPValidationError`

- **`detail`**: `array`

  **Items:**

  schema: `ValidationError`
  - **`loc` (required)**: `array of (string | integer)`
  - **`msg` (required)**: `string`
  - **`type` (required)**: `string`
  - **`ctx`**: `object`
  - **`input`**: `any`

**Example:**

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

## Schemas

- `HTTPValidationError` — shown above.
- `ValidationError` — shown above.
