# warmhub changelog

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

## 0.25.0

No new release notes.

## 0.24.1

No new release notes.

## 0.24.0

No new release notes.

## 0.23.0

No new release notes.

## 0.22.0

No new release notes.

## 0.21.1

No new release notes.

## 0.21.0

### Changed

A resumed repository export now sends the `format_version` of the header the download opened with, and the server refuses a resume whose format is not the one it writes: HTTP 412 `client-incompatible` with the message "Restart the export; a resume must use the format it started with." The refusal raises `RepositoryExportError` (`request_failed`) and the reader writes nothing further. A download started before a server upgrade that changes the export format can no longer be silently finished under its old header; the export must be restarted.

A resume of a file that holds only the header (interrupted before the first row) sends no cursor, so the server cannot check it; the session now compares the new header's format to the saved one and raises `RepositoryExportError` (`segment_mismatch`) with the same restart message rather than appending new-format rows under the old header.

**Upgrade with the server.** An older SDK sends no `formatVersion` on a resume, so a server with this change refuses every resume from it, not only cross-format ones. Upgrade the SDK in the same release as the server.

### Fixed

A repository export downloaded through an export token is now also checked against the redemption's `at_repo_seq`, as sdk-ts already does. A download pinned to a different fence than the one the API named raises `RepositoryExportError` with the new reason `fence_mismatch`; before, only the record count and content digest were checked.

### Added

`fence_mismatch` is a new `RepositoryExportErrorReason` value. Code that matches every reason exhaustively should handle it.

### Changed

`thing.get` and `thing.get_many` (async, sync, and bound forms) now type `data_mode` as `CollectionReadDataMode` (`"auto" | "full"`), which is all the server accepts on those reads. Previously the annotation was the head-family `DataMode`, so `get_many(data_mode="none")` type-checked and then failed with a 400. `thing.head`, `head_iter`, `head_all` and the changes reads keep `DataMode` (`"full" | "summary" | "none"`). For a metadata-only read, use `thing.head(data_mode="none")`. `CollectionReadDataMode` is exported from `warmhub`.

### Breaking

#### `NAME_TAKEN` names the holder by durable id only and offers no replacement name

**What it was.** `NameTaken.taken_by` was a `NameTakenBy` with `shape` and `durable_id`, `NameTaken.suggested_name` could carry a replacement name, `WarmHubError.suggested_name` returned it, and the message said `by a <Shape> Thing` and `for example "<name>"`.

**What it is now.** `NameTakenBy` has `durable_id` only; `shape` is removed. `NameTaken.suggested_name` and the `WarmHubError.suggested_name` property are removed, and no example name appears in the message or hint. The message names no Shape: `Name "acme" is already taken in this repository by another Thing; choose another name`. A caller who cannot read the holder still gets no `taken_by`. The decoder requires only `takenBy.durableId`.

**Why it breaks.** This is a client-visible breaking change, accepted. Released sdk-py 0.19.0 and 0.20.0 decode `takenBy` with `shape` required, so they refuse a `name_taken` result from a server that carries this change. It lands before the v2 floor release (0.21.0), so no v2 client sees `shape`.

**Migration.** Read the holder's Shapes with `thing.get` on `details.taken_by.durable_id`; a Thing can have several Shapes or none. Choose the replacement name yourself.

```python
# Before
if isinstance(err.details, NameTaken):
    print(err.details.taken_by.shape, err.suggested_name)

# After
if isinstance(err.details, NameTaken) and err.details.taken_by:
    holder = client.thing.get(org, repo, err.details.taken_by.durable_id)
```

### Added

`Restore` and `OperationBuilder.restore()` make a retracted Thing active again, by its durable ID. Pass `new_name` to restore it under another name.

In these notes, "old grammar" means the reference form used before this release (`Deal/acme`, operations with `kind`), and "new grammar" the form this release introduces (`acme`, operations with `shapes`).

### Breaking

#### Write operations take a name and its Shapes

**What it was.** `Add` and `Revise` took keyword-only `name=`. `Add` took
`about`; both took `kind`, `type` and `members`; `Revise` also took
`wref`. `Retract`, `Reaffirm`
and `Rename` took `kind`. An add name was spelled `Shape/name`, and the SDK
split it and converted the operation.

**What it is now.** `name` is the full Thing name, taken literally, and can
be passed positionally. Every add and revise states its Shapes in exactly
one way: `shapes=[...]`, `shapeless=True`, or (revise only)
`keep_shapes=True`. Passing a removed keyword to a dataclass raises
`TypeError` naming the new spelling. Passing it to a builder method raises
Python's standard "unexpected keyword argument" `TypeError`.

**Migration.**

Before:

```python
from warmhub import Add, Revise, Rename

repo.apply("seed", [
    Add(name="Deal", kind="shape", data={"fields": {"company": "string"}}),
    Add(name="Deal/acme", data={"company": "Acme"}),
    Add(name="Rating/r1", kind="assertion", about="Deal/acme", data={"score": 5}),
    Add(name="crew", kind="collection", type="set", members=["Deal/acme"]),
])
repo.apply("update", [
    Revise(wref="Deal/acme", data={"company": "Acme 2"}),
    Rename("Deal/acme", "acme-co", kind="thing"),
])
```

After:

```python
from warmhub import Add, Revise, Rename, shape_name

repo.apply("seed", [
    Add(shape_name("Deal"), shapes=[], data={"fields": {"company": "string"}}),
    Add("acme", shapes=["Deal"], data={"company": "Acme"}),
    Add("r1", shapes=["Rating"], data={"about": "acme", "score": 5}),
    Add("crew", shapes=["Set"], data={"members": ["acme"]}),
])
repo.apply("update", [
    Revise("acme", keep_shapes=True, data={"company": "Acme 2"}),
    Rename("acme", "acme-co"),
])
```

- In this example, `Rating` is an existing Shape that composes `Assertion`.
  An assertion is a Thing one of whose declared Shapes composes `Assertion`,
  with its target in `data["about"]`.
- `shapes` takes bare Shape names. A pinned `Deal@v2` is refused: a write is
  always checked against each Shape's current version.
- A raw mapping states a shapeless write as `"shapes": []`. `shapeless=True`
  is a dataclass and builder option, and is refused on a `hub/shapes/X`
  name.
- A `keep_shapes` revise of a Thing added or renamed earlier in the same
  submission is refused; state `shapes` instead.

#### Old `Shape/name` spellings are taken literally

**What it was.** `"Deal/acme"` meant the Thing `acme` under Shape `Deal`.

**What it is now.** It is a Thing name. This matters wherever a name is
accepted:

- `Add(name="Deal/acme", data=...)` still has a valid `name=` keyword, so
  it does not raise `TypeError`. It raises `WriteOperationError` ("states no
  Shapes"). If you only add `shapes=` and keep the name,
  `Add(name="Deal/acme", shapes=["Deal"], ...)` succeeds and creates a new
  Thing literally named `Deal/acme`.
- `Retract("Deal/acme")`, `Reaffirm("Deal/acme", ...)` and
  `Rename("Deal/acme", ...)` pass the local checks and are sent as written.
  The server answers `NOT_FOUND`, naming the new form when it can, or acts on
  a Thing literally named `Deal/acme` if one exists.
- `Revise("Deal/acme", keep_shapes=True)` fails locally with `NOT_FOUND`
  (no such Thing), with no hint.
- Read calls (`thing.get`, `get_many`, history, `thing.remove`, collection
  calls, `CommitOptions.committer`, `client.repository("wh:...")`) take the
  new form. An old spelling is looked up literally: a single read gets
  `NOT_FOUND`; `get_many` lists it in `missing` and may explain it in
  `missing_hints`. Returned `wref` values use the new form too.

**Migration.** Rename the argument as well as adding `shapes`:
`"Deal/acme"` becomes `"acme"`, and a Shape where any Thing can appear
becomes `shape_name("Deal")`.

#### Builder `add` and `revise` take the name first

**What it was.** `batch.add(name=..., kind=..., ...)` and
`batch.revise(name=..., wref=..., ...)` were keyword-only. `rename` took
`kind=`.

**What it is now.** `add(name, *, shapes=|shapeless=, data=, ...)` and
`revise(name, *, shapes=|shapeless=|keep_shapes=, data=, ...)`.
`name=` as a keyword still works. `retract`, `reaffirm` and `rename` lose
`kind=`. `add` and `revise` check the operation when it is queued and raise
`WriteOperationError` there, instead of leaving it for `validate()`.
`validate()` no longer reports `MISSING_NAME` or `MISSING_WREF`. A `shapes=`
validation mapping validates data against every stated Shape.

**Migration.**

Before:

```python
batch = repo.batch(message="seed")
batch.add(name="Reading/probe-1", data={"temp_celsius": 21.4})
batch.retract("Reading/probe-0", kind="thing")
batch.commit()
```

After:

```python
batch = repo.batch(message="seed")
batch.add("probe-1", shapes=["Reading"], data={"temp_celsius": 21.4})
batch.retract("probe-0")
batch.commit()
```

#### Old-grammar mappings are refused, not converted

**What it was.** A mapping with `kind`, `Shape/name`, top-level `about` or
collection `type`/`members` was converted before sending.

**What it is now.** A mapping with `kind`, `shape`, `wref`,
`declaredShapes`, `about`, `type` or `members`, or without `operation`,
raises `WriteOperationError` before any request. The message suggests a new
form, with data values replaced by `"..."`. An add or revise mapping with no
`shapes` (for example `{"operation": "add", "name": "Deal/acme", ...}`)
gets the "states no Shapes" message instead. A retract, reaffirm or rename
mapping is not checked for old spellings; see the previous item.

**Migration.**

```python
# Before: converted and sent
repo.apply("m", [{"operation": "add", "kind": "thing", "name": "Deal/acme", "data": {"x": 1}}])

# After: WriteOperationError
#   Invalid operation at index 0: this is the v1 write form ('kind').
#   Write it in the v2 form: {"operation":"add","name":"acme","shapes":["Deal"],"data":{"x":"..."}}
repo.apply("m", [{"operation": "add", "name": "acme", "shapes": ["Deal"], "data": {"x": 1}}])
```

Other local refusals: non-empty `shapes` on a `hub/shapes/X` name; a
`shapes` entry that is not a bare Shape name; `affirmed_targets` on a
shapeless write; renaming across the Shape/Thing line; a name under the
reserved `hub` root (`RESERVED_NAME`); `+` in a name; the same name added
twice in one submission (`ILLEGAL_OP_SEQUENCE`).

#### Local write refusals raise `WriteOperationError`

**What it was.** The builder raised `ValueError` (validation) and
`RuntimeError` (already submitted, in flight). `repo.apply` raised
`StreamValidationError` per operation. `commit.validate` raised a plain
`WarmHubError`.

**What it is now.** All raise `WriteOperationError`, a `WarmHubError` with
`operation_index` (`None` for a whole-batch refusal).
`StreamValidationError` is raised only for an empty operation list or an
invalid `submission_id`. `except WarmHubError` still catches everything;
`except ValueError`, `except RuntimeError` and
`except StreamValidationError` no longer catch operation refusals.
`warmhub.operations.to_backend_stream_operation(operation, index=0)` now
returns the new wire form and raises `WriteOperationError`.

**Migration.**

```python
# Before
try:
    batch.commit()
except ValueError as e:
    ...

# After
from warmhub import WriteOperationError

try:
    batch.commit()
except WriteOperationError as e:
    print(e.code, e.operation_index)
```

#### `thing.rename` takes full names

**What it was.** `thing.rename(org, repo, shape_name, old_name, new_name, *,
event_request_id)`.

**What it is now.** `thing.rename(org, repo, name, new_name, *,
event_request_id)`, both full names.

**Migration.**

```python
# Before
client.thing.rename("acme", "world", "Deal", "acme", "acme-co", event_request_id=req_id)

# After
client.thing.rename("acme", "world", "acme", "acme-co", event_request_id=req_id)
```

#### Read models drop the singular Shape fields

**What it was.** `ThingItem.shape_name`; `ThingDetail.shape`,
`.shape_name`, `.validated_shape`.

**What it is now.** Removed. Use `declared_shapes` (the Shapes the version
states) and `validated_shapes` (the Shapes it satisfies, including composed
ones). Both hold version-pinned names such as `"Deal@v3"`. Generated wire
classes change the same way: `WireThingDetail`, `ThingGetManyResult` items,
`ThingGetWithLease`, `ThingGraphResult` and `Assertion` lose all three;
`WireThingItem`, component owned Things, history versions and
view-evaluate items lose `shape_name`. Refs items and history's `thing`
replace `shape_name` with `declared_shapes` (history also gains
`validated_shapes`). `SynthesizedRepoContent.shape` becomes
`declared_shapes` / `validated_shapes`. `shape=` as a read filter is
unchanged.

**Migration.**

```python
# Before
detail.shape_name            # "Deal"
# After
detail.declared_shapes       # ("Deal@v3",)
detail.validated_shapes      # ("Deal@v3",) plus any composed Shapes
```

#### Token scopes use `grants`

**What it was.** A scope entry carried `allowed_matches` and
`allowed_shapes`.

**What it is now.** Restrictions are a list of (paths, Shapes) pairs in
`grants`. The output models for `token.create`, `token.get`, `token.list`
and whoami expose `grants`; each item has `allowed_matches` and
`allowed_shapes` (`shapes`, `shapes_all`, `shapes_not`, `shapeless`), and
the entry-level fields are gone. On input, `TokenCreateInputScopesItem`
still has entry-level `allowed_matches` / `allowed_shapes` fields, but the
server refuses them. The generated `TokenCreateInputScopesItemAllowedShapes`,
`TokenCreateResultScopesItemAllowedShapes` and
`TokenMetadataScopesItemAllowedShapes` are replaced by their
`...GrantsItemAllowedShapes` forms.

A pair must carry a non-empty `allowed_matches`, or an `allowed_shapes`
with at least one key; an empty `grants`, an empty pair, and an empty
`allowed_shapes` are refused. `shapeless=True` in a clause is not a
restriction of its own: it lets Things with no Shape through. Alone it
admits every Thing the pair's paths match, shaped or shapeless; beside
`shapes` it adds shapeless Things to the Things the clause covers. No clause
restricts a pair to shapeless Things only.

**Migration.**

```python
# Before
for s in info.scopes:
    print(s.allowed_matches, s.allowed_shapes)

# After
for s in info.scopes:
    for g in s.grants or ():
        print(g.allowed_matches, g.allowed_shapes)
```

#### Repository exports are format 6, and the delta fold needs format versions

**What it was.** Exports were format 5, and
`apply_repository_export_delta(base, delta)` took two arguments.

**What it is now.** The server writes format 6 (new-grammar references, one
row per Shape version, `retracted: true` on retracted Shape rows). This SDK
reads 5 and 6; older SDKs read only 5. `apply_repository_export_delta` takes
two required keyword arguments, and folding across formats raises
`RepositoryExportError("format_mismatch")`. In format 6 a Shape row is keyed
by `(durableId, version)`. `RepositoryExportHeader` has a required
`format_version`.

**Migration.**

```python
# Before
current = apply_repository_export_delta(base, list(changed.rows))

# After
current = apply_repository_export_delta(
    base,
    list(changed.rows),
    base_format_version=full.reader.header.format_version,
    delta_format_version=changed.reader.header.format_version,
)
```

#### Writes need a server running this release

**What it was.** The SDK wrote to any current server.

**What it is now.** Against an older server, writes raise `WarmHubError`
with code `CLIENT_INCOMPATIBLE` and the message "warmhub-sdk-py requires write
contract 3, but the backend provides 2."

**Migration.** Upgrade the SDK after the server.

#### Generated error-details classes renumbered

**What it was.** In `warmhub._generated.wire`, `CommitDiagnosticDetails8`
meant `reason="invalid_token"`, and every other `*Details8` class did too.

**What it is now.** `CommitDiagnosticDetails8` means
`reason="legacy-wrefs-window-ended"`, and `invalid_token` moved to
`CommitDiagnosticDetails9`. Every `*Details8` class in
`warmhub._generated.wire` shifts the same way. `WarmHubErrorDetails9`
(`invalid_token`) and `WarmHubErrorDetails10` (`legacy-wrefs-sdk`) are new.

**Migration.** Compare `reason` instead of the class.

```python
from warmhub._generated.wire import CommitDiagnosticDetails8

# Before
if isinstance(d, CommitDiagnosticDetails8):  # invalid_token
    ...
# After
if getattr(d, "reason", None) == "invalid_token":
    ...
```

#### Generated write-input unions reshaped

**What it was.** `CommitValidateInputOperationsItem0` to `Item11`, with
`kind`.

**What it is now.** They are replaced by `Item00` (add) to `Item04`
(rename) and `Item1` (remove). `StreamAppendInput.operations` uses new
operation classes without `kind`. `commit_validate_payload` (in
`warmhub.namespaces.commit_validate`) now takes resolved wire operations and
no `skip_existing`; the new `plan_commit_validation(operations,
skip_existing=...)` plans them.

**Migration.** Use the top-level operation classes and client methods
rather than the generated classes.

### Added

- **`shape_name(bare)`** — `"Deal"` to `"hub/shapes/Deal"`; an already
  prefixed name is returned unchanged.
  ```python
  from warmhub import shape_name
  shape_name("Deal")  # "hub/shapes/Deal"
  ```
- **`WriteOperationError`** — a `WarmHubError` with `operation_index`; codes
  `VALIDATION_ERROR`, `NOT_FOUND`, `RESERVED_NAME`, `ILLEGAL_OP_SEQUENCE`,
  and Shape-name check codes. `status` is 404 for `NOT_FOUND`, else 400.
- **`Revise(..., keep_shapes=True)`** — at submit the SDK reads the Thing's
  current Shapes and version (one read per 500 Things) and sends both, the
  version as `expectedVersion`; a supplied `expected_version` wins. A missing
  Thing is refused with `NOT_FOUND`. A Thing added, or renamed to or from,
  earlier in the same submission is refused. A Thing revised earlier in the
  same submission reuses that revise's Shapes. `keep_shapes` pins the version
  it read, so a resubmit of the same `submission_id` from another process
  can conflict; explicit `shapes` is the retry-safe form.
  ```python
  repo.apply("m", [Revise("acme", keep_shapes=True, data={"company": "Acme 2"})])
  ```
- **`shapeless=True`** — on `Add`, `Revise` and the builder; sent as
  `shapes: []`.
- **`OperationWarnings.shapeless`** — `True` when a write left the Thing
  with no Shape.
  ```python
  res = repo.apply("m", [Add("scratch", shapeless=True, data={})])
  res.operations[0].warnings.shapeless  # True
  ```
- **`shapeless` read filter** — on `thing.head`, `head_iter`, `head_all`,
  `thing.query`, `count`, `query_iter` and `query_all` (sync, async and
  repository-bound). Not on `search`.
  ```python
  client.thing.head("acme", "crm", shapeless=True)
  ```
- **`ThingGetManyResult.missing_hints`** — for a missing reference that is
  an old `Shape/name` spelling of a Thing you can read, the text naming its
  new form.
- **`DEPENDENCY_FAILED`** — constant for the per-operation code of an
  operation that failed only because an operation it read from failed.
- **`LEGACY_WREFS_REFUSAL`** — the refusal message string.
- **`RepositoryExportReader.after_version`** — resume position inside a
  format 6 Shape; the client sends it on resume.
- **`RepositoryExportErrorReason`** gains `"format_mismatch"`.
- **`redacted_resource`** — may appear on `TokenCreateResult` scopes.

### Behavior worth knowing

- **`client_flags=["legacy-wrefs"]` refuses every request.** The SDK does
  not send old-grammar requests. With the flag set, every request (reads,
  writes, the capabilities check, export download) raises `WarmHubError`
  with code `CLIENT_INCOMPATIBLE`, a message equal to
  `warmhub.LEGACY_WREFS_REFUSAL`, and `details.reason == "legacy-wrefs-sdk"`.
  Use the `wh` CLI to submit old-grammar files during the compatibility
  window.
  ```python
  client = WarmHubClient(..., client_flags=["legacy-wrefs"])
  client.thing.get("acme", "world", "acme")
  # WarmHubError CLIENT_INCOMPATIBLE:
  #   `legacy-wrefs` is set; the SDKs are v2-only. Unset it, or use the `wh` CLI for v1.
  ```

### Changed

- **`collection.revise`** — before, a selector-backed revise whose target
  was spelled with another collection Shape (`List/...`, `Arc/...`) was
  refused locally. Now the server decides.
- **Local sequence checks** — `repo.apply` now checks only for the same name
  added twice; other sequence problems are reported by the server.
  `commit.validate` now also refuses adding the same name twice locally.
- **`warmhub.rules.preflight_op_diagnostics` messages** teach the new form,
  for example `Cannot add kind="thing" under built-in shape "Arc". Name the
  Thing and state the Shape instead: {...}`, and refuse a write declaring the
  abstract `Collection` Shape. The SDK's own write paths refuse `kind`
  operations before these checks run.
- **Content-limit error labels** — `Field "Readme.content" is N bytes; ...`
  instead of `Field "Content/Readme.content" is N bytes; ...`, and the same
  for `Agents.content`.
- **Pinned Shapes on `stream.append`** are refused locally.

### Removed

- Dataclass and builder keywords `kind`, `about`, `type`, `members`, `wref`.
- `ThingItem.shape_name`; `ThingDetail.shape`, `.shape_name`,
  `.validated_shape`; the matching generated wire fields.
- Entry-level `allowed_matches` / `allowed_shapes` on token scope output
  models.
- Generated `CommitValidateInputOperationsItem0`–`Item11` and the three
  `Token*ScopesItemAllowedShapes` classes named above.

### Fixed

`commit.validate` now accepts a direct remove, as sdk-ts does. Pass a plain mapping: `{"operation": "remove", "name": "acme", "expectedVersion": 3}`. `name` must be a non-empty string and `expectedVersion` a positive integer; anything else raises `WriteOperationError` before any request. A remove is sent as written, is never part of the duplicate-add check and is not read for Shapes. Before, it was refused with "'operation' must be one of add, revise, retract, reaffirm, rename". There is no `Remove` class: `commit.apply` still refuses removes, and `thing.remove` performs one.

A raw operation with a `keepShapes` key that is present but not `true` (for example `None` or `False`) is now refused locally with "keepShapes must be true", as sdk-ts does. Before, `None` was treated as absent and sent.

`Revise("a", shapes=[...], keep_shapes=False)` now omits the key, so the operation states its Shapes and is accepted. Before, it sent `keepShapes: false` and was refused with "keepShapes applies only to a revise". `keep_shapes=False` with no other way of stating Shapes is refused as "states no Shapes".

## 0.20.0

No new release notes.

## 0.19.1

No new release notes.

## 0.19.0

No new release notes.

## 0.18.0

No new release notes.

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