Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Changed

- **Standard version `8.0.1`, Protocol `2.0.1` and core error registry `8.0.1` (Breaking)**: the package follows the standard's `v8.0.1` release: `MTHDS_STANDARD_VERSION` moved from `8.0.0` to `8.0.1`, so a manifest's `mthds_version` of `>=8.0.1`, as the standard's own examples now read, passes while an exact `8.0.0` no longer does; `PROTOCOL_VERSION` moved from `2.0.0` to `2.0.1`, the cut that states the order of a files map's refusals, which `read_file_map` already followed; and `CORE_ERROR_REGISTRY_VERSION` moved from `8.0.0` to `8.0.1`, with `mthds.protocol.problem.registry` generated from that release's registry. The titles of the codes a run can raise, and the next steps they give a reader other than the method's author, are reworded for someone following a run of a method they did not write: `pipe.output_invalid`, for one, is now titled "A step's result does not have the structure the method declares" where it read "A pipe's output does not fit its concept". No code, domain, retry verdict, step kind or status changes.
- **Dev tooling: the development Python is pinned in `.python-version`**: the Makefile, uv and CI read the version from that file, `PYTHON_VERSION=<version> make install` still builds `.venv` on another supported version, and each job of the CI matrices now fails unless its environment ran the version the job names. Nothing shipped changes.

## [v0.25.0] - 2026-10-18
Expand Down
2 changes: 1 addition & 1 deletion docs/method-tree.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ The standard bounds a tree path's length and nothing else, so a file's size, a t

## A tree in transit: the file entry

A tree travels as a JSON object mapping each tree path to a file entry: a string holding the file's text, `{"base64": "…"}` in canonical padded Base64, or `{"sha256": "…", "size": …}` naming bytes the receiver already holds. `encode_file_map(contents, held_by_receiver=…)` writes one as a `dict[str, FileEntry]`, each entry a `str`, a `Base64FileEntry` or a `HashFileEntry`, which `pydantic_core.to_json` writes in its wire form; `MethodFiles`, a read-only map of the same entries, is the type of the `files` a protocol client sends (see [Sending a method](./runners.md#sending-a-method-files-inline-or-by-hash) for how `MthdsAPIClient` builds one from a listed tree, inline or by hash). `read_file_map(document, blob_store=…)` reads one from its JSON text as a receiver does, refusing a key given twice in the map or inside an entry object, a key that is not a tree path or that the membership rule could never admit, such as one whose last segment is `inputs.json` or `inputs.toml`, refused as `excluded_name`, colliding keys, a malformed entry, and every hash entry whose bytes the blob store lacks. It reads the text rather than a decoded dictionary because a key given twice and a `size` written as `13.0` are only visible there. Text that is not JSON, holds `NaN` or `Infinity`, an integer longer than Python converts, or nesting deeper than `MAX_JSON_DEPTH` levels is a `TreeSourceError`, never a bare `ValueError` or `RecursionError`. That bound is the receiver's own, set below the depth at which any supported Python's decoder gives up, since that depth varies from one version to the next and Python 3.14's decoder has none, so a document gets the same answer on every supported Python. `Base64FileEntry` and `HashFileEntry` are the strict pydantic models of the object forms.
A tree travels as a JSON object mapping each tree path to a file entry: a string holding the file's text, `{"base64": "…"}` in canonical padded Base64, or `{"sha256": "…", "size": …}` naming bytes the receiver already holds. `encode_file_map(contents, held_by_receiver=…)` writes one as a `dict[str, FileEntry]`, each entry a `str`, a `Base64FileEntry` or a `HashFileEntry`, which `pydantic_core.to_json` writes in its wire form; `MethodFiles`, a read-only map of the same entries, is the type of the `files` a protocol client sends (see [Sending a method](./runners.md#sending-a-method-files-inline-or-by-hash) for how `MthdsAPIClient` builds one from a listed tree, inline or by hash). `read_file_map(document, blob_store=…)` reads one from its JSON text as a receiver does, refusing a key given twice in the map or inside an entry object, a key that is not a tree path or that the membership rule could never admit, such as one whose last segment is `inputs.json` or `inputs.toml`, refused as `excluded_name`, colliding keys, a malformed entry, and every hash entry whose bytes the blob store lacks. The checks run in the order the standard states: a key refused by its name, as no tree path or as one the membership rule could never admit, is refused for that alone, since the collision check and the entry checks read only the keys the map does not refuse so, and the bytes of the hash entries are checked last, so a map is refused as `missing_blob` only when it breaks no other rule, and a sender never uploads blobs for a map refused anyway. It reads the text rather than a decoded dictionary because a key given twice and a `size` written as `13.0` are only visible there. Text that is not JSON, holds `NaN` or `Infinity`, an integer longer than Python converts, or nesting deeper than `MAX_JSON_DEPTH` levels is a `TreeSourceError`, never a bare `ValueError` or `RecursionError`. That bound is the receiver's own, set below the depth at which any supported Python's decoder gives up, since that depth varies from one version to the next and Python 3.14's decoder has none, so a document gets the same answer on every supported Python. `Base64FileEntry` and `HashFileEntry` are the strict pydantic models of the object forms.

## A tree written out

Expand Down
4 changes: 2 additions & 2 deletions docs/runners.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ The two optional blob routes are methods of their own, for a sender that manages
- `missing_blobs(blobs, digest=None)` posts a `MissingBlobsRequest` to `/v1/blobs/missing` and answers with a `MissingBlobsResult`: every blob the runner lacks, each with an `UploadTarget` (`method`, `url`, `headers`). A runner that serves no blob routes answers non-2xx, raised as `ApiResponseError`. The route lists only blobs of the request, so an answer naming another, by its hash or its size, is not the route's answer: it raises `ApiResponseError` carrying `client.response_unreadable`, and nothing is uploaded.
- `upload_blob(blob, data)` sends the bytes to that target. The target is the runner's choice and may be another server, such as an object store behind a presigned URL, so the request carries exactly the target's headers and nothing of this client's: neither the API key nor the `User-Agent` goes with it. A `412` from a create-only target, one whose headers carry `If-None-Match`, means the bytes are already stored and counts as uploaded. Bytes that are not the named blob, by hash or by size, are refused before anything is sent. Any other non-2xx raises `BlobUploadError` (`mthds.runners.api.exceptions`), a `PipelineRequestError` carrying the problem the target's answer reads as (see [Errors](#errors-every-failure-is-a-problem) below) beside `sha256`, `status`, `status_text`, `response_body` and `upload_url`, the last without its userinfo, query or fragment, since a presigned query is itself the permission to write and a `user:password@` is a credential. For the same reason the message never quotes the target's body, as the TypeScript client never quotes it: an object store's refusal, such as S3's `SignatureDoesNotMatch`, echoes the key id, the signature and the presigned query it checked, so the body reaches neither the message nor the problem, and only `response_body` keeps it. An upload that gets no usable answer, because no connection opened, none came back in time, the connection was lost or the body cannot be decoded, raises `BlobUploadError` too, its `status`, `status_text` and `response_body` `None`, as the TypeScript client's carries no status then. It reads as a request that creates nothing does, `client.unreachable` naming the target as `upload_url` shows it, or `client.timeout`, since a blob is named by its bytes and storing it twice is harmless, so its upload is safe to repeat. A malformed target raises it carrying `client.response_unreadable`, naming the `200` of the `missing_blobs` answer that named it, since no repeat changes it: one that is no URL the client can send to, such as one whose port is no number or whose scheme is neither `http` nor `https`, and one with a header the client cannot write, a value not in ASCII or one holding a line break, which h11 refuses before writing a byte, rather than reading as a retryable `client.unreachable`. Its headers are never quoted, since they may carry credentials, and the refusal of a header is not chained as the error's cause, since h11's words quote the header's value.

A run that names a hash the runner does not hold is answered with a `422` carrying `request.blobs_missing`, raised as `ApiResponseError` with each hash the runner lacks on `exc.problem.params.missing_blobs`.
A run whose files map breaks no other rule of the method tree but names a hash the runner does not hold is answered with a `422` carrying `request.blobs_missing`, raised as `ApiResponseError` with each hash the runner lacks on `exc.problem.params.missing_blobs`.

### Errors: every failure is a problem

Expand Down Expand Up @@ -127,7 +127,7 @@ On a route that creates a run, a `5xx` that is no problem document, such as a ga

- **As received.** Every member is kept as the runtime sent it, and a member the protocol does not declare, such as a runner's `request_id`, rides `problem.model_extra`; `problem.as_wire()` gives the members back in JSON types. `status` is filled from the answer when the body leaves it out and the answer's status is one HTTP defines, `100` to `599`, and `retry_after` from the `Retry-After` header when the body gives none and its retry verdict is `yes`. The header is read as seconds in ASCII digits, up to `2**53 - 1`, or as an HTTP date in exactly the three forms RFC 9110 (section 5.6.7) has a recipient accept, the IMF-fixdate `Sun, 06 Nov 1994 08:49:37 GMT`, the obsolete RFC 850 form `Sunday, 06-Nov-94 08:49:37 GMT` and the asctime form `Sun Nov 6 08:49:37 1994`; a header the client cannot read, such as one written with other digits, in another date form, naming no real moment or naming a leap second that carries it past the end of the year 9999, gives no delay.
- **Typed where the protocol types it.** `code`, `domain`, `retry`, `next_step`, `params`, `location`, `errors` (a `ProblemItem` per fault, each with its `suggested_fix`) and `pipeline_run_id` are typed. A body whose declared member has the wrong type, such as a `status` written as a string, a `null` member or a code outside the code pattern, is no problem document, and reads as `client.response_unreadable`. JSON is decoded as every reader of the protocol decodes it: the constants `NaN` and `Infinity`, a number past the range of a double such as `1e999`, which would read as an infinity, and a body nesting arrays and objects deeper than `MAX_BODY_NESTING` (512 levels, the body's own object or array included, the limit the TypeScript client pins too, checked before the body is parsed) are not JSON, and an integral number reads as an integer. `problem.as_wire()` writes back every problem read within that limit, where pydantic's own `model_dump` refuses a member nested a few hundred levels deep.
- **Open for what it does not know.** A domain, a retry verdict, a step kind or a fix's safety the protocol does not list stays the plain string received. `effective_domain` and `effective_step_kind` read such a value by the code's family, as the generated registry declares it: the domain every core code of the family shares, and the step kind every one of them gives to the actor and to any other reader alike, since a reader that does not know a step kind cannot tell whose step it is. A family whose codes differ says nothing a reader can rely on and reads as a family it knows nothing of, `runtime` and `report`; in the core registry `8.0.0` no family shares a step kind, so an unknown step kind always reads as `report`, while `auth`, `account` and `input` share a domain. A retry verdict is never read by the family: `effective_retry` reads one it does not know as `no`, as the protocol requires, so that nothing is repeated on a guess. An item carries no verdict, so `ProblemItem.effective_domain` reads its domain from its code. A fix operation of a kind the protocol does not list reads as `UnknownFixOp`, and `SuggestedFix.is_applicable` is then false.
- **Open for what it does not know.** A domain, a retry verdict, a step kind or a fix's safety the protocol does not list stays the plain string received. `effective_domain` and `effective_step_kind` read such a value by the code's family, as the generated registry declares it: the domain every core code of the family shares, and the step kind every one of them gives to the actor and to any other reader alike, since a reader that does not know a step kind cannot tell whose step it is. A family whose codes differ says nothing a reader can rely on and reads as a family it knows nothing of, `runtime` and `report`; in the core registry `8.0.1` no family shares a step kind, so an unknown step kind always reads as `report`, while `auth`, `account` and `input` share a domain. A retry verdict is never read by the family: `effective_retry` reads one it does not know as `no`, as the protocol requires, so that nothing is repeated on a guess. An item carries no verdict, so `ProblemItem.effective_domain` reads its domain from its code. A fix operation of a kind the protocol does not list reads as `UnknownFixOp`, and `SuggestedFix.is_applicable` is then false.
- **Never repeated on a guess.** This client never repeats a request by itself. `problem.is_retryable` is true only for a retry verdict of `yes`, so a caller that retries does it only then, after `retry_after` seconds when given; `problem.outcome_is_unknown` is true for `outcome_unknown`, where the run may already exist and the caller reads it rather than repeating the call.

The registry's generated module (`mthds.protocol.problem.registry`) gives a constant per core code (`CoreCode`), a typed parameters model per code (`problem.params_as(ModelUnknownParams).model`), and each code's entry (`CORE_CODE_SPECS`). A client states its own problems with `make_client_problem` (`mthds.protocol.problem.client_problems`), which fills the code's sentences from its parameters and picks the step aimed at the client's caller: the actor's for the domains `input` and `runtime`, the other reader's for `method`, `account` and `operator`.
Expand Down
4 changes: 2 additions & 2 deletions docs/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The standard's numbers are **copies of a cut made by the standard**, not decisio

Two properties of the rule are worth knowing before you read a version out of this package:

- **The standard version and the protocol version are independent.** The protocol version never tracks the standard version. A release of the standard that leaves the routes alone leaves `PROTOCOL_VERSION` exactly where it is, which is why `2.0.0` and `8.0.0` are as far apart as they are.
- **The standard version and the protocol version are independent.** The protocol version never tracks the standard version. A release of the standard that leaves the routes alone leaves `PROTOCOL_VERSION` exactly where it is, which is why `2.0.1` and `8.0.1` are as far apart as they are.
- **The standard version moves on every release of the specification**, including one that changes nothing normative. So the number rising is not by itself evidence that anything you depend on changed; the standard's changelog is. The corollary is that the pinned native set is identified by the standard version in which it *last changed*, and an implementation of version `V` materializes the set pinned at the greatest version less than or equal to `V`.

## `mthds_version` in a manifest is a constraint
Expand Down Expand Up @@ -43,7 +43,7 @@ The same number appears in a library crate as a **stamp** rather than a constrai
The core error registry is published with each release of the standard, under that release's number, and a new code moves no protocol number: that is why the registry has a version of its own here, and why it can stand ahead of `MTHDS_STANDARD_VERSION`, which follows the language rather than the codes. The registry's JSON snapshot and its schema are vendored byte for byte from the `mthds` repository, with a sidecar (`vendor/mthds-error-registry/source.json`) recording the ref, the commit and each file's SHA-256, and the module `mthds/protocol/problem/registry.py` is generated from them. Following a registry release takes three steps:

```bash
make vendor-error-registry MTHDS=<path to an mthds checkout> REF=<tag, such as v8.0.0>
make vendor-error-registry MTHDS=<path to an mthds checkout> REF=<tag, such as v8.0.1>
make generate-error-registry
# then set CORE_ERROR_REGISTRY_VERSION in mthds/protocol/protocol.py to the registry's version
```
Expand Down
2 changes: 1 addition & 1 deletion mthds/package/manifest/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@

RESERVED_DOMAINS: frozenset[str] = frozenset({"native", "mthds", "pipelex"})

MTHDS_STANDARD_VERSION: Final[str] = "8.0.0"
MTHDS_STANDARD_VERSION: Final[str] = "8.0.1"
"""The MTHDS standard version this library implements.

This is a copy of a cut made by the standard, and the only place this library
Expand Down
4 changes: 3 additions & 1 deletion mthds/protocol/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,9 @@ class VersionInfo(BaseModel):
default=False,
description=(
"True when the runner keeps a blob store: it serves POST /blobs/missing and resolves hash entries in a files map. "
"False or absent when it does not, in which case it answers any hash entry with a 422 listing missing_blobs."
"False or absent when it does not, in which case it answers a files map that breaks no other rule but holds hash entries "
"with a 422 coded request.blobs_missing, listing their hashes in params.missing_blobs, and serves no upload route, "
"so a sender sends those files inline instead."
),
)

Expand Down
Loading
Loading