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
2 changes: 1 addition & 1 deletion .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,5 @@ Nothing in CI compares the new version against the one already on `main`: `versi
- **No pre-release form reaches `main`.** `publish.yml` does detect a PEP 440 `a`, `b` or `rc` suffix and marks the GitHub Release a pre-release, which reads like permission — but nothing can carry such a version through: `guard-branches.yml` refuses a `release/v0.6.0rc1` head into `main` outright, and `version-check.yml` fails on the mismatch between a suffixed `pyproject.toml` version and the plain number in a `release/vX.Y.Z` branch name. Ship a plain `X.Y.Z`. The `pre-release/v*` base branch that `guard-branches.yml`'s `gate-release` job tolerates publishes nothing either, since `publish.yml` fires only on the push to `main`.
- **The changelog heading carries the `v`** — `## [vX.Y.Z] - YYYY-MM-DD`, which is exactly what `changelog-check.yml` greps for and what `publish.yml` slices the Release notes out of. No `[Unreleased]` heading is left behind; the next change re-creates one.
- **The fixture corpus is half of a pair.** A capture lands in `mthds-python` and `mthds-js` or in neither, so a release is also a statement that the sibling's `dev` carries the same bytes. That is what gate 3 and `protocol-corpus-parity.yml` are for.
- **A release arms no follow-ups on its own.** The ledger's `ledger.toml` declares no `release_followups` for this repo, so anything that must happen after this version ships is filed by hand alongside the release item in the play's step 3.
- **A release arms no follow-ups on its own.** A release files nothing in the ledger, so anything that must happen after this version ships is filed by hand in the play's step 3 with `ledger new --owner <repo> … --after-release [email protected]`, or put on an item already open with `ledger link <id> --after-release [email protected]`, and it becomes ready by itself once the `vX.Y.Z` tag is in this repo's checkout.
- **The back-merge is a merge commit.** `dev` carries a `Merge main into dev after the vX.Y.Z release` commit after each release rather than a fast-forward; `/ledger-land` makes it, and `CHANGELOG.md` is the one conflict it expects.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## [v0.19.0] - 2026-10-04

### Changed

- **Standard version `3.0.0` (Breaking)**: `MTHDS_STANDARD_VERSION` moved from `2.0.0` to `3.0.0`, since this package now obeys the standard's `2.1.0` rule that a stuff's `concept` on the wire is the concept's crate key, and the inputs-template projection takes the `3.0.0` verdict natives through the descriptor's own kinds: a compact template keeps the `{concept, content}` envelope of a `Choice` or `Rating` slot, and leaves a `YesNo` slot a bare boolean. A manifest's `mthds_version` is evaluated against `3.0.0`, so `>=2.0.0` and `^3.0.0` pass while `^2.0.0`, `~2.0.0` or an exact `2.0.0` no longer does, and `pipelex` stamps the crates it normalizes with `3.0.0`. `PROTOCOL_VERSION` is unchanged.

### Removed

- **`DictConcept`, and the concept object as a stuff's `concept` (Breaking)**: `DictStuffAbstract.concept` is a `str` again, the concept's ref string, which is what the standard puts on the wire and what the hosted runner now emits. A stuff carrying the concept object in its place, or no concept at all, now fails validation at its `concept`, in a `/v1/execute` body and in the working memory `PipelexRunner.execute` reads from the CLI alike, where the local runner used to reduce the object to a ref and turn a missing concept into the ref `"None"`. Code that read `stuff.concept.code` or `stuff.concept.domain_code` reads the ref string from `stuff.concept`, or from `stuff.concept_ref`, which returns the same string.

## [v0.18.0] - 2026-10-03

### Added
Expand Down
6 changes: 3 additions & 3 deletions docs/runners.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The protocol contract and its implementations live in separate packages:

- `mthds/protocol/` — the MTHDS Protocol itself: `protocol.py` (the `MTHDSProtocol` interface), `models.py` (the run/discovery wire models — `RunResultExecute`, `RunResultStart`, `ModelDeck`, `ValidationReport`, `VersionInfo`), `pipe_io_contracts.py` and `input_form.py` (the two validate artifacts the standard owns — see [The validate artifacts](#the-validate-artifacts-pipe_io_contracts-and-input_form) below), `method_files.py` (the catalog serialization of a stored method's source — see [The catalog serialization](#the-catalog-serialization-method_files) below), `options.py` (the run-source argument surface — see [Run sources: what may be combined](#run-sources-what-may-be-combined) below), `exceptions.py` (`PipelineRequestError`), and the protocol's domain shapes — `concept.py`, `stuff.py`, `working_memory.py`, `pipe_output.py`, `pipeline_inputs.py` (the abstract, non-Dict base models the protocol is defined in terms of).
- `mthds/runners/` — every runner implementation, one subpackage per runner:
- `api/` — the API runner: `client.py` (`MthdsAPIClient`, one file with its helpers), `models.py` (the Dict-serialized wire models — `DictConcept`, `DictStuffAbstract`, `DictWorkingMemoryAbstract`, `DictPipeOutputAbstract`, `DictRunResultExecute` — the runners' concrete JSON materialization of the protocol's domain shapes), `exceptions.py` (`ApiResponseError` for a non-2xx answer, API auth, and the protocol's 202-degrade error, `RunStillRunningError`), `problem.py` (`ProblemDocument` and `UserAction`, the lenient read of a refused request's problem members — see [Errors: `ApiResponseError`](#errors-apiresponseerror) below), `user_agent.py` (the `User-Agent` builder and `AppInfo` — see [Client identification](#client-identification-user-agent) below).
- `api/` — the API runner: `client.py` (`MthdsAPIClient`, one file with its helpers), `models.py` (the Dict-serialized wire models — `DictStuffAbstract`, `DictWorkingMemoryAbstract`, `DictPipeOutputAbstract`, `DictRunResultExecute` — the runners' concrete JSON materialization of the protocol's domain shapes), `exceptions.py` (`ApiResponseError` for a non-2xx answer, API auth, and the protocol's 202-degrade error, `RunStillRunningError`), `problem.py` (`ProblemDocument` and `UserAction`, the lenient read of a refused request's problem members — see [Errors: `ApiResponseError`](#errors-apiresponseerror) below), `user_agent.py` (the `User-Agent` builder and `AppInfo` — see [Client identification](#client-identification-user-agent) below).
- `pipelex/runner.py` — `PipelexRunner`, the local runner that shells out to the `pipelex` CLI.
- `types.py` — `RunnerType`.

Expand Down Expand Up @@ -64,9 +64,9 @@ async with MthdsAPIClient() as client:

`models` answers with a `ModelDeck`, whose `models[]` entries carry a `name` and a `type`, the entry's category; a runner's own routing metadata (aliases, fallback chains) rides `model_extra`. The protocol's categories are the language's settings families — `llm`, `extract`, `img_gen`, `search` and `judgment` — and `ModelCategory` carries them. The deck is read openly, as the protocol's reader rule requires ("A client reading a model list MUST NOT fail it because an entry carries a category it does not recognize; it keeps that entry with its raw value or leaves it out."): a category this package knows reads as its `ModelCategory` member, and one it does not, which a runner of a later protocol minor may report, keeps its raw string, so a consumer handles a `str` `type` instead of the whole call failing. The `category` filter stays closed: a runner answers a category it does not define with a `422`, raised as `ApiResponseError`.

The Dict wire models are extension-open at every level, matching the protocol's OpenAPI spec (which declares `pipe_output` as an open object). Base fields are typed; anything more a runner returns rides `model_extra`. The hosted `pipelex-api` runner dumps its full `PipeOutput` — per-stuff `stuff_code` / `stuff_name`, pipe-output `graph_spec` / `tokens_usages` / `working_memory_raw` and assembly errors — and all of it is preserved. A stuff's `concept` accepts both wire forms: the reduced namespaced ref string (what this SDK's own serialization emits) or the full concept object (`DictConcept`, what the hosted runner dumps); `DictStuffAbstract.concept_ref` normalizes either form to the ref string.
The Dict wire models are extension-open at every level, matching the protocol's OpenAPI spec (which declares `pipe_output` as an open object). Base fields are typed; anything more a runner returns rides `model_extra`. The hosted `pipelex-api` runner dumps its full `PipeOutput` — per-stuff `stuff_code` / `stuff_name`, pipe-output `graph_spec` / `tokens_usages` / `working_memory_raw` and assembly errors — and all of it is preserved. The openness stops at a stuff's `concept`, which is the concept's ref string and nothing else: `DictStuffAbstract.concept` is a `str`, and a body whose stuff carries the concept object in its place fails validation at that stuff's `concept`. The local `PipelexRunner` holds the working memory the CLI writes to the same rule, passing each stuff's `concept` through rather than reducing whatever it finds, so a stuff with no concept or with the concept object is refused there too. That is the standard's wire form, and the hosted runner emits it — `tests/fixtures/hosted_run/` holds a working memory the hosted plane really delivered, and the suite parses it. `DictStuffAbstract.concept_ref` returns the same string.

On the producing side the protocol's own domain shapes say the same thing, and say it once: `ConceptAbstract` carries the reference alone (`code` + `domain_code`, with `concept_ref` joining them), and `StuffAbstract` serializes its `concept` field through that property, so every dump of a stuff emits `{"concept": "<domain>.<Code>", "content": …}` without a runner having to remember to reduce it. That is the standard's rule — a concept's definition (its description, its structure, what it refines, any runtime class name) belongs to the library the method loads and never travels beside a stuff, per the "Stuffs on the Wire" section of the [CLI I/O contract](https://mthds.ai/latest/spec/cli-io-contract/). A runtime that needs more of the definition in memory declares it on its own `ConceptAbstract` subclass, where it stays out of the wire form. Two consequences are worth naming. The crate key for a concept a dependency contributes is `<package_address>::<domain>.<Code>`, and the protocol model holds no declaring package address, so producing that form is a subclass's business — it overrides `StuffAbstract.serialize_concept` under that name, since a second `field_serializer` for the field under any other name is a class-definition error and a serializer re-annotated onto the field is ignored silently. Which override point a runtime picks decides what this SDK's own output path emits, since `DictRunResultExecute.from_pipe_output` reduces a stuff by `concept_ref` rather than through the serializer: a `concept_ref` override reaches it, a `serialize_concept` override does not. And the dump is one-way: these shapes are strict, so a stuff, a working memory or a pipe output cannot be validated back from what it dumped, the definition such a parse would need being precisely what no longer travels. A consumer that re-validated a dump resolves the concept by ref through the library the method loads instead, as the input side already does; the `Dict` wire models above are the other way to read one back, since they accept either form.
On the producing side the protocol's own domain shapes say the same thing, and say it once: `ConceptAbstract` carries the reference alone (`code` + `domain_code`, with `concept_ref` joining them), and `StuffAbstract` serializes its `concept` field through that property, so every dump of a stuff emits `{"concept": "<domain>.<Code>", "content": …}` without a runner having to remember to reduce it. That is the standard's rule — a concept's definition (its description, its structure, what it refines, any runtime class name) belongs to the library the method loads and never travels beside a stuff, per the "Stuffs on the Wire" section of the [CLI I/O contract](https://mthds.ai/latest/spec/cli-io-contract/). A runtime that needs more of the definition in memory declares it on its own `ConceptAbstract` subclass, where it stays out of the wire form. Two consequences are worth naming. The crate key for a concept a dependency contributes is `<package_address>::<domain>.<Code>`, and the protocol model holds no declaring package address, so producing that form is a subclass's business — it overrides `StuffAbstract.serialize_concept` under that name, since a second `field_serializer` for the field under any other name is a class-definition error and a serializer re-annotated onto the field is ignored silently. Which override point a runtime picks decides what this SDK's own output path emits, since `DictRunResultExecute.from_pipe_output` reduces a stuff by `concept_ref` rather than through the serializer: a `concept_ref` override reaches it, a `serialize_concept` override does not. And the dump is one-way: these shapes are strict, so a stuff, a working memory or a pipe output cannot be validated back from what it dumped, the definition such a parse would need being precisely what no longer travels. A consumer that re-validated a dump resolves the concept by ref through the library the method loads instead, as the input side already does; the `Dict` wire models above are the other way to read one back, since they take the concept as its ref string and never ask for its definition.

### Errors: `ApiResponseError`

Expand Down
2 changes: 1 addition & 1 deletion docs/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The first two are **copies of a cut made by the standard**, not decisions this p

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

- **The two numbers 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 `0.7.0` and `2.0.0` are as far apart as they are.
- **The two numbers 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 `0.7.0` and `3.0.0` 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
2 changes: 1 addition & 1 deletion mthds/package/manifest/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@

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

MTHDS_STANDARD_VERSION: Final[str] = "2.0.0"
MTHDS_STANDARD_VERSION: Final[str] = "3.0.0"
"""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
43 changes: 12 additions & 31 deletions mthds/runners/api/models.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
"""Dict-serialized wire models — the concrete JSON materialization of the protocol's domain shapes.

These are the JSON forms the runners deal in: each `Stuff` carrying a `concept`
(the namespaced ref string, or the full concept object a runner may dump) and a
`content`, working memory as a flat root + aliases, the pipe-output as that
working memory + a run id, and `DictRunResultExecute` as the protocol's
`RunResult` carrying a `DictPipeOutput`. The abstract (non-dict) domain shapes
these mirror live in `mthds.protocol.*`.
— the concept's ref string, never its definition, which belongs to the library
the method loads — and a `content`, working memory as a flat root + aliases,
the pipe-output as that working memory + a run id, and `DictRunResultExecute`
as the protocol's `RunResult` carrying a `DictPipeOutput`. The abstract
(non-dict) domain shapes these mirror live in `mthds.protocol.*`.

Like every response model in `mthds.protocol.models`, these are
extension-open (`extra="allow"`): the protocol's OpenAPI spec declares
`pipe_output` as an open object, and a compliant runner may return richer
shapes than the base fields named here — the hosted `pipelex-api` runner dumps
its full `PipeOutput` (per-stuff `stuff_code` / `stuff_name`, pipe-output
`graph_spec` / `tokens_usages` / `working_memory_raw`, …). Anything beyond the
base fields rides `model_extra`, never named by this SDK. This SDK's own
serialization (`from_pipe_output`) emits the reduced form: `concept` as the
ref string, base fields only.
base fields rides `model_extra`, never named by this SDK. The openness stops
at `concept` itself: a stuff that carries the concept object instead of its
ref is refused rather than tolerated. This SDK's own serialization
(`from_pipe_output`) emits base fields only.

These shapes are brand-neutral (no `pipelex_` prefix) and are a shared wire
contract: the in-process `PipelexRunner` and the `MthdsAPIClient` both deal in
Expand Down Expand Up @@ -43,35 +44,15 @@
MAIN_STUFF_NAME = "main_stuff"


class DictConcept(BaseModel):
"""The full-object wire form of a stuff's `concept`.

A runner may dump the whole concept object instead of the reduced ref
string (the hosted `pipelex-api` runner does). Only the two fields needed
to reconstitute the namespaced ref are required; anything more
(`description`, `structure_class_name`, `refines`, …) rides `model_extra`.
"""

model_config = ConfigDict(extra="allow", strict=True)
code: str
domain_code: str

@property
def concept_ref(self) -> str:
return f"{self.domain_code}.{self.code}"


class DictStuffAbstract(BaseModel, ABC):
model_config = ConfigDict(extra="allow", strict=True)
concept: str | DictConcept
concept: str
content: Any

@property
def concept_ref(self) -> str:
"""The namespaced concept ref, whichever wire form `concept` arrived in."""
if isinstance(self.concept, str):
return self.concept
return self.concept.concept_ref
"""The concept's ref string, as the stuff carried it under `concept`."""
return self.concept


class DictWorkingMemoryAbstract(BaseModel, ABC):
Expand Down
Loading
Loading