{
  "schemaVersion": 1,
  "sdk": "sdkpy",
  "baseline": {
    "version": "0.14.2",
    "markdown": "# Changelog — `warmhub`\n\n## 0.14.2\n\nRepository export v3.\n\n### Breaking\n\n#### Checkpoint generation is retired — take an export instead\n\n**What it was.** Producing a fresh snapshot of a repository meant asking the\nserver to mint a checkpoint, polling `status` until the job completed,\nrequesting a short-lived signed URL, downloading the archive outside the SDK,\nand verifying the bytes locally.\n\n**What it is now.** One call that streams verified rows. Framing, canonical\ndecode, per-row schema, ordering and the trailer are checked as the bytes\narrive, and a response that dies mid-body is re-requested from the last\nverified row into the *same* reader — so a stream that arrived in four pieces\nstill verifies against one digest and one record count.\n\n**Migration.**\n\nBefore:\n\n```python\nimport time\n\nimport httpx\n\nfrom warmhub import verify_repository_checkpoint_archive\n\nstatus = client.repo.checkpoint.generate(\"acme\", \"catalog\")\nwhile status.state != \"complete\":\n    time.sleep(1)\n    status = client.repo.checkpoint.status(\n        \"acme\", \"catalog\", checkpoint_id=status.checkpoint_id\n    )\n\naccess = client.repo.checkpoint.get_access(\n    \"acme\", \"catalog\", checkpoint=\"latest\", artifact=\"archive\"\n)\n# Signed URL: fetched WITHOUT the WarmHub bearer token.\nwith open(\"catalog.zip\", \"wb\") as handle:\n    handle.write(httpx.get(access.url).content)\nverify_repository_checkpoint_archive(\"catalog.zip\")\n```\n\nAfter:\n\n```python\nfrom warmhub import open_repository_export\n\nexport = open_repository_export(client, \"acme\", \"catalog\")\nfor row in export.rows:\n    print(row[\"kind\"], row[\"durableId\"], row[\"version\"])\n\n# `reader.trailer` is None until `rows` is fully consumed: the export is\n# verified at that moment, not before.\nprint(export.reader.record_count, export.reader.content_sha256)\n```\n\nIf you were relying on `generate` returning before the download — starting the\nbuild in one process and fetching it in another, or on another machine — that\nshape survives as the token flow:\n\n```python\nimport time\n\nfrom warmhub import (\n    RepositoryExportPending,\n    open_repository_export_token,\n    request_repository_export,\n)\n\n# Process A: ask for it, get a ticket, do not read a byte.\nticket = request_repository_export(client, \"acme\", \"catalog\")\nprint(ticket.export_token, ticket.at_repo_seq)\n\n# Process B: redeem it. A redemption answered 202 comes back as\n# RepositoryExportPending; poll it yourself.\nwhile True:\n    opened = open_repository_export_token(\n        client, \"acme\", \"catalog\", ticket.export_token\n    )\n    if not isinstance(opened, RepositoryExportPending):\n        break\n    print(\"still\", opened.state)  # \"pending\" or \"running\"\n    time.sleep(1)\n\nfor row in opened.rows:\n    ...\n```\n\nThe token is redeemable only at that repository's URL, by a principal holding\ncheckpoint read there.\n\n**Why.** Export format v3 replaces checkpoint generation outright; the whole\ngeneration pipeline is deleted server-side (PR #10020).\n\n#### `repo.checkpoint.generate` and `.retry` are gone\n\n**What it was.** `generate(org_name, repo_name, at_least_repo_seq=...)` minted\na checkpoint and `retry(org_name, repo_name, checkpoint_id)` re-ran a failed\none, on the unbound namespace and on the repository-bound one, in both the sync\nand async clients. The payload builders\n`checkpoint_generate_payload` and `checkpoint_retry_payload` are gone from\n`warmhub.namespaces.repo_checkpoint` with them.\n\n**What it is now.** `repo.checkpoint` is a read plane: `status`, `latest`,\n`get_access`. The procedures behind `generate` and `retry` are no longer served,\nand the `repo:checkpoint-generate` scope no longer buys anything.\n\n**Migration.**\n\nBefore:\n\n```python\nfresh = client.repo.checkpoint.generate(\"acme\", \"catalog\", at_least_repo_seq=42)\nrecovered = client.repository(\"acme/catalog\").repo.checkpoint.retry(\n    failed.checkpoint_id\n)\n```\n\nAfter:\n\n```python\nfrom warmhub import open_repository_export\n\n# `at_repo_seq` pins the fence the way `at_least_repo_seq` asked for a floor.\n# Omit it and the server pins current and echoes it back in the header.\nexport = open_repository_export(client, \"acme\", \"catalog\", at_repo_seq=42)\nfor row in export.rows:\n    ...\n```\n\nThere is no retry to replace: an export is a read, so a failed one is re-read,\nnot re-queued — and the reader resumes a broken transport on its own, up to\n`resume_limit` (4 by default).\n\n**Why.** Same retirement (PR #10020).\n\n#### Failed checkpoint statuses now all answer `next_action=\"none\"`\n\n**What it was.** `RepositoryCheckpointFailedDeadlineExceeded` and\n`RepositoryCheckpointFailedAttemptsExhausted` carried\n`next_action=\"retry\"`; `RepositoryCheckpointFailedInvalidSource` carried\n`next_action=\"generate\"`. Each named a method a reader could call.\n\n**What it is now.** All three carry `next_action=\"none\"`. The dataclasses keep\ntheir names and their `failure_code` values, so pattern-matching on\n`failure_code` is unaffected; only branching on `next_action` changes.\n\n**Migration.**\n\nBefore:\n\n```python\nif status.state == \"failed\":\n    if status.next_action == \"retry\":\n        client.repo.checkpoint.retry(\"acme\", \"catalog\", status.checkpoint_id)\n    elif status.next_action == \"generate\":\n        client.repo.checkpoint.generate(\"acme\", \"catalog\")\n    elif status.next_action == \"contact_support\":\n        report(status.checkpoint_id, status.failure_code)\n```\n\nAfter:\n\n```python\nif status.state == \"failed\":\n    if status.next_action == \"contact_support\":\n        report(status.checkpoint_id, status.failure_code)\n    else:\n        # Terminal. Take an export instead of reviving the checkpoint.\n        export = open_repository_export(client, \"acme\", \"catalog\")\n        for row in export.rows:\n            ...\n```\n\n**Why.** Each retired value pointed at a route the server no longer serves;\nleaving them in the vocabulary would send readers to a 404 (PR #10020).\n\n### Added\n\n- **`open_repository_export(client, org_name, repo_name, *, mode=\"heads\",\n  since_repo_seq=0, at_repo_seq=UNSET, resume_limit=4)`** — returns a\n  `RepositoryExportStream` with `.reader` and `.rows`. `aopen_repository_export`\n  is the `async` twin, returning `AsyncRepositoryExportStream` whose `.rows` is\n  an `async` iterator.\n\n- **`request_repository_export(...)` / `arequest_repository_export(...)`** —\n  ask the server to build the export in the background. Returns a\n  `RepositoryExportTicket` with `export_token` and `at_repo_seq`.\n\n- **`open_repository_export_token(client, org_name, repo_name, export_token, *,\n  resume_limit=4)`** and its `aopen_...` twin — redeem a ticket. Returns either\n  the stream or a `RepositoryExportPending` whose `state` is `\"pending\"` or\n  `\"running\"`; the first redemption does not poll for you.\n\n- **`RepositoryExportReader`** — the verification state of one logical export\n  across one or more transport segments. Read `header`, `trailer`,\n  `record_count`, `content_sha256`, `after_durable_id`, and `is_complete`. It\n  is also usable directly on bytes you obtained yourself, via\n  `read_segment(chunks)` / `feed(chunk)` / `end_segment()` / `finish()`.\n\n- **`verify_repository_export_file(source)`** — re-verify a saved export end to\n  end with no network access. Accepts `bytes`, `bytearray`, or a path, and\n  returns a `RepositoryExportVerification` with `header`, `trailer`, and\n  `record_count`.\n\n  ```python\n  from warmhub import verify_repository_export_file\n\n  result = verify_repository_export_file(\"catalog.ndjson\")\n  print(result.header.at_repo_seq, result.record_count)\n  ```\n\n- **`apply_repository_export_delta(base, delta)`** — fold a delta export over a\n  base by durable identity: active rows upsert, tombstones drop. Pure and\n  order-preserving — rows already present keep their position, new rows land at\n  the end — and it returns a `list`, so folds chain.\n\n  ```python\n  from warmhub import apply_repository_export_delta, open_repository_export\n\n  full = open_repository_export(client, \"acme\", \"catalog\")\n  base = list(full.rows)\n  fence = full.reader.header.at_repo_seq\n\n  # Later: only what changed since that fence.\n  changed = open_repository_export(client, \"acme\", \"catalog\", since_repo_seq=fence)\n  current = apply_repository_export_delta(base, list(changed.rows))\n  ```\n\n  Durable identity is the key rather than `wref`, because a rename changes the\n  wref and this fold must survive one.\n\n- **`RepositoryExportError`** with a typed `reason`: `\"count_mismatch\"`,\n  `\"digest_mismatch\"`, `\"non_canonical_line\"`, `\"record_invalid\"`,\n  `\"request_failed\"`, `\"row_order\"`, `\"segment_mismatch\"`,\n  `\"since_below_epoch_floor\"`, `\"stream_closed\"`, `\"truncated_stream\"`,\n  `\"unexpected_response\"`. `\"since_below_epoch_floor\"` is the only one that is\n  not a fault: the requested delta base is older than the server retains, and\n  the recovery is to re-run the export with `since_repo_seq=0`.\n\n- **`RepositoryExportHeader`, `RepositoryExportTrailer`,\n  `RepositoryExportRow`, `RepositoryExportMode`,\n  `RepositoryExportVerification`** — the record types. A row is a validated\n  mapping, not a dataclass: `data` holds your own Shape fields, so decoding it\n  into a frozen class would only be something you had to undo. The envelope\n  keys — `kind`, `wref`, `durableId`, `version`, `active` — are guaranteed\n  present.\n\n- **`stream=True` on `SyncTransport.send` / `AsyncTransport.send`** — returns\n  before the body is read, for responses whose whole point is not to be\n  buffered. The caller owns closing the response.\n\n### Behavior worth knowing\n\n- **A broken transport recovers itself, differently on the two paths.** A\n  direct stream that ends short, or dies mid-body, is re-requested from\n  `after_durable_id` into the same reader; the partial trailing line is never\n  fed, because the next `begin_segment` drops it. A redeemed token has no\n  cursor — its bytes come from storage rather than the API — so a dead transfer\n  fetches the object again from the top and the reader restarts with it. Either\n  way you are never handed the same row twice. Both are bounded by\n  `resume_limit` (default 4) and raise `truncated_stream` when it runs out — a\n  server that keeps cutting the stream should surface, not spin.\n\n- **A redeemed token is downloaded from storage, then held to what the API\n  said.** A complete redemption answers with a short-lived presigned URL rather\n  than the export bytes, and the SDK fetches that URL *without* your WarmHub\n  bearer: the signature in the URL is the whole capability, and the storage host\n  has no business seeing a credential. The record count and content digest the\n  reader derives are then checked against the ones the API named, because the\n  trailer inside the object cannot vouch for the object — a download that does\n  not match raises `count_mismatch` or `digest_mismatch` rather than handing you\n  rows nothing independent vouched for. An expired URL re-redeems the token,\n  which is a deterministic lookup, and retries.\n\n- **Ordering is verified, in `heads` mode only.** A heads export is a set\n  emitted in ascending durable-identity order, and that order is part of what\n  the digest proves; a row that does not advance the cursor raises\n  `row_order`. An `ops` export is a log and repeats identities by design.\n\n- **A trailer describes its segment, not the export.** `RepositoryExportTrailer`\n  carries the `record_count` and `content_sha256` of the rows in the segment it\n  closed — the whole export only when it arrived in one piece.\n  `reader.record_count` and `reader.content_sha256` are the whole-stream values.\n\n- **`202` means different things on different paths.** On a token redemption it\n  is the job still building, and comes back as `RepositoryExportPending`. On a\n  direct or resumed stream request it is `unexpected_response` — there, the\n  stream itself was supposed to be the whole answer.\n\n- **Verification finishes with the rows.** `reader.trailer` is `None` until\n  `rows` has been fully consumed. Abandoning the iterator mid-way leaves the\n  export unverified; do not treat partially-read rows as proven.\n\n### Still supported\n\nThe checkpoint **read** plane is untouched: `repo.checkpoint.status`,\n`.latest`, `.get_access`, and `verify_repository_checkpoint_archive` all continue\nto work against archives already in object storage. Nothing mints new ones.\n\n**Deprecation horizon:** those read surfaces are supported until the announced\ncleanup, 30 days after GA. Their removal is a separately-filed post-GA change.\nMove download-and-verify workflows to the export surface before then.\n"
  },
  "releases": [
    {
      "version": "0.14.3",
      "sourceSha": "4ab1a8b0c92c8852e3ed2bbfbe1ef17ed0f79dc6",
      "notes": [
        {
          "id": "backfill-sdkpy-0-14-3",
          "body": "### Release changes\n\n- chore(sdk): regenerate the contract artifacts for the count result's hasMore (commit `414aefde7369`)\n\nReconstructed from SDK release tags."
        }
      ]
    },
    {
      "version": "0.14.4",
      "sourceSha": "5e3bfb7a6e3024eb9e16707695d3e25a7e01bbf1",
      "notes": [
        {
          "id": "backfill-sdkpy-0-14-4",
          "body": "### Release changes\n\n- chore(compat): #10974 retire transitional client contracts and startup adoption (commit `4347a0500db6`)\n\nReconstructed from SDK release tags."
        }
      ]
    },
    {
      "version": "0.15.0",
      "sourceSha": "29f70ba169ad15b09f72b7a07782d9662a7d1191",
      "notes": [
        {
          "id": "backfill-sdkpy-0-15-0",
          "body": "### Release changes\n\n- feat(collections): Arc and Bond become ordinary Things under protected V2 Shapes (commit `50fec9ce2a8f`)\n- fix(collections): suppress the migrated Arc THING on replay and reserve Collection in Python (commit `171e5daabba7`)\n- feat(collections): the Collections V2 cutover with a legacy-sugar window (commit `adaaa4fe53dc`)\n- feat(collections): classify inbound traversal by closure and add the aggregate roles (commit `ca07faa123f0`)\n- feat(collections): make the collection router read the certificate closure (commit `74bf13faaf25`)\n- feat(collections): split the read kind and carry collections in the Python SDK (commit `cae90e67bf42`)\n- fix(collections): carry the summary rename into whedge and the e2e-py suites (commit `dc266e612abb`)\n- fix(collections): stop head and history claiming member facts they cannot see (commit `9c6752da5e8d`)\n- fix(sdk-py): decode collections on paged rows, not just on detail reads (commit `f7bbfcab6364`)\n- feat(collections): put the member layout on the Shape and the facts on the row (commit `be4d60df9499`)\n- test(collections): pin the layout ruling and the four review findings it touches (commit `102b6e20d7a2`)\n- fix(collections): finish the summary reshape in the two packages CI typechecks separately (commit `338faa58a7ea`)\n- chore(sdk): regenerate the conformance artifacts over main's retired contracts (commit `43e4ee9ef5c8`)\n- feat(export)!: carry Shape certificates as pinned wref strings (commit `30187cb8082e`)\n- fix(collections)!: name a foreign certifying Shape by its repository (commit `18478e45b47f`)\n\nReconstructed from SDK release tags."
        }
      ]
    },
    {
      "version": "0.15.1",
      "sourceSha": "f990f080d6f8b7310700b1d430ee1aa11dcaaf23",
      "notes": [
        {
          "id": "published-changelog-artifacts",
          "body": "### Documentation\n\nVersioned release notes are available as [HTML, Markdown, and JSON](https://docs.warmhub.ai/sdk/python/changelog/)."
        }
      ]
    },
    {
      "version": "0.16.0",
      "sourceSha": "e3b82b4b08936c141a727fc4f072a5d9ee1eeb31",
      "notes": []
    },
    {
      "version": "0.16.1",
      "sourceSha": "756a13ed78b5ffc90a2640f8b9b0a1db9233f6a4",
      "notes": []
    },
    {
      "version": "0.17.0",
      "sourceSha": "eeb99f940dc369a637a9b63d86b3cb3fe9530fd7",
      "notes": []
    },
    {
      "version": "0.18.0",
      "sourceSha": "5a309dc538853e5544eaa7ae7d68f0045ac6911a",
      "notes": []
    },
    {
      "version": "0.19.0",
      "sourceSha": "dbb88c4ad3afe73aeec2f6f60bdf43a8f3311910",
      "notes": []
    },
    {
      "version": "0.19.1",
      "sourceSha": "421b081a24dff3f4a44162011791ebcd1ed9b917",
      "notes": []
    },
    {
      "version": "0.20.0",
      "sourceSha": "ecdeee60fe6a0af5454e69a90710f6a9416999b8",
      "notes": []
    },
    {
      "version": "0.21.0",
      "sourceSha": "6d99197d6e84edc1e7842634e0f5e4b810c80824",
      "notes": [
        {
          "id": "export-resume-format-guard",
          "body": "### Changed\n\nA 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.\n\nA 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.\n\n**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."
        },
        {
          "id": "export-token-fence-check",
          "body": "### Fixed\n\nA 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.\n\n### Added\n\n`fence_mismatch` is a new `RepositoryExportErrorReason` value. Code that matches every reason exhaustively should handle it."
        },
        {
          "id": "get-data-mode-auto-full",
          "body": "### Changed\n\n`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`."
        },
        {
          "id": "name-taken-durable-id-only",
          "body": "### Breaking\n\n#### `NAME_TAKEN` names the holder by durable id only and offers no replacement name\n\n**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>\"`.\n\n**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`.\n\n**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`.\n\n**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.\n\n```python\n# Before\nif isinstance(err.details, NameTaken):\n    print(err.details.taken_by.shape, err.suggested_name)\n\n# After\nif isinstance(err.details, NameTaken) and err.details.taken_by:\n    holder = client.thing.get(org, repo, err.details.taken_by.durable_id)\n```"
        },
        {
          "id": "restore-operation",
          "body": "### Added\n\n`Restore` and `OperationBuilder.restore()` make a retracted Thing active again, by its durable ID. Pass `new_name` to restore it under another name."
        },
        {
          "id": "v2-write-form",
          "body": "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`).\n\n### Breaking\n\n#### Write operations take a name and its Shapes\n\n**What it was.** `Add` and `Revise` took keyword-only `name=`. `Add` took\n`about`; both took `kind`, `type` and `members`; `Revise` also took\n`wref`. `Retract`, `Reaffirm`\nand `Rename` took `kind`. An add name was spelled `Shape/name`, and the SDK\nsplit it and converted the operation.\n\n**What it is now.** `name` is the full Thing name, taken literally, and can\nbe passed positionally. Every add and revise states its Shapes in exactly\none way: `shapes=[...]`, `shapeless=True`, or (revise only)\n`keep_shapes=True`. Passing a removed keyword to a dataclass raises\n`TypeError` naming the new spelling. Passing it to a builder method raises\nPython's standard \"unexpected keyword argument\" `TypeError`.\n\n**Migration.**\n\nBefore:\n\n```python\nfrom warmhub import Add, Revise, Rename\n\nrepo.apply(\"seed\", [\n    Add(name=\"Deal\", kind=\"shape\", data={\"fields\": {\"company\": \"string\"}}),\n    Add(name=\"Deal/acme\", data={\"company\": \"Acme\"}),\n    Add(name=\"Rating/r1\", kind=\"assertion\", about=\"Deal/acme\", data={\"score\": 5}),\n    Add(name=\"crew\", kind=\"collection\", type=\"set\", members=[\"Deal/acme\"]),\n])\nrepo.apply(\"update\", [\n    Revise(wref=\"Deal/acme\", data={\"company\": \"Acme 2\"}),\n    Rename(\"Deal/acme\", \"acme-co\", kind=\"thing\"),\n])\n```\n\nAfter:\n\n```python\nfrom warmhub import Add, Revise, Rename, shape_name\n\nrepo.apply(\"seed\", [\n    Add(shape_name(\"Deal\"), shapes=[], data={\"fields\": {\"company\": \"string\"}}),\n    Add(\"acme\", shapes=[\"Deal\"], data={\"company\": \"Acme\"}),\n    Add(\"r1\", shapes=[\"Rating\"], data={\"about\": \"acme\", \"score\": 5}),\n    Add(\"crew\", shapes=[\"Set\"], data={\"members\": [\"acme\"]}),\n])\nrepo.apply(\"update\", [\n    Revise(\"acme\", keep_shapes=True, data={\"company\": \"Acme 2\"}),\n    Rename(\"acme\", \"acme-co\"),\n])\n```\n\n- In this example, `Rating` is an existing Shape that composes `Assertion`.\n  An assertion is a Thing one of whose declared Shapes composes `Assertion`,\n  with its target in `data[\"about\"]`.\n- `shapes` takes bare Shape names. A pinned `Deal@v2` is refused: a write is\n  always checked against each Shape's current version.\n- A raw mapping states a shapeless write as `\"shapes\": []`. `shapeless=True`\n  is a dataclass and builder option, and is refused on a `hub/shapes/X`\n  name.\n- A `keep_shapes` revise of a Thing added or renamed earlier in the same\n  submission is refused; state `shapes` instead.\n\n#### Old `Shape/name` spellings are taken literally\n\n**What it was.** `\"Deal/acme\"` meant the Thing `acme` under Shape `Deal`.\n\n**What it is now.** It is a Thing name. This matters wherever a name is\naccepted:\n\n- `Add(name=\"Deal/acme\", data=...)` still has a valid `name=` keyword, so\n  it does not raise `TypeError`. It raises `WriteOperationError` (\"states no\n  Shapes\"). If you only add `shapes=` and keep the name,\n  `Add(name=\"Deal/acme\", shapes=[\"Deal\"], ...)` succeeds and creates a new\n  Thing literally named `Deal/acme`.\n- `Retract(\"Deal/acme\")`, `Reaffirm(\"Deal/acme\", ...)` and\n  `Rename(\"Deal/acme\", ...)` pass the local checks and are sent as written.\n  The server answers `NOT_FOUND`, naming the new form when it can, or acts on\n  a Thing literally named `Deal/acme` if one exists.\n- `Revise(\"Deal/acme\", keep_shapes=True)` fails locally with `NOT_FOUND`\n  (no such Thing), with no hint.\n- Read calls (`thing.get`, `get_many`, history, `thing.remove`, collection\n  calls, `CommitOptions.committer`, `client.repository(\"wh:...\")`) take the\n  new form. An old spelling is looked up literally: a single read gets\n  `NOT_FOUND`; `get_many` lists it in `missing` and may explain it in\n  `missing_hints`. Returned `wref` values use the new form too.\n\n**Migration.** Rename the argument as well as adding `shapes`:\n`\"Deal/acme\"` becomes `\"acme\"`, and a Shape where any Thing can appear\nbecomes `shape_name(\"Deal\")`.\n\n#### Builder `add` and `revise` take the name first\n\n**What it was.** `batch.add(name=..., kind=..., ...)` and\n`batch.revise(name=..., wref=..., ...)` were keyword-only. `rename` took\n`kind=`.\n\n**What it is now.** `add(name, *, shapes=|shapeless=, data=, ...)` and\n`revise(name, *, shapes=|shapeless=|keep_shapes=, data=, ...)`.\n`name=` as a keyword still works. `retract`, `reaffirm` and `rename` lose\n`kind=`. `add` and `revise` check the operation when it is queued and raise\n`WriteOperationError` there, instead of leaving it for `validate()`.\n`validate()` no longer reports `MISSING_NAME` or `MISSING_WREF`. A `shapes=`\nvalidation mapping validates data against every stated Shape.\n\n**Migration.**\n\nBefore:\n\n```python\nbatch = repo.batch(message=\"seed\")\nbatch.add(name=\"Reading/probe-1\", data={\"temp_celsius\": 21.4})\nbatch.retract(\"Reading/probe-0\", kind=\"thing\")\nbatch.commit()\n```\n\nAfter:\n\n```python\nbatch = repo.batch(message=\"seed\")\nbatch.add(\"probe-1\", shapes=[\"Reading\"], data={\"temp_celsius\": 21.4})\nbatch.retract(\"probe-0\")\nbatch.commit()\n```\n\n#### Old-grammar mappings are refused, not converted\n\n**What it was.** A mapping with `kind`, `Shape/name`, top-level `about` or\ncollection `type`/`members` was converted before sending.\n\n**What it is now.** A mapping with `kind`, `shape`, `wref`,\n`declaredShapes`, `about`, `type` or `members`, or without `operation`,\nraises `WriteOperationError` before any request. The message suggests a new\nform, with data values replaced by `\"...\"`. An add or revise mapping with no\n`shapes` (for example `{\"operation\": \"add\", \"name\": \"Deal/acme\", ...}`)\ngets the \"states no Shapes\" message instead. A retract, reaffirm or rename\nmapping is not checked for old spellings; see the previous item.\n\n**Migration.**\n\n```python\n# Before: converted and sent\nrepo.apply(\"m\", [{\"operation\": \"add\", \"kind\": \"thing\", \"name\": \"Deal/acme\", \"data\": {\"x\": 1}}])\n\n# After: WriteOperationError\n#   Invalid operation at index 0: this is the v1 write form ('kind').\n#   Write it in the v2 form: {\"operation\":\"add\",\"name\":\"acme\",\"shapes\":[\"Deal\"],\"data\":{\"x\":\"...\"}}\nrepo.apply(\"m\", [{\"operation\": \"add\", \"name\": \"acme\", \"shapes\": [\"Deal\"], \"data\": {\"x\": 1}}])\n```\n\nOther local refusals: non-empty `shapes` on a `hub/shapes/X` name; a\n`shapes` entry that is not a bare Shape name; `affirmed_targets` on a\nshapeless write; renaming across the Shape/Thing line; a name under the\nreserved `hub` root (`RESERVED_NAME`); `+` in a name; the same name added\ntwice in one submission (`ILLEGAL_OP_SEQUENCE`).\n\n#### Local write refusals raise `WriteOperationError`\n\n**What it was.** The builder raised `ValueError` (validation) and\n`RuntimeError` (already submitted, in flight). `repo.apply` raised\n`StreamValidationError` per operation. `commit.validate` raised a plain\n`WarmHubError`.\n\n**What it is now.** All raise `WriteOperationError`, a `WarmHubError` with\n`operation_index` (`None` for a whole-batch refusal).\n`StreamValidationError` is raised only for an empty operation list or an\ninvalid `submission_id`. `except WarmHubError` still catches everything;\n`except ValueError`, `except RuntimeError` and\n`except StreamValidationError` no longer catch operation refusals.\n`warmhub.operations.to_backend_stream_operation(operation, index=0)` now\nreturns the new wire form and raises `WriteOperationError`.\n\n**Migration.**\n\n```python\n# Before\ntry:\n    batch.commit()\nexcept ValueError as e:\n    ...\n\n# After\nfrom warmhub import WriteOperationError\n\ntry:\n    batch.commit()\nexcept WriteOperationError as e:\n    print(e.code, e.operation_index)\n```\n\n#### `thing.rename` takes full names\n\n**What it was.** `thing.rename(org, repo, shape_name, old_name, new_name, *,\nevent_request_id)`.\n\n**What it is now.** `thing.rename(org, repo, name, new_name, *,\nevent_request_id)`, both full names.\n\n**Migration.**\n\n```python\n# Before\nclient.thing.rename(\"acme\", \"world\", \"Deal\", \"acme\", \"acme-co\", event_request_id=req_id)\n\n# After\nclient.thing.rename(\"acme\", \"world\", \"acme\", \"acme-co\", event_request_id=req_id)\n```\n\n#### Read models drop the singular Shape fields\n\n**What it was.** `ThingItem.shape_name`; `ThingDetail.shape`,\n`.shape_name`, `.validated_shape`.\n\n**What it is now.** Removed. Use `declared_shapes` (the Shapes the version\nstates) and `validated_shapes` (the Shapes it satisfies, including composed\nones). Both hold version-pinned names such as `\"Deal@v3\"`. Generated wire\nclasses change the same way: `WireThingDetail`, `ThingGetManyResult` items,\n`ThingGetWithLease`, `ThingGraphResult` and `Assertion` lose all three;\n`WireThingItem`, component owned Things, history versions and\nview-evaluate items lose `shape_name`. Refs items and history's `thing`\nreplace `shape_name` with `declared_shapes` (history also gains\n`validated_shapes`). `SynthesizedRepoContent.shape` becomes\n`declared_shapes` / `validated_shapes`. `shape=` as a read filter is\nunchanged.\n\n**Migration.**\n\n```python\n# Before\ndetail.shape_name            # \"Deal\"\n# After\ndetail.declared_shapes       # (\"Deal@v3\",)\ndetail.validated_shapes      # (\"Deal@v3\",) plus any composed Shapes\n```\n\n#### Token scopes use `grants`\n\n**What it was.** A scope entry carried `allowed_matches` and\n`allowed_shapes`.\n\n**What it is now.** Restrictions are a list of (paths, Shapes) pairs in\n`grants`. The output models for `token.create`, `token.get`, `token.list`\nand whoami expose `grants`; each item has `allowed_matches` and\n`allowed_shapes` (`shapes`, `shapes_all`, `shapes_not`, `shapeless`), and\nthe entry-level fields are gone. On input, `TokenCreateInputScopesItem`\nstill has entry-level `allowed_matches` / `allowed_shapes` fields, but the\nserver refuses them. The generated `TokenCreateInputScopesItemAllowedShapes`,\n`TokenCreateResultScopesItemAllowedShapes` and\n`TokenMetadataScopesItemAllowedShapes` are replaced by their\n`...GrantsItemAllowedShapes` forms.\n\nA pair must carry a non-empty `allowed_matches`, or an `allowed_shapes`\nwith at least one key; an empty `grants`, an empty pair, and an empty\n`allowed_shapes` are refused. `shapeless=True` in a clause is not a\nrestriction of its own: it lets Things with no Shape through. Alone it\nadmits every Thing the pair's paths match, shaped or shapeless; beside\n`shapes` it adds shapeless Things to the Things the clause covers. No clause\nrestricts a pair to shapeless Things only.\n\n**Migration.**\n\n```python\n# Before\nfor s in info.scopes:\n    print(s.allowed_matches, s.allowed_shapes)\n\n# After\nfor s in info.scopes:\n    for g in s.grants or ():\n        print(g.allowed_matches, g.allowed_shapes)\n```\n\n#### Repository exports are format 6, and the delta fold needs format versions\n\n**What it was.** Exports were format 5, and\n`apply_repository_export_delta(base, delta)` took two arguments.\n\n**What it is now.** The server writes format 6 (new-grammar references, one\nrow per Shape version, `retracted: true` on retracted Shape rows). This SDK\nreads 5 and 6; older SDKs read only 5. `apply_repository_export_delta` takes\ntwo required keyword arguments, and folding across formats raises\n`RepositoryExportError(\"format_mismatch\")`. In format 6 a Shape row is keyed\nby `(durableId, version)`. `RepositoryExportHeader` has a required\n`format_version`.\n\n**Migration.**\n\n```python\n# Before\ncurrent = apply_repository_export_delta(base, list(changed.rows))\n\n# After\ncurrent = apply_repository_export_delta(\n    base,\n    list(changed.rows),\n    base_format_version=full.reader.header.format_version,\n    delta_format_version=changed.reader.header.format_version,\n)\n```\n\n#### Writes need a server running this release\n\n**What it was.** The SDK wrote to any current server.\n\n**What it is now.** Against an older server, writes raise `WarmHubError`\nwith code `CLIENT_INCOMPATIBLE` and the message \"warmhub-sdk-py requires write\ncontract 3, but the backend provides 2.\"\n\n**Migration.** Upgrade the SDK after the server.\n\n#### Generated error-details classes renumbered\n\n**What it was.** In `warmhub._generated.wire`, `CommitDiagnosticDetails8`\nmeant `reason=\"invalid_token\"`, and every other `*Details8` class did too.\n\n**What it is now.** `CommitDiagnosticDetails8` means\n`reason=\"legacy-wrefs-window-ended\"`, and `invalid_token` moved to\n`CommitDiagnosticDetails9`. Every `*Details8` class in\n`warmhub._generated.wire` shifts the same way. `WarmHubErrorDetails9`\n(`invalid_token`) and `WarmHubErrorDetails10` (`legacy-wrefs-sdk`) are new.\n\n**Migration.** Compare `reason` instead of the class.\n\n```python\nfrom warmhub._generated.wire import CommitDiagnosticDetails8\n\n# Before\nif isinstance(d, CommitDiagnosticDetails8):  # invalid_token\n    ...\n# After\nif getattr(d, \"reason\", None) == \"invalid_token\":\n    ...\n```\n\n#### Generated write-input unions reshaped\n\n**What it was.** `CommitValidateInputOperationsItem0` to `Item11`, with\n`kind`.\n\n**What it is now.** They are replaced by `Item00` (add) to `Item04`\n(rename) and `Item1` (remove). `StreamAppendInput.operations` uses new\noperation classes without `kind`. `commit_validate_payload` (in\n`warmhub.namespaces.commit_validate`) now takes resolved wire operations and\nno `skip_existing`; the new `plan_commit_validation(operations,\nskip_existing=...)` plans them.\n\n**Migration.** Use the top-level operation classes and client methods\nrather than the generated classes.\n\n### Added\n\n- **`shape_name(bare)`** — `\"Deal\"` to `\"hub/shapes/Deal\"`; an already\n  prefixed name is returned unchanged.\n  ```python\n  from warmhub import shape_name\n  shape_name(\"Deal\")  # \"hub/shapes/Deal\"\n  ```\n- **`WriteOperationError`** — a `WarmHubError` with `operation_index`; codes\n  `VALIDATION_ERROR`, `NOT_FOUND`, `RESERVED_NAME`, `ILLEGAL_OP_SEQUENCE`,\n  and Shape-name check codes. `status` is 404 for `NOT_FOUND`, else 400.\n- **`Revise(..., keep_shapes=True)`** — at submit the SDK reads the Thing's\n  current Shapes and version (one read per 500 Things) and sends both, the\n  version as `expectedVersion`; a supplied `expected_version` wins. A missing\n  Thing is refused with `NOT_FOUND`. A Thing added, or renamed to or from,\n  earlier in the same submission is refused. A Thing revised earlier in the\n  same submission reuses that revise's Shapes. `keep_shapes` pins the version\n  it read, so a resubmit of the same `submission_id` from another process\n  can conflict; explicit `shapes` is the retry-safe form.\n  ```python\n  repo.apply(\"m\", [Revise(\"acme\", keep_shapes=True, data={\"company\": \"Acme 2\"})])\n  ```\n- **`shapeless=True`** — on `Add`, `Revise` and the builder; sent as\n  `shapes: []`.\n- **`OperationWarnings.shapeless`** — `True` when a write left the Thing\n  with no Shape.\n  ```python\n  res = repo.apply(\"m\", [Add(\"scratch\", shapeless=True, data={})])\n  res.operations[0].warnings.shapeless  # True\n  ```\n- **`shapeless` read filter** — on `thing.head`, `head_iter`, `head_all`,\n  `thing.query`, `count`, `query_iter` and `query_all` (sync, async and\n  repository-bound). Not on `search`.\n  ```python\n  client.thing.head(\"acme\", \"crm\", shapeless=True)\n  ```\n- **`ThingGetManyResult.missing_hints`** — for a missing reference that is\n  an old `Shape/name` spelling of a Thing you can read, the text naming its\n  new form.\n- **`DEPENDENCY_FAILED`** — constant for the per-operation code of an\n  operation that failed only because an operation it read from failed.\n- **`LEGACY_WREFS_REFUSAL`** — the refusal message string.\n- **`RepositoryExportReader.after_version`** — resume position inside a\n  format 6 Shape; the client sends it on resume.\n- **`RepositoryExportErrorReason`** gains `\"format_mismatch\"`.\n- **`redacted_resource`** — may appear on `TokenCreateResult` scopes.\n\n### Behavior worth knowing\n\n- **`client_flags=[\"legacy-wrefs\"]` refuses every request.** The SDK does\n  not send old-grammar requests. With the flag set, every request (reads,\n  writes, the capabilities check, export download) raises `WarmHubError`\n  with code `CLIENT_INCOMPATIBLE`, a message equal to\n  `warmhub.LEGACY_WREFS_REFUSAL`, and `details.reason == \"legacy-wrefs-sdk\"`.\n  Use the `wh` CLI to submit old-grammar files during the compatibility\n  window.\n  ```python\n  client = WarmHubClient(..., client_flags=[\"legacy-wrefs\"])\n  client.thing.get(\"acme\", \"world\", \"acme\")\n  # WarmHubError CLIENT_INCOMPATIBLE:\n  #   `legacy-wrefs` is set; the SDKs are v2-only. Unset it, or use the `wh` CLI for v1.\n  ```\n\n### Changed\n\n- **`collection.revise`** — before, a selector-backed revise whose target\n  was spelled with another collection Shape (`List/...`, `Arc/...`) was\n  refused locally. Now the server decides.\n- **Local sequence checks** — `repo.apply` now checks only for the same name\n  added twice; other sequence problems are reported by the server.\n  `commit.validate` now also refuses adding the same name twice locally.\n- **`warmhub.rules.preflight_op_diagnostics` messages** teach the new form,\n  for example `Cannot add kind=\"thing\" under built-in shape \"Arc\". Name the\n  Thing and state the Shape instead: {...}`, and refuse a write declaring the\n  abstract `Collection` Shape. The SDK's own write paths refuse `kind`\n  operations before these checks run.\n- **Content-limit error labels** — `Field \"Readme.content\" is N bytes; ...`\n  instead of `Field \"Content/Readme.content\" is N bytes; ...`, and the same\n  for `Agents.content`.\n- **Pinned Shapes on `stream.append`** are refused locally.\n\n### Removed\n\n- Dataclass and builder keywords `kind`, `about`, `type`, `members`, `wref`.\n- `ThingItem.shape_name`; `ThingDetail.shape`, `.shape_name`,\n  `.validated_shape`; the matching generated wire fields.\n- Entry-level `allowed_matches` / `allowed_shapes` on token scope output\n  models.\n- Generated `CommitValidateInputOperationsItem0`–`Item11` and the three\n  `Token*ScopesItemAllowedShapes` classes named above.\n"
        },
        {
          "id": "validate-remove-and-keep-shapes-refusal",
          "body": "### Fixed\n\n`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.\n\nA 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.\n\n`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\"."
        }
      ]
    },
    {
      "version": "0.21.1",
      "sourceSha": "1864f32b1776781546cf69c1b0f1fee9e748b38a",
      "notes": []
    },
    {
      "version": "0.22.0",
      "sourceSha": "30c1c1e3267fb5a818890794c82b4602d46dc02a",
      "notes": []
    },
    {
      "version": "0.23.0",
      "sourceSha": "afdf1b126e6f578b3ef214e6c56e8732ab9a7599",
      "notes": []
    },
    {
      "version": "0.24.0",
      "sourceSha": "b554042d01acc75236d70634ca4bc9150b8f1e95",
      "notes": []
    },
    {
      "version": "0.24.1",
      "sourceSha": "98bb1d4fd1af7ec25148194c5ac21cfa95f80888",
      "notes": []
    },
    {
      "version": "0.25.0",
      "sourceSha": "5c92063917d8c26596c06e6632eac81e0fe1c5cc",
      "notes": []
    },
    {
      "version": "0.26.0",
      "sourceSha": "328254a84f050601eb5791f0f6f96e46f2a5cede",
      "notes": []
    }
  ]
}
