# warmhub changelog

<!-- warmhub-changelog-artifact:v1 -->

## 0.17.0

No new release notes.

## 0.16.1

No new release notes.

## 0.16.0

No new release notes.

## 0.15.1

### Documentation

Versioned release notes are available as [HTML, Markdown, and JSON](https://docs.warmhub.ai/sdk/python/changelog/).

## 0.15.0

### Release changes

- feat(collections): Arc and Bond become ordinary Things under protected V2 Shapes (commit `50fec9ce2a8f`)
- fix(collections): suppress the migrated Arc THING on replay and reserve Collection in Python (commit `171e5daabba7`)
- feat(collections): the Collections V2 cutover with a legacy-sugar window (commit `adaaa4fe53dc`)
- feat(collections): classify inbound traversal by closure and add the aggregate roles (commit `ca07faa123f0`)
- feat(collections): make the collection router read the certificate closure (commit `74bf13faaf25`)
- feat(collections): split the read kind and carry collections in the Python SDK (commit `cae90e67bf42`)
- fix(collections): carry the summary rename into whedge and the e2e-py suites (commit `dc266e612abb`)
- fix(collections): stop head and history claiming member facts they cannot see (commit `9c6752da5e8d`)
- fix(sdk-py): decode collections on paged rows, not just on detail reads (commit `f7bbfcab6364`)
- feat(collections): put the member layout on the Shape and the facts on the row (commit `be4d60df9499`)
- test(collections): pin the layout ruling and the four review findings it touches (commit `102b6e20d7a2`)
- fix(collections): finish the summary reshape in the two packages CI typechecks separately (commit `338faa58a7ea`)
- chore(sdk): regenerate the conformance artifacts over main's retired contracts (commit `43e4ee9ef5c8`)
- feat(export)!: carry Shape certificates as pinned wref strings (commit `30187cb8082e`)
- fix(collections)!: name a foreign certifying Shape by its repository (commit `18478e45b47f`)

Reconstructed from SDK release tags.

## 0.14.4

### Release changes

- chore(compat): #10974 retire transitional client contracts and startup adoption (commit `4347a0500db6`)

Reconstructed from SDK release tags.

## 0.14.3

### Release changes

- chore(sdk): regenerate the contract artifacts for the count result's hasMore (commit `414aefde7369`)

Reconstructed from SDK release tags.

---

Historical changelog shipped with warmhub 0.14.2:

# Changelog — `warmhub`

## 0.14.2

Repository export v3.

### Breaking

#### Checkpoint generation is retired — take an export instead

**What it was.** Producing a fresh snapshot of a repository meant asking the
server to mint a checkpoint, polling `status` until the job completed,
requesting a short-lived signed URL, downloading the archive outside the SDK,
and verifying the bytes locally.

**What it is now.** One call that streams verified rows. Framing, canonical
decode, per-row schema, ordering and the trailer are checked as the bytes
arrive, and a response that dies mid-body is re-requested from the last
verified row into the *same* reader — so a stream that arrived in four pieces
still verifies against one digest and one record count.

**Migration.**

Before:

```python
import time

import httpx

from warmhub import verify_repository_checkpoint_archive

status = client.repo.checkpoint.generate("acme", "catalog")
while status.state != "complete":
    time.sleep(1)
    status = client.repo.checkpoint.status(
        "acme", "catalog", checkpoint_id=status.checkpoint_id
    )

access = client.repo.checkpoint.get_access(
    "acme", "catalog", checkpoint="latest", artifact="archive"
)
# Signed URL: fetched WITHOUT the WarmHub bearer token.
with open("catalog.zip", "wb") as handle:
    handle.write(httpx.get(access.url).content)
verify_repository_checkpoint_archive("catalog.zip")
```

After:

```python
from warmhub import open_repository_export

export = open_repository_export(client, "acme", "catalog")
for row in export.rows:
    print(row["kind"], row["durableId"], row["version"])

# `reader.trailer` is None until `rows` is fully consumed: the export is
# verified at that moment, not before.
print(export.reader.record_count, export.reader.content_sha256)
```

If you were relying on `generate` returning before the download — starting the
build in one process and fetching it in another, or on another machine — that
shape survives as the token flow:

```python
import time

from warmhub import (
    RepositoryExportPending,
    open_repository_export_token,
    request_repository_export,
)

# Process A: ask for it, get a ticket, do not read a byte.
ticket = request_repository_export(client, "acme", "catalog")
print(ticket.export_token, ticket.at_repo_seq)

# Process B: redeem it. A redemption answered 202 comes back as
# RepositoryExportPending; poll it yourself.
while True:
    opened = open_repository_export_token(
        client, "acme", "catalog", ticket.export_token
    )
    if not isinstance(opened, RepositoryExportPending):
        break
    print("still", opened.state)  # "pending" or "running"
    time.sleep(1)

for row in opened.rows:
    ...
```

The token is redeemable only at that repository's URL, by a principal holding
checkpoint read there.

**Why.** Export format v3 replaces checkpoint generation outright; the whole
generation pipeline is deleted server-side (PR #10020).

#### `repo.checkpoint.generate` and `.retry` are gone

**What it was.** `generate(org_name, repo_name, at_least_repo_seq=...)` minted
a checkpoint and `retry(org_name, repo_name, checkpoint_id)` re-ran a failed
one, on the unbound namespace and on the repository-bound one, in both the sync
and async clients. The payload builders
`checkpoint_generate_payload` and `checkpoint_retry_payload` are gone from
`warmhub.namespaces.repo_checkpoint` with them.

**What it is now.** `repo.checkpoint` is a read plane: `status`, `latest`,
`get_access`. The procedures behind `generate` and `retry` are no longer served,
and the `repo:checkpoint-generate` scope no longer buys anything.

**Migration.**

Before:

```python
fresh = client.repo.checkpoint.generate("acme", "catalog", at_least_repo_seq=42)
recovered = client.repository("acme/catalog").repo.checkpoint.retry(
    failed.checkpoint_id
)
```

After:

```python
from warmhub import open_repository_export

# `at_repo_seq` pins the fence the way `at_least_repo_seq` asked for a floor.
# Omit it and the server pins current and echoes it back in the header.
export = open_repository_export(client, "acme", "catalog", at_repo_seq=42)
for row in export.rows:
    ...
```

There is no retry to replace: an export is a read, so a failed one is re-read,
not re-queued — and the reader resumes a broken transport on its own, up to
`resume_limit` (4 by default).

**Why.** Same retirement (PR #10020).

#### Failed checkpoint statuses now all answer `next_action="none"`

**What it was.** `RepositoryCheckpointFailedDeadlineExceeded` and
`RepositoryCheckpointFailedAttemptsExhausted` carried
`next_action="retry"`; `RepositoryCheckpointFailedInvalidSource` carried
`next_action="generate"`. Each named a method a reader could call.

**What it is now.** All three carry `next_action="none"`. The dataclasses keep
their names and their `failure_code` values, so pattern-matching on
`failure_code` is unaffected; only branching on `next_action` changes.

**Migration.**

Before:

```python
if status.state == "failed":
    if status.next_action == "retry":
        client.repo.checkpoint.retry("acme", "catalog", status.checkpoint_id)
    elif status.next_action == "generate":
        client.repo.checkpoint.generate("acme", "catalog")
    elif status.next_action == "contact_support":
        report(status.checkpoint_id, status.failure_code)
```

After:

```python
if status.state == "failed":
    if status.next_action == "contact_support":
        report(status.checkpoint_id, status.failure_code)
    else:
        # Terminal. Take an export instead of reviving the checkpoint.
        export = open_repository_export(client, "acme", "catalog")
        for row in export.rows:
            ...
```

**Why.** Each retired value pointed at a route the server no longer serves;
leaving them in the vocabulary would send readers to a 404 (PR #10020).

### Added

- **`open_repository_export(client, org_name, repo_name, *, mode="heads",
  since_repo_seq=0, at_repo_seq=UNSET, resume_limit=4)`** — returns a
  `RepositoryExportStream` with `.reader` and `.rows`. `aopen_repository_export`
  is the `async` twin, returning `AsyncRepositoryExportStream` whose `.rows` is
  an `async` iterator.

- **`request_repository_export(...)` / `arequest_repository_export(...)`** —
  ask the server to build the export in the background. Returns a
  `RepositoryExportTicket` with `export_token` and `at_repo_seq`.

- **`open_repository_export_token(client, org_name, repo_name, export_token, *,
  resume_limit=4)`** and its `aopen_...` twin — redeem a ticket. Returns either
  the stream or a `RepositoryExportPending` whose `state` is `"pending"` or
  `"running"`; the first redemption does not poll for you.

- **`RepositoryExportReader`** — the verification state of one logical export
  across one or more transport segments. Read `header`, `trailer`,
  `record_count`, `content_sha256`, `after_durable_id`, and `is_complete`. It
  is also usable directly on bytes you obtained yourself, via
  `read_segment(chunks)` / `feed(chunk)` / `end_segment()` / `finish()`.

- **`verify_repository_export_file(source)`** — re-verify a saved export end to
  end with no network access. Accepts `bytes`, `bytearray`, or a path, and
  returns a `RepositoryExportVerification` with `header`, `trailer`, and
  `record_count`.

  ```python
  from warmhub import verify_repository_export_file

  result = verify_repository_export_file("catalog.ndjson")
  print(result.header.at_repo_seq, result.record_count)
  ```

- **`apply_repository_export_delta(base, delta)`** — fold a delta export over a
  base by durable identity: active rows upsert, tombstones drop. Pure and
  order-preserving — rows already present keep their position, new rows land at
  the end — and it returns a `list`, so folds chain.

  ```python
  from warmhub import apply_repository_export_delta, open_repository_export

  full = open_repository_export(client, "acme", "catalog")
  base = list(full.rows)
  fence = full.reader.header.at_repo_seq

  # Later: only what changed since that fence.
  changed = open_repository_export(client, "acme", "catalog", since_repo_seq=fence)
  current = apply_repository_export_delta(base, list(changed.rows))
  ```

  Durable identity is the key rather than `wref`, because a rename changes the
  wref and this fold must survive one.

- **`RepositoryExportError`** with a typed `reason`: `"count_mismatch"`,
  `"digest_mismatch"`, `"non_canonical_line"`, `"record_invalid"`,
  `"request_failed"`, `"row_order"`, `"segment_mismatch"`,
  `"since_below_epoch_floor"`, `"stream_closed"`, `"truncated_stream"`,
  `"unexpected_response"`. `"since_below_epoch_floor"` is the only one that is
  not a fault: the requested delta base is older than the server retains, and
  the recovery is to re-run the export with `since_repo_seq=0`.

- **`RepositoryExportHeader`, `RepositoryExportTrailer`,
  `RepositoryExportRow`, `RepositoryExportMode`,
  `RepositoryExportVerification`** — the record types. A row is a validated
  mapping, not a dataclass: `data` holds your own Shape fields, so decoding it
  into a frozen class would only be something you had to undo. The envelope
  keys — `kind`, `wref`, `durableId`, `version`, `active` — are guaranteed
  present.

- **`stream=True` on `SyncTransport.send` / `AsyncTransport.send`** — returns
  before the body is read, for responses whose whole point is not to be
  buffered. The caller owns closing the response.

### Behavior worth knowing

- **A broken transport recovers itself, differently on the two paths.** A
  direct stream that ends short, or dies mid-body, is re-requested from
  `after_durable_id` into the same reader; the partial trailing line is never
  fed, because the next `begin_segment` drops it. A redeemed token has no
  cursor — its bytes come from storage rather than the API — so a dead transfer
  fetches the object again from the top and the reader restarts with it. Either
  way you are never handed the same row twice. Both are bounded by
  `resume_limit` (default 4) and raise `truncated_stream` when it runs out — a
  server that keeps cutting the stream should surface, not spin.

- **A redeemed token is downloaded from storage, then held to what the API
  said.** A complete redemption answers with a short-lived presigned URL rather
  than the export bytes, and the SDK fetches that URL *without* your WarmHub
  bearer: the signature in the URL is the whole capability, and the storage host
  has no business seeing a credential. The record count and content digest the
  reader derives are then checked against the ones the API named, because the
  trailer inside the object cannot vouch for the object — a download that does
  not match raises `count_mismatch` or `digest_mismatch` rather than handing you
  rows nothing independent vouched for. An expired URL re-redeems the token,
  which is a deterministic lookup, and retries.

- **Ordering is verified, in `heads` mode only.** A heads export is a set
  emitted in ascending durable-identity order, and that order is part of what
  the digest proves; a row that does not advance the cursor raises
  `row_order`. An `ops` export is a log and repeats identities by design.

- **A trailer describes its segment, not the export.** `RepositoryExportTrailer`
  carries the `record_count` and `content_sha256` of the rows in the segment it
  closed — the whole export only when it arrived in one piece.
  `reader.record_count` and `reader.content_sha256` are the whole-stream values.

- **`202` means different things on different paths.** On a token redemption it
  is the job still building, and comes back as `RepositoryExportPending`. On a
  direct or resumed stream request it is `unexpected_response` — there, the
  stream itself was supposed to be the whole answer.

- **Verification finishes with the rows.** `reader.trailer` is `None` until
  `rows` has been fully consumed. Abandoning the iterator mid-way leaves the
  export unverified; do not treat partially-read rows as proven.

### Still supported

The checkpoint **read** plane is untouched: `repo.checkpoint.status`,
`.latest`, `.get_access`, and `verify_repository_checkpoint_archive` all continue
to work against archives already in object storage. Nothing mints new ones.

**Deprecation horizon:** those read surfaces are supported until the announced
cleanup, 30 days after GA. Their removal is a separately-filed post-GA change.
Move download-and-verify workflows to the export surface before then.
