@pipelex/sdk 0.52.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +17 -17
  2. package/dist/answer-reading.d.ts +70 -0
  3. package/dist/answer-reading.js +106 -0
  4. package/dist/answer-reading.js.map +1 -0
  5. package/dist/artifacts.js +45 -32
  6. package/dist/artifacts.js.map +1 -1
  7. package/dist/cli/present.d.ts +11 -14
  8. package/dist/cli/present.js +41 -37
  9. package/dist/cli/present.js.map +1 -1
  10. package/dist/cli/run.js +8 -7
  11. package/dist/cli/run.js.map +1 -1
  12. package/dist/cli/script.d.ts +1 -1
  13. package/dist/cli/source.js +2 -2
  14. package/dist/cli/source.js.map +1 -1
  15. package/dist/client.d.ts +248 -218
  16. package/dist/client.js +538 -477
  17. package/dist/client.js.map +1 -1
  18. package/dist/codegen-check.d.ts +21 -15
  19. package/dist/codegen-check.js +54 -27
  20. package/dist/codegen-check.js.map +1 -1
  21. package/dist/errors.d.ts +298 -455
  22. package/dist/errors.js +426 -534
  23. package/dist/errors.js.map +1 -1
  24. package/dist/exact-json.d.ts +8 -12
  25. package/dist/exact-json.js +19 -12
  26. package/dist/exact-json.js.map +1 -1
  27. package/dist/hooks/check-core.d.ts +12 -7
  28. package/dist/hooks/check-core.js +20 -14
  29. package/dist/hooks/check-core.js.map +1 -1
  30. package/dist/hooks/validate-stage.js +12 -3
  31. package/dist/hooks/validate-stage.js.map +1 -1
  32. package/dist/index.d.ts +4 -5
  33. package/dist/index.js +7 -6
  34. package/dist/index.js.map +1 -1
  35. package/dist/method-selector.js +7 -5
  36. package/dist/method-selector.js.map +1 -1
  37. package/dist/method-trees.d.ts +6 -7
  38. package/dist/method-trees.js +31 -37
  39. package/dist/method-trees.js.map +1 -1
  40. package/dist/models.d.ts +33 -210
  41. package/dist/models.js +2 -1
  42. package/dist/models.js.map +1 -1
  43. package/dist/prepare-inputs.d.ts +2 -2
  44. package/dist/prepare-inputs.js +51 -54
  45. package/dist/prepare-inputs.js.map +1 -1
  46. package/dist/problem-items.d.ts +19 -0
  47. package/dist/problem-items.js +52 -0
  48. package/dist/problem-items.js.map +1 -0
  49. package/dist/product-models.d.ts +23 -21
  50. package/dist/records.d.ts +12 -0
  51. package/dist/records.js +23 -0
  52. package/dist/records.js.map +1 -0
  53. package/dist/runs.d.ts +49 -38
  54. package/dist/runs.js +45 -19
  55. package/dist/runs.js.map +1 -1
  56. package/dist/transport-reading.d.ts +18 -0
  57. package/dist/{run-may-have-started.js → transport-reading.js} +15 -44
  58. package/dist/transport-reading.js.map +1 -0
  59. package/dist/upload-grant.d.ts +9 -6
  60. package/dist/upload-grant.js +42 -29
  61. package/dist/upload-grant.js.map +1 -1
  62. package/dist/upload.js +53 -33
  63. package/dist/upload.js.map +1 -1
  64. package/dist/version.d.ts +1 -1
  65. package/dist/version.js +1 -1
  66. package/docs/architecture.md +19 -19
  67. package/docs/artifact-download.md +6 -6
  68. package/docs/cli.md +1 -1
  69. package/docs/crate-routes.md +13 -14
  70. package/docs/errors.md +154 -267
  71. package/docs/exact-integers.md +1 -1
  72. package/docs/files.md +13 -13
  73. package/docs/hook-bundle.md +15 -1
  74. package/docs/input-preparation.md +14 -14
  75. package/docs/method-copy.md +24 -24
  76. package/docs/method-examples.md +13 -14
  77. package/docs/method-trees.md +27 -28
  78. package/docs/organization-branding.md +14 -16
  79. package/docs/run-results.md +8 -8
  80. package/package.json +1 -1
  81. package/dist/error-models.d.ts +0 -302
  82. package/dist/error-models.js +0 -203
  83. package/dist/error-models.js.map +0 -1
  84. package/dist/error-verdicts.d.ts +0 -46
  85. package/dist/error-verdicts.js +0 -120
  86. package/dist/error-verdicts.js.map +0 -1
  87. package/dist/run-may-have-started.d.ts +0 -31
  88. package/dist/run-may-have-started.js.map +0 -1
@@ -38,7 +38,7 @@ The package also declares one `bin`, `pipelex-sdk`, which is `dist/cli.js` with
38
38
 
39
39
  Condition order in `exports` matters: `types` is listed first (TS/Node resolution convention — the type-checker looks at the first matching condition), then `import`, then `default` as the CJS fallback.
40
40
 
41
- The map has a second entry, **`@pipelex/sdk/upload`** (`dist/upload-grant.js`), the one a page that stores a file imports. The client holds an API key and belongs on a server, so a page imports the main entry for its error classes, its types and its pure helpers, never to construct a client, and the subpath carries only what a page needs to store a file with an upload grant: `describeUpload`, which reads the file's name, type, size and SHA-256 for the grant request, `uploadWithGrant`, the grant types and the three error classes they raise. Its runtime import graph is `errors.ts` (with the fallback table it reads, `error-verdicts.ts`), `timers.ts` and `mthds/protocol`, nothing else. Both entries load the same `dist/errors.js` and `dist/upload-grant.js`, so the function and the error classes are identical whichever entry a consumer imports them through.
41
+ The map has a second entry, **`@pipelex/sdk/upload`** (`dist/upload-grant.js`), the one a page that stores a file imports. The client holds an API key and belongs on a server, so a page imports the main entry for its error classes, its types and its pure helpers, never to construct a client, and the subpath carries only what a page needs to store a file with an upload grant: `describeUpload`, which reads the file's name, type, size and SHA-256 for the grant request, `uploadWithGrant`, the grant types and the three error classes they raise. Its runtime import graph is `errors.ts` (with the readers it builds problems with, `answer-reading.ts` and `transport-reading.ts`), `timers.ts` and `mthds/protocol`, nothing else. Both entries load the same `dist/errors.js` and `dist/upload-grant.js`, so the function and the error classes are identical whichever entry a consumer imports them through.
42
42
 
43
43
  **Both entries bundle for the browser with nothing marked external.** A Next.js client component importing an error class from `@pipelex/sdk` puts the barrel's whole graph in the browser bundle, the modules it loads by dynamic import included, since a bundler bundles a dynamic import with a literal specifier like a static one; in 0.48.0 that graph reached `node:fs` through the `mthds` package's root entry, which Turbopack's `next build` tolerated and its `next dev` did not, panicking and answering `500` for the page. `tests/browser-bundle.test.ts` is the gate the templates' builds are not: it builds the package with `tsc` into a scratch directory laid out as the published package, bundles every entry of the `exports` map for the browser with esbuild, and fails on any Node built-in, `undici` or the `mthds` root entry it reaches, naming each import and the module that made it.
44
44
 
@@ -55,7 +55,7 @@ Node, a Next.js server bundle and the hook bundle resolve the host; a browser bu
55
55
  - **Inside Node, without the `node` condition**, as a bundle built for esbuild's `neutral` platform resolves the package, `#node-host` resolves to the absent variant, and the reason names the missing condition and how to bundle with it.
56
56
  - **Inside Node, where the import itself fails**, as in a toolchain that does not resolve the `imports` map of `package.json` (Jest before 29.4, whose error is `ERR_PACKAGE_IMPORT_NOT_DEFINED` or "Cannot find module '#node-host'") or in a package installed in part, the reason says the host could not be loaded, names that toolchain and quotes the import's own error, which the refusal carries as its `cause`. It never claims the `node` condition was missing.
57
57
 
58
- Each operation that needs Node refuses with its own class and verdict whichever the case, and the reason after its own subject: reading a path string is an `InvalidLocalSourceError`, `downloadArtifacts` an `ArtifactOperationError` (`config`), a `directory` a `RequestArgumentError` (`input`) and `writeMethodTree` a `RequestArgumentError` (`config`). A call given a `directory` asks for the host before its version handshake, so a failed or refused handshake never hides that refusal. An artifact fetch is never refused: without the host, or where `undici` cannot be loaded, it runs on the platform's default dispatcher. `tests/node-host.test.ts`, `tests/node-host-unresolved.test.ts` and `tests/node-host-import-fails.test.ts` hold each case, and `tests/browser-bundle.test.ts` runs the import failure under Node itself, against a built copy of the package whose `package.json` has no `imports` map.
58
+ Each operation that needs Node refuses with its own class and verdict whichever the case, and the reason after its own subject: reading a path string is an `InvalidLocalSourceError` (`input.file_unreadable`), and `downloadArtifacts` an `ArtifactOperationError`, a `directory` a `RequestArgumentError` and `writeMethodTree` a `RequestArgumentError`, each of those three carrying `runtime.unsupported` naming what the runtime cannot do. A call given a `directory` asks for the host before its version handshake, so a failed or refused handshake never hides that refusal. An artifact fetch is never refused: without the host, or where `undici` cannot be loaded, it runs on the platform's default dispatcher. `tests/node-host.test.ts`, `tests/node-host-unresolved.test.ts` and `tests/node-host-import-fails.test.ts` hold each case, and `tests/browser-bundle.test.ts` runs the import failure under Node itself, against a built copy of the package whose `package.json` has no `imports` map.
59
59
 
60
60
  The package's own conditions were chosen over marking each dynamic import for bundlers to leave alone (`/* webpackIgnore: true */`, `/* turbopackIgnore: true */`): only webpack and Turbopack read those marks, so esbuild, Vite and Rollup would still bundle Node into a page, and an import left alone is resolved at run time from wherever the bundle landed, which finds the package's own `mthds` or `undici` only when the package manager hoisted it beside the application, and a strict layout such as pnpm's does not. Conditions are read by every bundler and resolve from the package itself.
61
61
 
@@ -111,21 +111,21 @@ A run request carries its method as its **tree**, the MTHDS protocol's one metho
111
111
  Flat `src/` (mirrors `mthds-js`'s `runners/api` flatness — the SDK has one client, so no runner/registry abstraction):
112
112
 
113
113
  - `client.ts` — `PipelexApiClient`: the `request()` pipeline (auth, base URL, timeouts/abort, problem-details parsing) and every route. It implements the standard's `MTHDSProtocol<DictPipeOutput>` for every route but `validate`, so the protocol-execution methods stay shaped like the standard: `validate` answers Pipelex's own verdict, `PipelexValidationResult`, because the hosted plane still sends the verdict that predates the protocol's problem items, where the standard's `ValidationResult` lists them.
114
- - `models.ts` — Dict-serialized concretes (`DictStuff` / `DictWorkingMemory` / `DictPipeOutput` / `DictRunResultExecute`), the `/v1/validate` surface (`PipelexValidationResult`, whose two arms are declared whole rather than as narrowings of the standard's `ValidationResult`, `ValidationErrorItem`, and the `SuggestedFix` / `FixOp` repair vocabulary its items carry — the hosted plane's shapes, which predate the standard's problem items, their operation and safety vocabularies pinned to the standard's by a type-level test, see [errors.md](./errors.md#a-validation-item--validationerroritem)), the tools surface (`Diagnostic` / `LintResponse` / `FormatResponse`), and the crate-route request/response models — the shared `CrateRequestBase` / `CrateInvalidReport` vocabulary plus the `/v1/resolve`, `/v1/codegen` and `/v1/pipe-io` shapes built on it, `GeneratedArtifact` among them.
114
+ - `models.ts` — Dict-serialized concretes (`DictStuff` / `DictWorkingMemory` / `DictPipeOutput` / `DictRunResultExecute`), the `/v1/validate` surface (`PipelexValidationResult`, whose two arms are declared whole rather than as narrowings of the standard's `ValidationResult`, listing their faults and warnings as the standard's `ProblemItem`, with its `SuggestedFix` and `FixOp`, see [errors.md](./errors.md#a-verdicts-problem-items)), the tools surface (`Diagnostic` / `LintResponse` / `FormatResponse`), and the crate-route request/response models — the shared `CrateRequestBase` / `CrateInvalidReport` vocabulary plus the `/v1/resolve`, `/v1/codegen` and `/v1/pipe-io` shapes built on it, `GeneratedArtifact` among them.
115
115
  - `product-models.ts` — the snake_case wire shapes for the hosted-product management routes (profile, methods catalog, organizations, billing, API keys, onboarding, storage, an organization's files, runs list/detail/update). Each interface models only the fields the product actually consumes — no speculative mirrors.
116
- - `error-models.ts` — the error-report wire models: `RunErrorReport`, the runner's `ErrorReport` typed with every field it carries (with `UserAction`, `ProviderErrorMetadata` and `MigrationErrorBlock`), which is the one type for a failed run's report on `RunPublic.error`, `PipelineRun.error`, `RunHistoryItem.error`, the failed arm of `RunResultState` and `RunFailedError.error`; and `ProblemDetails` / `FieldError`, the typed members of a problem document that `ApiResponseError` exposes. Every field is optional and every interface keeps an index signature, because the runner owns the shape and adds fields without asking. It also holds the one function that checks a stored report, the internal `readRunErrorReport`, which every read handing a report back applies: each named field is kept only when it has its declared type, and members the SDK does not name are relayed as sent. It imports the `ValidationErrorItem` type and nothing else. See [errors.md](./errors.md#a-failed-run--runerrorreport).
116
+ - `answer-reading.ts` — `readHostedAnswer`, the reading of an answer that is not the route's result as its problem: a problem document as received, and an answer whose body is none, the hosted edge's, a gateway's or a proxy's, by its status, `client.outcome_unknown` for a start or an execute that may have created a run; and `outcomeUnknownProblem`. `ApiResponseError` reads every answer through it. The Python twin is `pipelex_sdk/answer_reading.py`. See [errors.md](./errors.md#an-answer-that-is-no-problem-document).
117
+ - `transport-reading.ts` — `requestMayHaveLeft`, the **pure** reading of whether a request that got no answer may have left this machine, every failure counting unless it proves nothing was sent (a failure of the connection's set-up). `ApiUnreachableError` applies it on a start or an execute, which the client marks with `mayStartRun`, to read the failure as `client.unreachable` or `client.outcome_unknown`. The Python twin is `pipelex_sdk/transport_reading.py`. See [errors.md](./errors.md#a-run-that-may-have-started--outcome_unknown).
118
+ - `problem-items.ts` — `readProblemItems`, the reading of a verdict's `errors` and `warnings` as the standard's problem items, member by member, so one malformed member never costs the caller its fault. The Python twin is `pipelex_sdk/problem_items.py`. See [errors.md](./errors.md#a-verdicts-problem-items).
117
119
  - `method-trees.ts` — a saved method's tree on the client side: `MethodTreeSource`, the shape a save reads a tree from, which the `mthds` package's `MethodTree` satisfies; the choice between sending a tree inline and by hash (`INLINE_TREE_MAX_BYTES`), the wire types of `POST /v1/blobs/missing` and the upload of a blob the platform lacks; `redactSignedLinks`, which every error built from an answer holding signed links reads its body through; the tree of an in-memory map; the tree limits a version handshake announces; the listing of a `directory`, by the `mthds` package's lister, which it gets from the Node host only when a directory is given; and the Node-only write of a saved listing to disk, with the file system it gets from the Node host when called. Outside Node, both are refused with `RequestArgumentError`. Its tree-sending half mirrors the `mthds` package's own helpers, which the `mthds/protocol` subpath does not export yet, so it holds the same rules with Web Crypto's SHA-256 and a Base64 writer of its own. See [method-trees.md](./method-trees.md).
118
- - `codegen-check.ts` — `runCodegenCheck`, the **pure** offline drift check over a committed codegen tree: the stamp grammar, the `codegen.lock` parser, the hash, and the drift taxonomy, ported from pipelex's `codegen/check.py` so a verdict computed here equals the one `pipelex codegen check` computes over the same bytes (the drift `detail` sentences included, verbatim). It touches neither the client nor `mthds/protocol` — its only runtime imports are `smol-toml` and the plain-object guard of the pure `error-models.ts` — and it hashes through WebCrypto rather than `node:crypto` so it adds no Node builtin to the barrel's import graph. It raises `CodegenLockError`, which extends `Error` rather than the protocol base and declares the error verdict itself (`input`, not retryable). No filesystem, no fetch, no API key — the caller walks its own tree and hands in the text. See [crate-routes.md](./crate-routes.md#the-offline-check--runcodegencheck).
120
+ - `codegen-check.ts` — `runCodegenCheck`, the **pure** offline drift check over a committed codegen tree: the stamp grammar, the `codegen.lock` parser, the hash, and the drift taxonomy, ported from pipelex's `codegen/check.py` so a verdict computed here equals the one `pipelex codegen check` computes over the same bytes (the drift `detail` sentences included, verbatim). It touches no client: its runtime imports are `smol-toml`, `mthds/protocol`'s `ProblemError` and `buildProblem` for the error it raises, the plain-object guard of `records.ts` and `version.ts`'s `SDK_VERSION`, and it hashes through WebCrypto rather than `node:crypto` so it adds no Node builtin to the barrel's import graph. It raises `CodegenLockError`, a `ProblemError` outside the request family, whose problem is `client.file_unreadable` naming `codegen.lock`, `client.tool_outdated` or `client.argument_invalid` ([errors.md](./errors.md#each-classs-problem)). No filesystem, no fetch, no API key — the caller walks its own tree and hands in the text. See [crate-routes.md](./crate-routes.md#the-offline-check--runcodegencheck).
119
121
  - `upload-grant.ts` — the halves of an upload grant (`requestUploadGrant`, `POST /v1/upload/grant`) that run where the bytes are. `describeUpload` reads a file's name, type and size and computes its SHA-256 with Web Crypto, leaving the hash out, and the file unread, past a size bound of 50 MiB by default, and leaving it out where the runtime has no digest. `uploadWithGrant` sends nothing for a grant that answers `stored: true`, and otherwise sends the file as the raw body of a `PUT` to the grant's URL with its signed headers unchanged, redirects refused, a `412` read as stored for a grant that names its bytes, a `409 ConditionalRequestConflict` sent again after a short pause a few times, the whole exchange bounded by a time limit scaled by the file's size unless the caller passes `timeoutMs`, only the first 16 KiB of storage's error body read, and storage's refusals mapped onto the input-preparation errors (`RejectedAssetError` with a `code` for a `4xx` such as the `412` of a used hashless grant, the `403` of a signature mismatch or the `400 BadDigest` of bytes other than the hashed ones, `UploadTransportError` with a `code` for the time limit running out, a `5xx`, storage's `RequestTimeout`, a `409` conflict that outlasted every attempt, a redirect or no response). It is the browser-safe `@pipelex/sdk/upload` entry, so it imports the error classes, the timer bound in `timers.ts` and the wire types only, never the client. See [input-preparation.md](./input-preparation.md#uploading-with-a-grant--requestuploadgrant--uploadwithgrant).
120
122
  - `artifacts.ts` — the artifact stack, the download twin of `prepareInputs`, in layers: `locateArtifacts` (a pure, exported walk for `pipelex-storage://` references, each with every `$`-rooted path it sits at) and `collectArtifacts` (the same walk's bare references), `resolveArtifacts` (the bulk resolve route over a whole list, chunked at its bound), `fetchArtifact` (one reference as a bounded `Response`) and `downloadArtifacts` (Node-only, a run's produced files saved under a directory as a produced verdict), plus `artifactFilename`, the rule that names a saved file after the field it fills. The three network operations are client methods over a small structural seam (`ArtifactCapableClient`: the raw `resolveStorageUrls` call and `getRunResult`); the module's graph, static and dynamic, stays free of Node builtins, because `node:fs/promises`, `node:path` and `undici` come from the Node host. `undici` is a runtime dependency for one reason: the fetch runs on an `Agent` carrying `headersTimeout` and `bodyTimeout`, the per-stall bounds an `AbortSignal` budget does not give, and Node exposes its bundled undici's `fetch` but not its `Agent`. See [artifact-download.md](./artifact-download.md).
121
123
  - `runs.ts` — the run-lifecycle types (`RunStatus`, `RunRead`, `RunResults`, `RunResultState`) and the single poll loop (`pollUntilResult`). `RunResults.main_stuff` (the resolved main output content) is always present for a completed run: on the hosted path it is the `main_stuff.json` S3 artifact; on the bare-runner blocking path the SDK resolves it from the returned working memory via the response's `main_stuff_name`, so both paths deliver the same shape. Consumers read `main_stuff` directly. `working_memory` — every named stuff of the run, `{ root, aliases }`, typed `DictWorkingMemory` from `models.ts` — reads the same on both paths: the hosted path relays the `working_memory.json` artifact, and the blocking path lifts it off `pipe_output`, which still rides whole (blocking path only). `graph_spec` — the executed graph — also reaches the client on both paths: the hosted path relays the `graphspec.json` artifact, and the blocking path lifts it off `pipe_output` beside `graph_assembly_error`, the graph's twin of `usage_assembly_error`. That error field is declared ahead of the wire on the hosted side, where the platform's results body relays no such key and the field is therefore absent, reading `undefined` rather than `null`. A completed run that cannot deliver a main stuff throws `MissingMainStuffError` when the read asked for it. `runs.ts` also declares `RUN_RESULT_ARTIFACTS` / `RunResultArtifact`, the artifacts a results read can be narrowed to with `getRunResult`'s `artifacts` option; with a selection an unselected artifact is absent while a selected unwritten one is `null`. `runs.ts` also owns `TokensUsageRecord` — one client-facing usage record per inference call, carried by `RunResults.tokens_usages` on both paths. That is a mirror of a wire contract this SDK does not own (inference accounting is a Pipelex runtime extension rather than part of the MTHDS standard): every field is optional and the interface carries an index signature so pre-contract artifacts (relayed verbatim, never migrated) still type-check. See [`run-usage.md`](./run-usage.md) for the field reference, the cost/null semantics, and the old-artifact rules, and [`run-results.md`](./run-results.md) for the field-by-field account of `RunResults` — the durable handle, the output and how to read it, the run's working memory, the executed graph and how to render or keep it, and the produced files with their short-lived links.
122
124
  - `usage.ts` — `summarizeUsage`, the **pure** fold of a run's usage pair (`tokens_usages` / `usage_assembly_error`) into one `UsageSummary`: a `state` (`records`, `no_inference` or `unavailable`), the null-aware total cost with its `cost_partial` flag, the `input` and `output` token totals, the call count, the relayed assembly error, and a `by_pipe` rollup sorted by cost with the unattributed calls grouped under a `null` `pipe_code`. It applies the rules [`run-usage.md`](./run-usage.md#summarizing-a-run--summarizeusage) states, so that every consumer reads one computation rather than re-deriving those rules by hand. It imports types from `runs.ts` and nothing else: no client, no fetch, no `mthds/protocol`.
123
125
  - `execute-result.ts` — `PipelexExecuteResult`, a `DictRunResultExecute` subtype that adds a resolved `.main_stuff` accessor (dug out of `pipe_output`'s working memory via the response's `main_stuff_name`, throwing `MissingMainStuffError` if unlocatable). `execute()` returns it, giving blocking and durable results the **same output accessor** — `result.main_stuff` — so callers never branch on which path ran. The neutral `mthds` wire model leaves `main_stuff_name` in its extension index signature; this Pipelex-branded subtype declares it as a typed field (Pipelex owns that concept). `resultsFromExecute` (the public lift from that result onto `RunResults`, in the same module) reads that accessor too, keeping the resolution single-sourced; the `executeBlocking` fallback is its only caller inside the SDK, and a consumer driving `execute()` directly calls it by name.
124
- - `errors.ts` — the error verdict and the typed errors. The verdict is `ErrorDomain` (`input`, `config`, `runtime`: who can fix the failure) and `ErrorVerdict` (that domain and `retryable`), carried as two readonly own properties by the abstract `PipelexRequestError`, which refines the protocol-base `PipelineRequestError` (re-exported from `mthds/protocol`) and takes the verdict as a constructor argument, so every subclass declares its verdict where it is defined and one that forgets it does not compile; `errorVerdictOf` reads the pair structurally from anything a `catch` holds. The base also carries `runMayHaveStarted`, false unless a request that may create a run failed in a way that may have started one, and `runMayHaveStartedOf` reads it structurally the same way. Under that base sit `RequestArgumentError` (the client refusing a call's arguments before sending any request, `input` unless the site declares otherwise, as the invalid base URL declares `config`), the transport and run-lifecycle errors, `PagingNotTerminatingError`, the input-preparation family under `InputPreparationError`, and the artifact family under `ArtifactOperationError` (`ScopeUnavailableError`, `ArtifactFetchError`, `ArtifactAuthenticationError`, the last carrying the download verdict so far); both family bases take an optional verdict for the sites, and the consumer subclasses, whose verdict is not the family's. `ApiResponseError` carries every member of the problem document it was built from, its nested members checked one level down by the readers a stored report goes through, and also stands for a 2xx answer the SDK could not read — the branch fields `errorDomain` and `type`, `retryable`, `userAction`, `requestId` (the body's, else the `X-Request-ID` header's), the native `code` and `errorType`, the platform's `errors[]`, the inference members, and the decoded document whole as `problemDocument` — and resolves `retryable` and `errorDomain` from the document's values when they are valid and the fallback's otherwise, except that a valid `input` or `config` domain sent without `retryable` is not retryable. Beside those hosted members it carries `problem`, the same answer read as the standard reads it (`readAnswer` from `mthds/protocol`), the problem `mthds`'s own `ApiResponseError` carries, so a caller handling both clients branches on `problem.code`; the platform's `code` and `errors[]` stay on the error, apart from the registry's `problem.code` and the problem items of `problem.errors`. `RunFailedError` carries the run's `status` and its stored report as `error`, and takes its verdict from that report. It is not the whole error surface: `codegen-check.ts` exports `CodegenLockError`, which extends plain `Error` because nothing was requested over the wire, and declares the two verdict members itself. See [errors.md](./errors.md#the-verdict-every-error-carries).
125
- - `error-verdicts.ts` — `fallbackVerdict`, the table an `ApiResponseError` falls back to for a member the server did not send: the HTTP status, the platform `code`, and whether the body is named, which decides a `404`. Internal and pure. `pipelex-sdk` (Python) must apply the same table: `tests/fixtures/error-verdicts.json`, the case file this package's suite drives, is the contract, and the Python suite drives its own code through the identical copy `make shared-files` writes.
126
- - `run-may-have-started.ts` — the **pure** reading of whether a failed request that may create a run may still have started one: `answerMayHaveStartedRun(status, code)` for an answer and `transportMayHaveStartedRun(cause)` for a transport failure, every failure counting unless it proves otherwise (a failure of the connection's set-up, a `501`, the platform's `503` coded `pipeline_start_unavailable`, or any status that refuses the request). The client marks the requests that may create a run, `start` and the blocking `execute`, with a `RunCreation` flag it threads through `requestRaw`, `readObjectAnswer` and its error helpers into the constructors of `ApiUnreachableError` and `ApiResponseError`, which apply the reading and carry the answer as `runMayHaveStarted`, forcing `retryable` false when it is true; nothing changes an error after it is thrown. The Python twin is `pipelex_sdk/run_may_have_started.py`. See [errors.md](./errors.md#a-failed-run-start--runmayhavestarted).
126
+ - `errors.ts` — the typed errors, each carrying its failure as a problem document. The abstract `PipelexRequestError` refines the standard's `ProblemError` (from `mthds/protocol`), so every getter that reads the problem (`code`, `domain`, `retry`, `nextStep`, `retryAfter`, `requestId`) is on every SDK error, and it takes the problem as a constructor argument, so every subclass states its problem where it is raised and one that forgets it does not compile. The message stays the SDK's own sentence. Under that base sit `RequestArgumentError` (the client refusing a call's arguments before sending any request, `client.argument_invalid` unless the site gives another, as the invalid base URL gives `client.not_configured`), the transport and run-lifecycle errors, `PagingNotTerminatingError`, the input-preparation family under `InputPreparationError`, and the artifact family under `ArtifactOperationError` (`ScopeUnavailableError`, `ArtifactFetchError`, `ArtifactAuthenticationError`, the last carrying the download verdict so far). `ApiResponseError` carries the answer read as its problem (`readHostedAnswer`), with its `status`, `statusText` and raw `responseBody`, and also stands for a 2xx answer the SDK could not read. `RunFailedError` carries `run.failed` and reads the run's own problem, which it nests as `run_error`, as `runError`. The module also holds the builders of the problems the SDK states itself (`argumentInvalidProblem`, `responseUnreadableProblem`, `storageStatusProblem`, `uploadTransportProblem`, `artifactFetchProblem`, `methodFileTransferProblem`, `runFailedProblem`) and the codes a caller branches on (`MethodErrorCode`, `IdempotencyErrorCode`, `LogoRefusalCode`). It is not the whole error surface: `codegen-check.ts` exports `CodegenLockError` and `exact-json.ts` `ExactJsonUnsupportedError`, both `ProblemError`s outside the request family because nothing was requested over the wire. The shared case file `tests/fixtures/sdk-problems.json` pins each problem's code for both SDKs. See [errors.md](./errors.md#the-problem-every-error-carries).
127
127
  - `user-agent.ts` — client identification per the spec `conformance/specs/client-identification.md`, in the `conformance` repository beside the specs' tests: the exported `AppInfo` option, its RFC 9110 token validation (a `TypeError` on a bad field, a `RangeError` past the 512-character ceiling), its rendering, the runtime detection (Node, Bun, Deno, or a browser, where no header is set) and `buildUserAgent`, which the client calls once at construction. The barrel re-exports `buildUserAgent`, `validateAppInfo`, `MAX_USER_AGENT_LENGTH` and the `RuntimeInfo` type, so a consumer's raw fetches send the same value. The client's private `requestHeaders` is the one place API request headers are built, so every route carries the value; the artifact fetch of a presigned link never goes through it. See [client-identification.md](./client-identification.md).
128
- - `exact-json.ts` — `parseExactJson` and `stringifyExactJson`, JSON whose integers stay exact, and `ExactJsonUnsupportedError`. The reader, which the client applies to every answer carrying a person's data (a method example, a run's record, a run's results, the blocking execute's answer), is one `JSON.parse` call whose reviver turns a number past `Number.MAX_SAFE_INTEGER` spelled as an integer into a `bigint` of its source text (`context.source`), so it reads everything else exactly as `JSON.parse` does, with no second parser to keep in step; a text holding no run of 16 digits is parsed with no reviver at all. On a runtime whose reviver gets no source text, it refuses a whole number past the safe range with `ExactJsonUnsupportedError`, which the client reports as an answer it cannot read, rather than rounding it. The writer, which `requestRaw` applies to every request body before the request is made, is one `JSON.stringify` call whose replacer turns each `bigint` into `JSON.rawJSON` text, so every getter and `toJSON` in the body runs once, and a `bigint` an inherited `toJSON` such as the `BigInt.prototype.toJSON` polyfill already turned into a string is looked up in its holder and written as its digits; a runtime without `JSON.rawJSON` refuses the `bigint`, as a `RequestArgumentError` from the client, rather than rounding it. The barrel exports all three, for a caller showing such data as JSON text; the error declares its verdict itself, `config` and not retryable, as `CodegenLockError` does. It imports nothing but the `ErrorDomain` type. See [exact-integers.md](./exact-integers.md).
128
+ - `exact-json.ts` — `parseExactJson` and `stringifyExactJson`, JSON whose integers stay exact, and `ExactJsonUnsupportedError`. The reader, which the client applies to every answer carrying a person's data (a method example, a run's record, a run's results, the blocking execute's answer), is one `JSON.parse` call whose reviver turns a number past `Number.MAX_SAFE_INTEGER` spelled as an integer into a `bigint` of its source text (`context.source`), so it reads everything else exactly as `JSON.parse` does, with no second parser to keep in step; a text holding no run of 16 digits is parsed with no reviver at all. On a runtime whose reviver gets no source text, it refuses a whole number past the safe range with `ExactJsonUnsupportedError`, which the client reports as an answer it cannot read, rather than rounding it. The writer, which `requestRaw` applies to every request body before the request is made, is one `JSON.stringify` call whose replacer turns each `bigint` into `JSON.rawJSON` text, so every getter and `toJSON` in the body runs once, and a `bigint` an inherited `toJSON` such as the `BigInt.prototype.toJSON` polyfill already turned into a string is looked up in its holder and written as its digits; a runtime without `JSON.rawJSON` refuses the `bigint`, as a `RequestArgumentError` from the client, rather than rounding it. The barrel exports all three, for a caller showing such data as JSON text; the error is a `ProblemError` whose problem is `client.tool_outdated`, naming the JavaScript runtime. It imports nothing but the standard's problem helpers. See [exact-integers.md](./exact-integers.md).
129
129
  - `timers.ts` — `MAX_TIMER_DELAY_MS`, the longest delay `setTimeout` honours, and `isTimerDelay`, which every caller-supplied `timeoutMs` is checked against before it reaches a timer: a longer delay overflows and fires at once. It imports nothing, so the browser-safe upload entry uses it too.
130
130
  - `node-host.ts` and `node-host/` — the Node host ([above](#the-node-host)): `loadNodeHost()` and `isNodeRuntime()` in `node-host.ts`, the Node variant `node-host/node.ts`, the only module of the barrel's graph that imports a Node built-in, `undici` or the `mthds` root entry, and `node-host/unavailable.ts`, the variant every other runtime resolves.
131
131
  - `version.ts` — `SDK_VERSION`, in its own module so the client stamps it on the `User-Agent` without importing the barrel, which re-exports it.
@@ -138,23 +138,23 @@ Flat `src/` (mirrors `mthds-js`'s `runners/api` flatness — the SDK has one cli
138
138
  - **Two independent opt-in lists on `validate`.** `render` asks the server to render a *view format* and adds a `rendered_<format>` field; `views` asks for a *structured view* and adds a same-named top-level field — today `input_form` and `output_form`, the per-pipe form descriptors. A caller that wants the forms without the dry-run verdict reads `pipeIo` instead, which derives them with no dry run. Each resolves its own tokens against its own supported set and unknown tokens are lenient-ignored on both, never a `422`. The client always asks for `render: ["markdown"]` but sends `views` only when a caller names a token, because an opt-in view exists precisely so the default response stays byte-identical for the consumers that discard it. `pipelex-api` gates `input_form` on the token as of 0.18.0; a 0.17.0 runner resolved no token and emitted the field on every valid verdict, which is why `PipelexValidationReport.input_form` is typed optional rather than required — the one typing that reads a response from either runner correctly.
139
139
  - **The method's tree:** `execute` / `start` / `startAndWaitForResult` (both paths) and `validate` take the whole method as its tree — every `.mthds` bundle, its `funcs/*.py`, `structures/*.py` and `requirements.txt`, and every file a step reads, such as a `PipeDocGen` step's template — in one of four forms, `directory`, `contents`, `tree` or `files`, sent inline or by hash. `startAndWaitForResult` prepares the tree once, under its `signal`, before it starts the run, so its fallback to the blocking execute sends the same files without reading or uploading them again. A tree is self-contained, so it satisfies the "something to run" precondition on its own — `pipe_code` is not required beside it. See [run-sources](#run-sources) above and [method-trees.md](./method-trees.md).
140
140
  - **Method selectors on the run methods:** `execute` / `start` / `startAndWaitForResult` take the Pipelex API's `method_ref` (an address the runner resolves; provenance comes back typed as `method_provenance`) and the platform's `method_id` as **typed named options** (`PipelexRunOptions` / `PipelexStartOptions`), never `extra` entries. See [Pipelex API run extensions](#pipelex-api-run-extensions-method_ref) and [hosted run extensions](#hosted-run-extensions-method_id) above.
141
- - **An idempotency key on a run start (hosted):** `start` and `startAndWaitForResult` take `idempotencyKey` (`PipelexStartRequestOptions`, intersected into `PipelexStartOptions`), sent as the `Idempotency-Key` header of `POST /v1/start` and never in the body; with no key there is no header. The hosted API binds the key to the run, answers a repeat of the same start with that run, and refuses the key with other options; a bare runner ignores the header. The client refuses a key that is not 1 to 255 visible ASCII characters with a `RequestArgumentError` before any request, the blocking `execute` fallback of `startAndWaitForResult` sends none, and the key leaves the error verdict as it is: a start that may have reached the server stays `runMayHaveStarted`, and the caller who sent a key recovers the run by sending the same start again under it. [errors.md](./errors.md#a-starts-idempotency-key--idempotencykey) holds the contract.
141
+ - **An idempotency key on a run start (hosted):** `start` and `startAndWaitForResult` take `idempotencyKey` (`PipelexStartRequestOptions`, intersected into `PipelexStartOptions`), sent as the `Idempotency-Key` header of `POST /v1/start` and never in the body; with no key there is no header. The hosted API binds the key to the run, answers a repeat of the same start with that run, and refuses the key with other options; a bare runner ignores the header. The client refuses a key that is not 1 to 255 visible ASCII characters with a `RequestArgumentError` before any request, the blocking `execute` fallback of `startAndWaitForResult` sends none, and the key leaves the error's problem as it is: a start that may have reached the server stays `client.outcome_unknown`, and the caller who sent a key recovers the run by sending the same start again under it. [errors.md](./errors.md#a-starts-idempotency-key--idempotencykey) holds the contract.
142
142
  - **Method selectors on `validate`:** the first argument is a `PipelexValidateSource` — the method's tree in one of its four forms, `{ method_ref }` (runner-resolved by address) or `{ method_id }` (hosted, platform-resolved) — under the strict tooling XOR: exactly one, no linkage exception, refused with a `RequestArgumentError` before any request. Its options (`ValidateOptions`: `render`, `views`, `timeoutMs`, `signal`) ride the third argument. A selector-resolution failure is a non-2xx `ApiResponseError`, never an `is_valid: false` verdict.
143
- - **The files a step reads ride the tree.** A `PipeDocGen` step's `template_file` is a file of the method's tree like any other, at the tree path the step names; the separate `resources` field is gone from every route. The valid verdict's `warnings` carry the template check's `template_check_warning` items, whose `template_file` and `template_location` `ValidationErrorItem` declares ahead of `mthds`, a difference the type-level pin states, and its optional `tree_files_read` and `tree_file_readers` name the files of the method's tree each step reads, whatever form the method was sent in ([which step reads which file](./method-trees.md#which-step-reads-which-file)). See [method-trees.md](./method-trees.md#the-files-a-step-reads).
144
- - **Crate routes (`/v1/resolve`, `/v1/codegen`, `/v1/pipe-io`):** `resolve` emits the **normalized library crate** (the MTHDS standard's Library Crate Format, fingerprint riding inside the payload); `codegen` projects that crate through two explicit axes — `kind` (`types`) × `target` (`ts-zod` / `python-pydantic` / `python-structures`) — into stamped artifacts plus their `codegen.lock`. All three share one closure envelope (the method's tree, in one of the four forms a run takes, XOR a `method_ref`, the address form server-resolved and the registry form a `501`) and return a **discriminated 200 verdict** (`is_valid`, with `CrateInvalidReport` as the shared invalid arm) rather than a bare payload, the same discipline as `validate`. They also take the hosted `method_id` (`PipelexHostedToolingExtensions`) as a third closure form under the strict three-way XOR — a pass-through the platform resolves, meaningless against a bare runner. Writing the artifacts and lock verbatim reproduces a local `pipelex codegen types` run byte-for-byte, so the offline `pipelex codegen check` passes — the SDK stays transport-only and does not write files. Verifying such a tree afterwards is `runCodegenCheck`, a standalone pure helper rather than a client method, because it needs no server at all. `pipeIo` derives a method's three I/O artifacts — the standard's `PipeIOContracts`, `InputForm` and `OutputForm`, imported from `mthds/protocol` — for the pipe the route selects (the request's qualified `pipe_ref`, else the method's own entry pipe) or for every pipe under `all_pipes`, with the runnability facts beside them and the closure's files echoed on `include_files`, and it runs no dry run; a selection it refuses is a `422` typed `EntryPipeNotFoundError` or `EntryPipeAmbiguousError`. See [crate-routes.md](./crate-routes.md).
145
- - **Model reference check (`/v1/models/check`):** `checkModelReference(reference, category?)`, a Pipelex API extension served by any `pipelex-api` runner from pipelex 0.78.0 and on the hosted API, whose platform relays it byte-identically. It is a **diagnostic endpoint** like `validate`: a reference that resolves nowhere is a `200` verdict with `resolution: "not_found"` and the names the caller may have meant (`suggestions`, `other_kinds`, `other_categories`), never a thrown error. Only a request that cannot produce a verdict throws `ApiResponseError`, a `422` typed by its `errorType`: `InvalidModelReference` for a reference that is blank, a sigil or a namespace alone, or past the runner's length limit, `InvalidModelCategory` for an unknown `type`, `ValidationError` for a missing or repeated parameter. The SDK validates none of it, so the runner's rule is the only one. `ModelReferenceVerdict` is a union of one arm per reference kind, discriminated on the wire's own `kind` (`PresetReferenceVerdict`, `AliasReferenceVerdict`, `WaterfallReferenceVerdict`, `HandleReferenceVerdict`): a `matches` entry carries no kind of its own, but every entry of one verdict shares the verdict's, so narrowing on `kind` types `matches` as `PresetMatch[]`, `AliasMatch[]`, `WaterfallMatch[]` or `HandleMatch[]` without a field the wire does not carry. `ModelCheckCategory` is the protocol's model categories plus `doc_gen`, the family of `PipeDocGen`, which the protocol and so `models` leave out but a method's `model` field can name. `resolution` is typed open, `ModelReferenceResolution | (string & {})`, under the spec's reader rule that a client reading a value it does not know treats the reference as unresolved, and so is every category a verdict names, `ModelCheckCategory | (string & {})` as `mthds` types `ModelInfo.type`, since a runner of a later version may add one; the `category` a request names stays closed. A match's `resolves_to` is `null` when the reference resolves by name but a run through it would reach no model the runner can call, which a consumer shows as a warning rather than as an unknown name.
143
+ - **The files a step reads ride the tree.** A `PipeDocGen` step's `template_file` is a file of the method's tree like any other, at the tree path the step names; the separate `resources` field is gone from every route. The valid verdict's `warnings` carry the template check's findings as problem items, located by the template file they name, and its optional `tree_files_read` and `tree_file_readers` name the files of the method's tree each step reads, whatever form the method was sent in ([which step reads which file](./method-trees.md#which-step-reads-which-file)). See [method-trees.md](./method-trees.md#the-files-a-step-reads).
144
+ - **Crate routes (`/v1/resolve`, `/v1/codegen`, `/v1/pipe-io`):** `resolve` emits the **normalized library crate** (the MTHDS standard's Library Crate Format, fingerprint riding inside the payload); `codegen` projects that crate through two explicit axes — `kind` (`types`) × `target` (`ts-zod` / `python-pydantic` / `python-structures`) — into stamped artifacts plus their `codegen.lock`. All three share one closure envelope (the method's tree, in one of the four forms a run takes, XOR a `method_ref`, the address form server-resolved and the registry form a `501`) and return a **discriminated 200 verdict** (`is_valid`, with `CrateInvalidReport` as the shared invalid arm) rather than a bare payload, the same discipline as `validate`. They also take the hosted `method_id` (`PipelexHostedToolingExtensions`) as a third closure form under the strict three-way XOR — a pass-through the platform resolves, meaningless against a bare runner. Writing the artifacts and lock verbatim reproduces a local `pipelex codegen types` run byte-for-byte, so the offline `pipelex codegen check` passes — the SDK stays transport-only and does not write files. Verifying such a tree afterwards is `runCodegenCheck`, a standalone pure helper rather than a client method, because it needs no server at all. `pipeIo` derives a method's three I/O artifacts — the standard's `PipeIOContracts`, `InputForm` and `OutputForm`, imported from `mthds/protocol` — for the pipe the route selects (the request's qualified `pipe_ref`, else the method's own entry pipe) or for every pipe under `all_pipes`, with the runnability facts beside them and the closure's files echoed on `include_files`, and it runs no dry run; a selection it refuses is a `422` problem coded `request.pipe_not_found`, `request.pipe_unspecified` or `request.pipe_ambiguous`. See [crate-routes.md](./crate-routes.md).
145
+ - **Model reference check (`/v1/models/check`):** `checkModelReference(reference, category?)`, a Pipelex API extension served by any `pipelex-api` runner from pipelex 0.78.0 and on the hosted API, whose platform relays it byte-identically. It is a **diagnostic endpoint** like `validate`: a reference that resolves nowhere is a `200` verdict with `resolution: "not_found"` and the names the caller may have meant (`suggestions`, `other_kinds`, `other_categories`), never a thrown error. Only a request that cannot produce a verdict throws `ApiResponseError`, a `422` `request.invalid` whose `location.field` names the parameter it refused: `reference` for a reference that is missing, repeated, blank, a sigil or a namespace alone, or past the runner's length limit, and `type` for a category that is repeated, empty or unknown. The SDK validates none of it, so the runner's rule is the only one. `ModelReferenceVerdict` is a union of one arm per reference kind, discriminated on the wire's own `kind` (`PresetReferenceVerdict`, `AliasReferenceVerdict`, `WaterfallReferenceVerdict`, `HandleReferenceVerdict`): a `matches` entry carries no kind of its own, but every entry of one verdict shares the verdict's, so narrowing on `kind` types `matches` as `PresetMatch[]`, `AliasMatch[]`, `WaterfallMatch[]` or `HandleMatch[]` without a field the wire does not carry. `ModelCheckCategory` is the protocol's model categories plus `doc_gen`, the family of `PipeDocGen`, which the protocol and so `models` leave out but a method's `model` field can name. `resolution` is typed open, `ModelReferenceResolution | (string & {})`, under the spec's reader rule that a client reading a value it does not know treats the reference as unresolved, and so is every category a verdict names, `ModelCheckCategory | (string & {})` as `mthds` types `ModelInfo.type`, since a runner of a later version may add one; the `category` a request names stays closed. A match's `resolves_to` is `null` when the reference resolves by name but a run through it would reach no model the runner can call, which a consumer shows as a warning rather than as an unknown name.
146
146
  - **Tools (`/v1/lint`, `/v1/format`):** `lint`, `format` — single-file static diagnostics and canonical formatting, served by any pipelex-api runner (no inference, no bundle load). Both are **diagnostic endpoints** like `validate`: a malformed `.mthds` file is a produced verdict on a **200** carrying `diagnostics[]`, never a thrown error — `format` additionally echoes the content back unchanged with `changed: false`. Only a no-verdict condition is non-2xx: a malformed body, **malformed formatter `options`** (e.g. a non-numeric `column_width` ⇒ 422), auth, or a server fault, all mapped to the typed `ApiResponseError`. `lint` is the cheap static check (syntax / schema / semantic); it does not replace `validate`, which loads the bundle, resolves across files, and dry-runs the pipes.
147
- - **Durable run lifecycle (hosted extension — NOT protocol):** `getRunStatus`, `getRunProgress` (`GET /v1/runs/{id}/progress`, the run's progress snapshot, see [run-results.md](./run-results.md#following-a-run-while-it-runs--onprogress-and-getrunprogress)), `getRunResult` (with an optional `artifacts` selection sent as `?artifacts=`, see [run-results.md](./run-results.md#reading-only-some-artifacts--artifacts)), `waitForResult` (whose `onProgress` reads the progress route on each poll that finds the run running and delivers each fresher snapshot by `isFresherRunProgress`, a failed progress read never failing the wait), `startAndWaitForResult` (handshakes `/v1/version`, takes the durable start+poll path on a hosted deployment, and self-heals to the blocking `execute` against a bare runner). The handshake's outcome is cached for the client's lifetime, under the rule the Python SDK follows too: an answer that is not a usable version assumes hosted and lets the start surface the real error, while no answer at all throws its `ApiUnreachableError` at once, caches nothing and sends no start. An answer is anything the server sent back: an error status, a body that is not JSON, not UTF-8 or no version, and a body that arrived but could not be decoded, such as a broken gzip stream, which fetch fails with the decompressor's code (`Z_DATA_ERROR` and the other zlib codes, brotli's `ERR__ERROR_…`, zstd's `ZSTD_error_…`). A run that ended without completing answers the results read with a `409` whose `run_status` and `error` members carry its status and its stored error report; `getRunResult` turns it into the `failed` arm (`status`, `message` from `detail`, `error`) and the waiting calls on the durable path into a `RunFailedError` carrying the same (the blocking fallback's failure is the runner's `ApiResponseError`), the status read from the member, or from the `detail` sentence on a platform that does not send it yet. The status read serves the same report as `RunRead.error`. See [errors.md](./errors.md#a-failed-run--runerrorreport). The status read also carries `degraded` and the `degradation` that explains it, whose `reason` tells a status the platform could not refresh (`status_unavailable`) from a run whose workflow task keeps failing until the platform ends it (`workflow_task_failing`), checked field by field so it never fails the poll, and the run's `request_id`, the id to quote to support. See [run-results.md](./run-results.md#a-degraded-status--degraded-and-degradation). Reading what a completed run consumed is `summarizeUsage(results)`, a standalone pure helper rather than a client method, because it folds the usage pair the results already carry and needs no server. See [run-usage.md](./run-usage.md#summarizing-a-run--summarizeusage).
147
+ - **Durable run lifecycle (hosted extension — NOT protocol):** `getRunStatus`, `getRunProgress` (`GET /v1/runs/{id}/progress`, the run's progress snapshot, see [run-results.md](./run-results.md#following-a-run-while-it-runs--onprogress-and-getrunprogress)), `getRunResult` (with an optional `artifacts` selection sent as `?artifacts=`, see [run-results.md](./run-results.md#reading-only-some-artifacts--artifacts)), `waitForResult` (whose `onProgress` reads the progress route on each poll that finds the run running and delivers each fresher snapshot by `isFresherRunProgress`, a failed progress read never failing the wait), `startAndWaitForResult` (handshakes `/v1/version`, takes the durable start+poll path on a hosted deployment, and self-heals to the blocking `execute` against a bare runner). The handshake's outcome is cached for the client's lifetime, under the rule the Python SDK follows too: an answer that is not a usable version assumes hosted and lets the start surface the real error, while no answer at all throws its `ApiUnreachableError` at once, caches nothing and sends no start. An answer is anything the server sent back: an error status, a body that is not JSON, not UTF-8 or no version, and a body that arrived but could not be decoded, such as a broken gzip stream, which fetch fails with the decompressor's code (`Z_DATA_ERROR` and the other zlib codes, brotli's `ERR__ERROR_…`, zstd's `ZSTD_error_…`). A run that ended without completing answers the results read with a `409` `run.failed` problem document, naming the terminal status as its `run_status` parameter and nesting the run's own problem as `run_error`; `getRunResult` turns it into the `failed` arm (`status`, `message` from `detail`, `problem`, `error`) and the waiting calls on the durable path into a `RunFailedError` carrying the same (the blocking fallback's failure is the runner's `ApiResponseError`). The status read serves the run's own problem as `RunRead.error`. See [errors.md](./errors.md#a-failed-run--runfailederror). The status read also carries `degraded` and the `degradation` that explains it, whose `reason` tells a status the platform could not refresh (`status_unavailable`) from a run whose workflow task keeps failing until the platform ends it (`workflow_task_failing`), checked field by field so it never fails the poll, and the run's `request_id`, the id to quote to support. See [run-results.md](./run-results.md#a-degraded-status--degraded-and-degradation). Reading what a completed run consumed is `summarizeUsage(results)`, a standalone pure helper rather than a client method, because it folds the usage pair the results already carry and needs no server. See [run-usage.md](./run-usage.md#summarizing-a-run--summarizeusage).
148
148
  - **Health:** `health` (origin-level `/health`), on the same transport as every route: an unreachable origin is an `ApiUnreachableError` and a non-2xx answer an `ApiResponseError` naming `/health`, whose verdict the fallback reads from the status. A 2xx whose body is not a JSON object is an `ApiResponseError` too, as on every protocol, extension and run-lifecycle route.
149
149
 
150
150
  ### Product routes (hosted management surface)
151
151
 
152
- The hosted catalog/account routes the webapp drives. All ride the same `{base}/v1/*` + `Authorization: Bearer` + org-from-JWT contract as the protocol routes, go through the shared `requestProduct<T>` helper (30s management timeout, empty-body tolerant), and map a non-2xx RFC 9457 `problem+json` to a typed `ApiResponseError`. Every route parses a 2xx body through one private helper, so an answer that is not JSON (a gateway's HTML page, an empty body where one was due) is an `ApiResponseError` carrying its status and raw text, `runtime` and not retryable, rather than a bare `SyntaxError`. A product route whose answer the SDK or its caller reads before anything else (`listMethods`, `listRuns`, `getRunDetail`, `upload`, `requestUploadGrant`, `resolveStorageUrls`, and the methods routes answering one stored method) goes through `requestProductObject` or a helper built on the same read instead, which also refuses an empty body and JSON that is not an object; the two paged routes then refuse, the same way, through one page reader, a page whose `items` is not an array or whose `next_cursor` is neither a string nor `null`, `upload` an answer with no non-empty string `uri`, and `getMethod` and `getMethodVersion` a `files` member that is not a listing of files, each with its string `path`, `sha256`, `url` and `expires_at` and a non-negative integer `size`. Consumers branch on **`type` and `errorDomain`**, as the hosted-envelope spec (`conformance/specs/pipelex-hosted-envelope.md`, in the `conformance` repository) says, never on the HTTP status. The platform's `type` is `https://pipelex.com/errors/<code>`, one-to-one with its native `code` (`conflict`, `not_found`, `pipelex_api_key_limit_reached`, …), the finer surface-specific code, on which a client of the platform alone may branch as well: the two read the same answer, and a refusal the platform relays from the runner carries the runner's `type` and `errorType` and no `code`. The SDK reads the `error_domain` and `retryable` the server sent first, and where the platform sends none on a refusal it renders itself, as its Lambda-backed routes do not yet, `errorDomain` and `retryable` are the SDK's fallback, read from the status; every error carries both, decided. See [errors.md](./errors.md).
152
+ The hosted catalog/account routes the webapp drives. All ride the same `{base}/v1/*` + `Authorization: Bearer` + org-from-JWT contract as the protocol routes, go through the shared `requestProduct<T>` helper (30s management timeout, empty-body tolerant), and map a non-2xx RFC 9457 `problem+json` to a typed `ApiResponseError`. Every route parses a 2xx body through one private helper, so an answer that is not JSON (a gateway's HTML page, an empty body where one was due) is an `ApiResponseError` carrying its status and raw text, `client.response_unreadable`, rather than a bare `SyntaxError`. A product route whose answer the SDK or its caller reads before anything else (`listMethods`, `listRuns`, `getRunDetail`, `upload`, `requestUploadGrant`, `resolveStorageUrls`, and the methods routes answering one stored method) goes through `requestProductObject` or a helper built on the same read instead, which also refuses an empty body and JSON that is not an object; the two paged routes then refuse, the same way, through one page reader, a page whose `items` is not an array or whose `next_cursor` is neither a string nor `null`, `upload` an answer with no non-empty string `uri`, and `getMethod` and `getMethodVersion` a `files` member that is not a listing of files, each with its string `path`, `sha256`, `url` and `expires_at` and a non-negative integer `size`. Every refusal the platform renders is a problem document under a registered code, a core one (`request.invalid`, `auth.forbidden`, …) or a Pipelex one (`pipelex.catalog.not_found`, `pipelex.billing.plan_limit`, …), and consumers branch on its `code`, as the hosted-envelope spec (`conformance/specs/pipelex-hosted-envelope.md`, in the `conformance` repository) says, never on the HTTP status. An answer whose body is no problem document comes from the hosted edge and reads by its status. See [errors.md](./errors.md).
153
153
 
154
154
  - **User profile:** `getMe` (`GET /v1/me`).
155
- - **Methods catalog:** `listMethods`, `getMethod`, `createMethod`, `writeDraft`, `renameMethod`, `publishMethod`, `listMethodVersions`, `getMethodVersion`, `deleteMethod`. **A saved method has a draft and immutable published versions.** Its draft is a method tree with its `input_data`, which `writeDraft` (`PUT /v1/methods/{id}/draft`) replaces whole, without validating it; `updated_at` is the draft's token, which moves on every draft write and nothing else, and `draft_digest` the draft's tree digest (`sha256:` and 64 hex digits), `null` for an empty draft. A save takes the tree as `files`, sent as given, or as `directory`, `tree` or `contents`, sent inline or by hash after uploading the blobs the organization lacks, and a read lists the tree's files, each with a short-lived link to its bytes, which `writeMethodTree` writes to disk ([method-trees.md](./method-trees.md)). A draft write may carry `expected_updated_at`, the token the caller last saw, and is then a compare-and-swap: a draft that moved since is a `409` `method_update_conflict` and nothing is written. `renameMethod` (`PATCH /v1/methods/{id}`) changes the name alone and moves no token. `publishMethod` (`POST /v1/methods/{id}/publish`) takes the required `expected_draft_updated_at` and answers a `MethodPublishResult` discriminated on `outcome` — `published` with the new version, `unchanged` with the existing latest version when the draft's digest equals it, `refused` with a `reason` (`invalid` or `not_runnable`), a `message` and the runner's `validation` verdict — every arm carrying the `method`; the SDK reads the nested method as `getMethod` does, and an answer whose `outcome` is none of these is an answer it cannot read. A publish that produced no verdict is thrown rather than answered: only a draft that differs from the latest version reaches the runner, and a runner that cannot be reached, answers unusably or does not finish within the platform's deadline is a `502` or `503` with nothing written, so a retry is safe, and a retry of a publish that landed while its answer was lost answers `unchanged` with the version it wrote. A method is stored as one item that keeps room for the summary a publish adds, so a draft that saves can always be published: a creation or a draft write is a `413` `payload_too_large` when it would grow the method past that room, below the store's item limit, and a rename is the same `413` for a name so long it would leave the method too large to publish. A version is numbered from 1, never reused, and holds the draft's tree as it stood, its `tree_digest` (`null` for a version stored before methods were trees), its `crate_fingerprint` (`null` when the runner could not resolve the bundle in memory), the runner's version, and when and by whom it was published; `listMethodVersions` pages the summaries newest first (`MethodVersionPage`, `{items, nextCursor}`, a `limit` from 1 to 100, 20 by default, and a `422` outside that range), and `getMethodVersion` reads one with its `files` listed as a method's are, or answers a `409` `conflict`, whose verdict is `runtime`, for a version stored before methods were trees and never rewritten as one. A method read carries `latest_version` and `latest_published`, both `null` until the first publish, so the publish state is derived: never published, draft unchanged (`draft_digest` not `null` and equal to `latest_published.tree_digest`), or draft ahead. The method routes take a bare id and address the method itself, never one of its versions; the platform does not parse a suffix there, so `mt_x@3` would answer a `404` that reads as a method that does not exist, and each route refuses a suffixed id with `RequestArgumentError` before anything is sent, naming `parseMethodSelector` and `getMethodVersion`. The whole-method `updateMethod` is gone, replaced by the draft write and the rename. **`deleteMethod` is asynchronous and its return type says so:** `DELETE /v1/methods/{id}` answers `202` with a `MethodDeletionAccepted` (`method_id`, `deletion_state`, `deletion_job_id`) the moment the platform has claimed the method and terminated its in-flight workflows — the rest of the cascade (runs, events, S3 objects) is enqueued. A resolved promise means "accepted", never "gone"; completion is the row disappearing from `listMethods`, not any field of that body. The claim is a conditional write, so a double-clicked delete is a `409 method_being_deleted` rather than a second cascade over the same runs, and an unknown or foreign-org id is a `404`. The erasure deletes the method's versions with the rest. (This doc previously stated the SDK had no `delete` because the platform had no such route — the route has always existed; the claim was simply false.) `listMethods` returns **one page**, not the whole catalog — a `MethodPage` (`{items, nextCursor}`) served from a narrow index projection, so its cost is the page size rather than the size of the catalog. It used to return every method with its full `.mthds` bundle attached, which hit DynamoDB's 1 MB page cap at a couple hundred methods and came back truncated with no error and no flag, so methods silently vanished from the UI. Rows are therefore `MethodSummary`, **not** `MethodDetail`: no `files`, no `updated_at`, because neither is in the projection and putting the source back is what restored the truncation. Reach for `getMethod(id)` when you need a method's files. `query.q` is applied server-side across the whole catalog, not over the page — filtering one page client-side would be searching 50 rows of 10,000 and calling it a search. `query.published` filters the same way, sent as `published=true` (the methods with a published version) or `published=false` (those never published), and combines with `q`. Each row carries the method's publish state, so a list tells its methods apart without reading each one: `latest_version` and `latest_published` (a `MethodVersionSummary`), both `null` when the method was never published, and `draft_digest`, `null` for an empty draft; all three are optional, absent from a platform that does not serve them. Never published is `latest_version === null`, the draft unchanged is a `draft_digest` that is not `null` and equals `latest_published.tree_digest`, and the draft ahead is two digests that differ. Both filters run after the index read, so **a filtered page can come back empty and still carry a `nextCursor`**: an empty page is not the end of the list, and only a `null` cursor is. `iterateMethods` is the async iterator that follows the cursor for callers who genuinely want everything (`for await (const m of client.iterateMethods())`); it keeps going **past empty pages**, since `{items: [], nextCursor: "…"}` means "nothing matched in the slice I just read, keep going". A server that keeps minting fresh cursors forever stops it at a page ceiling far past any real catalog with a `PagingNotTerminatingError` carrying `pageLimit`, never a silently truncated stream; `iterateRuns` shares the same loop. It gives up only when the server stops advancing its cursor, and **throws** rather than returning a partial answer; like `iterateRuns` it is deliberately not a `listAllMethods(): Promise<MethodSummary[]>`, because an all-at-once helper needs a page cap and a cap means silently returning a truncated list. `MethodSummary.deletion_state` rides the list rows: a method mid-erasure stays listed so the UI can render it as "Deleting…", while `getMethod` refuses it with a 409. Full read responses (`MethodDetail`, from `getMethod`) carry `org_id`, `created_by_user_id`, a server-derived `description` (recomputed from the draft's top-level `description` on every draft write), the draft token and digest, the latest version's summary and the draft's `files` on top of the write contract's `name` / `input_data`; the writes and the rename answer `MethodData`, the same method without `files`.
155
+ - **Methods catalog:** `listMethods`, `getMethod`, `createMethod`, `writeDraft`, `renameMethod`, `publishMethod`, `listMethodVersions`, `getMethodVersion`, `deleteMethod`. **A saved method has a draft and immutable published versions.** Its draft is a method tree with its `input_data`, which `writeDraft` (`PUT /v1/methods/{id}/draft`) replaces whole, without validating it; `updated_at` is the draft's token, which moves on every draft write and nothing else, and `draft_digest` the draft's tree digest (`sha256:` and 64 hex digits), `null` for an empty draft. A save takes the tree as `files`, sent as given, or as `directory`, `tree` or `contents`, sent inline or by hash after uploading the blobs the organization lacks, and a read lists the tree's files, each with a short-lived link to its bytes, which `writeMethodTree` writes to disk ([method-trees.md](./method-trees.md)). A draft write may carry `expected_updated_at`, the token the caller last saw, and is then a compare-and-swap: a draft that moved since is a `409` `pipelex.catalog.update_conflict` and nothing is written. `renameMethod` (`PATCH /v1/methods/{id}`) changes the name alone and moves no token. `publishMethod` (`POST /v1/methods/{id}/publish`) takes the required `expected_draft_updated_at` and answers a `MethodPublishResult` discriminated on `outcome` — `published` with the new version, `unchanged` with the existing latest version when the draft's digest equals it, `refused` with a `reason` (`invalid` or `not_runnable`), a `message` and the runner's `validation` verdict, its problem items read member by member as `validate` reads them — every arm carrying the `method`; the SDK reads the nested method as `getMethod` does, and an answer whose `outcome` is none of these, or a refusal whose `validation` is not a verdict object, is an answer it cannot read. A publish that produced no verdict is thrown rather than answered: only a draft that differs from the latest version reaches the runner, and a runner that cannot be reached, answers unusably or does not finish within the platform's deadline is a `502` or `503` with nothing written, so a retry is safe, and a retry of a publish that landed while its answer was lost answers `unchanged` with the version it wrote. A method is stored as one item that keeps room for the summary a publish adds, so a draft that saves can always be published: a creation or a draft write is a `413` `request.too_large` when it would grow the method past that room, below the store's item limit, and a rename is the same `413` for a name so long it would leave the method too large to publish. A version is numbered from 1, never reused, and holds the draft's tree as it stood, its `tree_digest` (`null` for a version stored before methods were trees), its `crate_fingerprint` (`null` when the runner could not resolve the bundle in memory), the runner's version, and when and by whom it was published; `listMethodVersions` pages the summaries newest first (`MethodVersionPage`, `{items, nextCursor}`, a `limit` from 1 to 100, 20 by default, and a `422` outside that range), and `getMethodVersion` reads one with its `files` listed as a method's are, or answers a `500` `runtime.unexpected` for a version stored before methods were trees and never rewritten as one. A method read carries `latest_version` and `latest_published`, both `null` until the first publish, so the publish state is derived: never published, draft unchanged (`draft_digest` not `null` and equal to `latest_published.tree_digest`), or draft ahead. The method routes take a bare id and address the method itself, never one of its versions; the platform does not parse a suffix there, so `mt_x@3` would answer a `404` that reads as a method that does not exist, and each route refuses a suffixed id with `RequestArgumentError` before anything is sent, naming `parseMethodSelector` and `getMethodVersion`. The whole-method `updateMethod` is gone, replaced by the draft write and the rename. **`deleteMethod` is asynchronous and its return type says so:** `DELETE /v1/methods/{id}` answers `202` with a `MethodDeletionAccepted` (`method_id`, `deletion_state`, `deletion_job_id`) the moment the platform has claimed the method and terminated its in-flight workflows — the rest of the cascade (runs, events, S3 objects) is enqueued. A resolved promise means "accepted", never "gone"; completion is the row disappearing from `listMethods`, not any field of that body. The claim is a conditional write, so a double-clicked delete is a `409` `pipelex.catalog.being_deleted` rather than a second cascade over the same runs, and an unknown or foreign-org id is a `404`. The erasure deletes the method's versions with the rest. (This doc previously stated the SDK had no `delete` because the platform had no such route — the route has always existed; the claim was simply false.) `listMethods` returns **one page**, not the whole catalog — a `MethodPage` (`{items, nextCursor}`) served from a narrow index projection, so its cost is the page size rather than the size of the catalog. It used to return every method with its full `.mthds` bundle attached, which hit DynamoDB's 1 MB page cap at a couple hundred methods and came back truncated with no error and no flag, so methods silently vanished from the UI. Rows are therefore `MethodSummary`, **not** `MethodDetail`: no `files`, no `updated_at`, because neither is in the projection and putting the source back is what restored the truncation. Reach for `getMethod(id)` when you need a method's files. `query.q` is applied server-side across the whole catalog, not over the page — filtering one page client-side would be searching 50 rows of 10,000 and calling it a search. `query.published` filters the same way, sent as `published=true` (the methods with a published version) or `published=false` (those never published), and combines with `q`. Each row carries the method's publish state, so a list tells its methods apart without reading each one: `latest_version` and `latest_published` (a `MethodVersionSummary`), both `null` when the method was never published, and `draft_digest`, `null` for an empty draft; all three are optional, absent from a platform that does not serve them. Never published is `latest_version === null`, the draft unchanged is a `draft_digest` that is not `null` and equals `latest_published.tree_digest`, and the draft ahead is two digests that differ. Both filters run after the index read, so **a filtered page can come back empty and still carry a `nextCursor`**: an empty page is not the end of the list, and only a `null` cursor is. `iterateMethods` is the async iterator that follows the cursor for callers who genuinely want everything (`for await (const m of client.iterateMethods())`); it keeps going **past empty pages**, since `{items: [], nextCursor: "…"}` means "nothing matched in the slice I just read, keep going". A server that keeps minting fresh cursors forever stops it at a page ceiling far past any real catalog with a `PagingNotTerminatingError` carrying `pageLimit`, never a silently truncated stream; `iterateRuns` shares the same loop. It gives up only when the server stops advancing its cursor, and **throws** rather than returning a partial answer; like `iterateRuns` it is deliberately not a `listAllMethods(): Promise<MethodSummary[]>`, because an all-at-once helper needs a page cap and a cap means silently returning a truncated list. `MethodSummary.deletion_state` rides the list rows: a method mid-erasure stays listed so the UI can render it as "Deleting…", while `getMethod` refuses it with a 409. Full read responses (`MethodDetail`, from `getMethod`) carry `org_id`, `created_by_user_id`, a server-derived `description` (recomputed from the draft's top-level `description` on every draft write), the draft token and digest, the latest version's summary and the draft's `files` on top of the write contract's `name` / `input_data`; the writes and the rename answer `MethodData`, the same method without `files`.
156
156
  - **Method examples:** `createMethodExample`, `listMethodExamples`, `iterateMethodExamples`, `getMethodExample`, `updateMethodExample`, `recordMethodExampleLatestRun`, `saveAssistantMethodExample`, `deleteMethodExample`, on `/v1/methods/{id}/examples`. An example is a named set of inputs saved on a method, shared across the organization, with an `input_revision` that rises each time its inputs change and the `latest_run_id` of those inputs, which an inputs edit clears. `recordMethodExampleLatestRun` records a run against the revision whose inputs it used and answers a `MethodExampleLatestRunResult` discriminated on `outcome`, `recorded` or `stale`, each arm carrying the `example`: a run that outlasted an edit is a verdict, never thrown, and an answer off the union is one the SDK cannot read; `saveAssistantMethodExample` is the build assistant's save, which writes into the example it names only while that example is still the assistant's (`kept_by_assistant`) at the revision it read, and otherwise creates one, answering a `MethodExampleAssistantSaveResult` discriminated on `outcome`, `kept` or `created`; one helper, `readVerdict`, reads these verdicts and `publishMethod`'s, the example checked once, by the reader every example goes through. Every answer carrying an example is read with `parseExactJson` and every request body written with `stringifyExactJson`, so an integer past `Number.MAX_SAFE_INTEGER` in the inputs reads as a `bigint` and goes back digit for digit, to an edit or as a run's inputs; `3.0` still reads as `3`, and inputs holding one sent back unchanged are an edit for the platform, which compares inputs as canonical JSON. Every example the client hands back, the page rows and the verdict's `example` included, is checked by `readMethodExample` against the fields the Python SDK's model requires, with the strict types it holds them to, an example breaking one being an answer the SDK cannot read. The list is paged oldest first by an opaque cursor, and a page may be short while `nextCursor` is set; `iterateMethodExamples` follows it through the loop `iterateMethods` and `iterateRuns` share, and refuses a method id at the call rather than at the first pull. Every method takes a bare catalog id, and the client refuses an example id that is empty, `.` or `..` or holds a lone surrogate, a create without its name or inputs or whose `run_id` is given but empty, an edit naming neither `name` nor `inputs`, and a latest-run write without a run id or a positive integer revision before sending. See [method-examples.md](./method-examples.md).
157
- - **Method copies:** `copyMethod`, `listMethodCopies`, on `/v1/methods/{id}/copy` and `/v1/methods/{id}/copies`. `copyMethod` makes a new, unpublished method in an organization the caller may change methods in, the method's own for a duplicate, holding the original's draft tree, its form inputs and its examples, the stored files they name copied into the destination, and answers a `MethodCopyResult` discriminated on `outcome`: `copied` with the copy as `method` and what was copied, or `refused` with the `references` of a bundle naming storage the destination cannot read or, in a duplicate, a file under the original's run files, which erasing the original would remove. The two arms carry different members, so the verdict is read by the copy itself rather than by `readVerdict`, which reads verdicts whose arms share one: an `outcome` off the union, a `copied` answer whose `method` is not an object and a `refused` one whose `references` is not an array are each an answer the SDK cannot read. Its body is closed, `destination_org_id` with `name` and `expected_draft_updated_at` when given, and its last argument takes an `idempotencyKey`, sent as the `Idempotency-Key` header through the same `requestRaw` option and the same `idempotencyKeyOf` check a start uses. `listMethodCopies` pages the original's `MethodCopyMarker` rows through `requestPage`, as `listMethodVersions` does, with no iterator. Every method read carries `copied_from`, the original a copy names, `null` on a method created afresh. `MethodErrorCode` names the copy's own codes, `organization_not_found` and `method_copy_too_large`, whose `limit` member is typed `MethodCopyLimit`. Both take a bare catalog id. See [method-copy.md](./method-copy.md).
157
+ - **Method copies:** `copyMethod`, `listMethodCopies`, on `/v1/methods/{id}/copy` and `/v1/methods/{id}/copies`. `copyMethod` makes a new, unpublished method in an organization the caller may change methods in, the method's own for a duplicate, holding the original's draft tree, its form inputs and its examples, the stored files they name copied into the destination, and answers a `MethodCopyResult` discriminated on `outcome`: `copied` with the copy as `method` and what was copied, or `refused` with the `references` of a bundle naming storage the destination cannot read or, in a duplicate, a file under the original's run files, which erasing the original would remove. The two arms carry different members, so the verdict is read by the copy itself rather than by `readVerdict`, which reads verdicts whose arms share one: an `outcome` off the union, a `copied` answer whose `method` is not an object and a `refused` one whose `references` is not an array are each an answer the SDK cannot read. Its body is closed, `destination_org_id` with `name` and `expected_draft_updated_at` when given, and its last argument takes an `idempotencyKey`, sent as the `Idempotency-Key` header through the same `requestRaw` option and the same `idempotencyKeyOf` check a start uses. `listMethodCopies` pages the original's `MethodCopyMarker` rows through `requestPage`, as `listMethodVersions` does, with no iterator. Every method read carries `copied_from`, the original a copy names, `null` on a method created afresh. `MethodErrorCode` names the copy's own codes, `pipelex.org.not_found` and `pipelex.catalog.copy_too_large`, whose `limit` member is typed `MethodCopyLimit`. Both take a bare catalog id. See [method-copy.md](./method-copy.md).
158
158
  - **Organizations:** `listMemberships`, `createOrganization`, `renameOrganization` (`PATCH`). Each membership carries the caller's `role_in_org`, an `OrgRole` (`admin`, `member` or `runner`) read open: a role a later platform adds arrives as its raw string, so a `switch` over it says what an unknown role does. The role is for display; what it may do is the platform's to decide, and a call refused to the caller's role is an `ApiResponseError`. The org "switch" is a WorkOS-session op (`/api/active-org`), not a `/v1` route — out of SDK scope.
159
159
  - **Organization branding:** `getBranding` (`GET /v1/branding`, any role, web session or API key) and `updateOrganizationBranding` (`PATCH /v1/organizations/{org_id}/branding`, an admin's write) answer the same `OrganizationBranding`: the organization's name, its light and dark colour sets (`BrandingColors`) and its square and wide logos (`LogoDescriptor`). The write's `OrganizationBrandingUpdate` sets a key given a value, clears a key given `null` and keeps a key left out; a refused logo or colour is named in `ApiResponseError.errors`, by slot or by dotted key, a logo the platform measured with a `LogoRefusalCode`. See [organization-branding.md](./organization-branding.md).
160
160
  - **Billing:** `getSubscription`, `listPlans`, `listInvoices`, `createCheckout`, `changePlan` (409 `conflict` ⇒ no subscription), `getBillingPortal` (409 `conflict` ⇒ no subscription).
@@ -162,7 +162,7 @@ The hosted catalog/account routes the webapp drives. All ride the same `{base}/v
162
162
  - **Onboarding:** `submitOnboarding`.
163
163
  - **Storage:** `resolveStorageUrl`, `resolveStorageUrls` (`POST /v1/resolve-storage-url/bulk` — the single route applied to a list of at most 100 references, one verdict per reference in request order, a refused reference a value on its item; a deployment without the route answers a `404` `ApiResponseError`), `upload` (JSON base64 — the multipart hop is browser→BFF only — answering the file's `uri`, its `filename` and the `file_id` of its record), `requestUploadGrant` (`POST /v1/upload/grant` — for a file described by its name, size and, from `describeUpload`, its SHA-256: either `stored: true`, the organization already holding those bytes, or a presigned, create-only `PUT` for the object, which the holder of the bytes sends with the standalone `uploadWithGrant`; both helpers are also exported from the browser-safe `@pipelex/sdk/upload` entry; a declared size over the cap is a `413` `ApiResponseError`). The higher-level `uploadFile` / `prepareInputs` preparation surface is built on top of `upload`; `prepareInputs` additionally composes one `pipeIo` call, which selects the pipe and answers with the input-form descriptor it walks, and takes the method as its tree, a `method_ref` or a `method_id` like every other method-taking operation — see [input-preparation.md](./input-preparation.md). The download twin — `locateArtifacts`, `collectArtifacts`, `resolveArtifacts`, `fetchArtifact`, `downloadArtifacts` — is built on top of `resolveStorageUrls` and `getRunResult`, never on the embedded `public_url`, and is documented on [artifact-download.md](./artifact-download.md); downloading is a separate explicit gesture, and no run call takes a `download` option.
164
164
  - **Runs list/detail/update:** `listRuns` (by `method_id`) returns **one page**, not the whole history — a `RunPage` (`{items, nextCursor}`) served from a time-ordered index, so its cost is the page size rather than the size of the history. Its rows are `RunHistoryItem`, **not** `PipelineRun`: the id, `status`, `created_at`, `finished_at`, `pipe_code`, for a failed run `error`, and `method_version` and `source_digest`, which version of the method the run ran (a number, `"draft"`, or `null` for an inline source) and the SHA-256 of the sources it sent as text, 64 hex digits with no prefix and `null` for a run whose `files` held bytes as Base64 or by hash — what a history row shows. `getRunDetail` also carries the run's `tree_digest` and `crate_fingerprint`, each `null` for a run that recorded none. The history lists the runs of every version and of the draft together, by the bare id. `method_version` and `source_digest` are optional on every run record (`RunPublic`, `RunRead`, `PipelineRun`, `RunDetail`, `RunHistoryItem`), since a run recorded before the platform recorded them carries neither. The run's organization, creator, method, workflow id and result location are not on the list; `getRunDetail` returns them. `iterateRuns` is the async iterator that follows the cursor for callers who genuinely want everything (`for await (const run of client.iterateRuns(id))`); it is deliberately not a `listAllRuns(): Promise<RunHistoryItem[]>`, because an all-at-once helper needs a page cap and a cap means silently returning a truncated list. Both accept `createdFrom` / `createdTo`, applied server-side as index key conditions — these are **instants, not days** (ISO-8601 with a UTC offset; a naive timestamp is a 400), because only the caller knows which timezone's day it means. Both also accept `createdBy: "me"`, sent as `created_by=me`, which keeps the runs the caller started (the platform resolves "me" from the authenticated identity and takes no other value), and `status`, a list of `RunStatus` sent as one repeated `status=` parameter per status, deduplicated into the platform's order, which keeps the runs in any of them: the caller's own latest unfinished run is `{ createdBy: "me", status: ["PENDING", "STARTED", "RUNNING"], limit: 1 }`. An empty `status` list would be sent as no parameter at all, which lists every status, so it is a `RequestArgumentError` before any request, as is a name that is no `RunStatus`. These two are filters applied after the index read, so **a filtered page can hold fewer runs than `limit`, or none, and still carry a `nextCursor`**: an empty page is not the end of the history, a `null` cursor is, and `iterateRuns` keeps following the cursor through one. It runs the same loop as `iterateMethods`, so it also stops on a cursor the server failed to advance and throws `PagingNotTerminatingError` past the same page ceiling. `getRunDetail` (`GET /v1/runs/{id}`) is the only call returning `mthds_contents` — what the run actually executed — and `inputs`; it is kept off the status read that pollers hit every few seconds and off the list, where the bundle would be multiplied by the page size. `updateRun` is the admin/manual status patch. The status/results/start lifecycle routes live in the run-lifecycle section above.
165
- - **An organization's files:** `listFiles` (`GET /v1/files`) returns **one page** of the organization's listed files, newest first, a `FilePage` (`{items, nextCursor}`) of `FileSummary` rows, each with the `uri` a run's inputs take as any address, its name, type, size, `kind` (`uploaded`, `generated` or `test`) and `origin`. Its `ListFilesQuery` follows `ListRunsQuery`'s camelCase and is sent under the API's names: `methodId`, `runId` and `createdBy` choose the method's, the run's (in the order of its addresses), one member's or the organization's list, and `q`, `kind`, `format`, `mediaType`, `createdFrom` and `createdTo` filter it, `format` and `mediaType` as one repeated parameter per value, an empty list of either refused with `RequestArgumentError` before any request since it would be sent as no filter at all. The filters apply after the index read, so a filtered page can be short or empty and still carry a `nextCursor`, which is bound to the list and filters that issued it; `iterateFiles` follows it through the loop the other iterators share. An upload is recorded as a method's test file by `kind: "test"` with its `method_id`, the pair `UploadInput`, `uploadFile`'s options and `prepareInputs`' nested `upload` all take, checked before any file is read; the upload route's refusals of a test file, for the caller's role or for its method, are a `TestFileRefusedError`. The rest of the files surface reads and acts on one file or a selection: `getFile` answers a `FileDetail`, the whole `FileRecord` with its `method_ids` and one page of its uses, whose cursor stays inside the answer as `uses.next_cursor`; `getFilesByUri` the records holding one address, the one a surface shows first; `renameFile` the record under the name the platform's one rule saved; and `linkFiles` one `FileLink` per requested file, in order, each a link or an `error`, a `200` either way, read through `withLinks` so an answer the SDK cannot read hides every link. A zip is two calls, `requestFileArchive` (answered `202`, `queued`) and `getFileArchive`, read until `ready` or `failed`, with no waiting helper; a selection past a cap is a `422` `file_archive_too_large` whose `limit` member `problemDocument` carries. `listRunFiles` (`GET /v1/runs/{id}/files`) answers a `RunFilesPage` of a run's inputs, outputs, intermediate files and web links, its `next_cursor` read as `nextCursor` and its `state` `listing` until every record is written. `copyFileIntoDraft` copies a file into a method's draft and answers a `200` discriminated on `blob`, `copied` or `already_held`, with the new draft token on its `method`. Each answer is checked on the member a caller branches on before it is handed back. See [files.md](./files.md).
165
+ - **An organization's files:** `listFiles` (`GET /v1/files`) returns **one page** of the organization's listed files, newest first, a `FilePage` (`{items, nextCursor}`) of `FileSummary` rows, each with the `uri` a run's inputs take as any address, its name, type, size, `kind` (`uploaded`, `generated` or `test`) and `origin`. Its `ListFilesQuery` follows `ListRunsQuery`'s camelCase and is sent under the API's names: `methodId`, `runId` and `createdBy` choose the method's, the run's (in the order of its addresses), one member's or the organization's list, and `q`, `kind`, `format`, `mediaType`, `createdFrom` and `createdTo` filter it, `format` and `mediaType` as one repeated parameter per value, an empty list of either refused with `RequestArgumentError` before any request since it would be sent as no filter at all. The filters apply after the index read, so a filtered page can be short or empty and still carry a `nextCursor`, which is bound to the list and filters that issued it; `iterateFiles` follows it through the loop the other iterators share. An upload is recorded as a method's test file by `kind: "test"` with its `method_id`, the pair `UploadInput`, `uploadFile`'s options and `prepareInputs`' nested `upload` all take, checked before any file is read; the upload route's refusals of a test file, for the caller's role or for its method, are a `TestFileRefusedError`. The rest of the files surface reads and acts on one file or a selection: `getFile` answers a `FileDetail`, the whole `FileRecord` with its `method_ids` and one page of its uses, whose cursor stays inside the answer as `uses.next_cursor`; `getFilesByUri` the records holding one address, the one a surface shows first; `renameFile` the record under the name the platform's one rule saved; and `linkFiles` one `FileLink` per requested file, in order, each a link or an `error`, a `200` either way, read through `withLinks` so an answer the SDK cannot read hides every link. A zip is two calls, `requestFileArchive` (answered `202`, `queued`) and `getFileArchive`, read until `ready` or `failed`, with no waiting helper; a selection past a cap is a `422` refusal whose problem carries a `limit` member. `listRunFiles` (`GET /v1/runs/{id}/files`) answers a `RunFilesPage` of a run's inputs, outputs, intermediate files and web links, its `next_cursor` read as `nextCursor` and its `state` `listing` until every record is written. `copyFileIntoDraft` copies a file into a method's draft and answers a `200` discriminated on `blob`, `copied` or `already_held`, with the new draft token on its `method`. Each answer is checked on the member a caller branches on before it is handed back. See [files.md](./files.md).
166
166
 
167
167
  ## Hook bundle (`dist-hooks/check.mjs`)
168
168
 
@@ -177,4 +177,4 @@ Two layers, split by whether a server is in the loop:
177
177
  - **Unit suites (`tests/*.test.ts`, `make test`):** mock the fetch boundary (`vi.spyOn(globalThis, "fetch")`) and pin the client's wire behavior — URLs, request bodies, status→error mapping, poll semantics. These run everywhere, including CI. The codegen-check suite is the exception that proves the rule: it has no server to mock, so it runs against **vendored real codegen output** under `tests/fixtures/codegen/` — artifacts and lock emitted by an actual `pipelex codegen types` run, committed beside the `.mthds` they came from. A hand-written stamp would only prove the port agrees with itself. The artifacts are vendored with a trailing `.txt` and their real names restored at load time, because `format:check`, `lint`, and `typecheck:test` all glob `tests/**/*.ts` — and a single reformatted byte would invalidate the very hash the fixture exists to pin.
178
178
  - **The command's case table (`tests/cli.test.ts`):** runs every case of `tests/fixtures/cli-cases.json` through the `pipelex-sdk` command in-process, in a fresh working directory, with `fetch` answering from the case's recorded routes, so the command runs on the real client. The table is written for the Python SDK's command of the same name to run too; [`cli.md`](cli.md) describes the format.
179
179
  - **The packed command (`make test-pack`):** packs the package with `npm pack` and runs `pipelex-sdk --help` and `--version` from the tarball through `npx`, from a directory outside the package. It needs the registry for the dependencies, so CI's `js (make all)` job runs it and `make test` does not.
180
- - **E2E suites (`tests/e2e/*.e2e.ts`, `make test-e2e`):** no mocks — they drive a **live pipelex-api server** (`PIPELEX_E2E_BASE_URL`, default `http://localhost:8081`) and verify the diagnostic-endpoint contract end-to-end with correct and malformed MTHDS bundles: clean content lints with zero diagnostics and formats to itself (idempotently), malformed content comes back as a 200 verdict (`diagnostics[]` on lint; `diagnostics[]` plus the content echoed back unchanged with `changed: false` on format; `is_valid: false` + `validation_errors[]` on validate), and malformed formatter options surface as a 422 `ApiResponseError`. The crate suite adds the case only a live server can settle: the `kind` / `target` vocabulary is a mirror of a Python `StrEnum` in another repo, so nothing but a real call proves the two still agree — and it cross-checks that `codegen`'s `crate_fingerprint` equals the `fingerprint` `resolve` returns for the same closure. It then runs `runCodegenCheck` over the artifacts and lock the server just emitted — verbatim first (current, with the lock header's fingerprint matching the response's), then mutated in memory once per drift category, for a `.ts` target and a `.py` one. The unit suite pins the port against vendored bytes; only this pins it against the stamp and lock bytes the server writes **today**, which is the pairing that matters when a consumer commits the server's artifacts and gates CI on the SDK's verdict about them. The same suite drives `pipeIo` across all three selectors: the artifacts under the resolved ref, their equality with `validate`'s views for the same closure, the `include_files` echo, `all_pipes` on a method with no entry pipe, a `method_ref` package's manifest entry pipe, the invalid arm, and the typed selection `422`. Its `method_id` case, like the `prepareInputs` suite's, runs only when `PIPELEX_E2E_METHOD_ID` names a stored method the key's organization owns, since only a hosted origin can resolve one, and is skipped otherwise. The distinct `.e2e.ts` suffix + `vitest.e2e.config.ts` keep them out of the default `vitest run`, so `make test` / CI never require a server. An unreachable server is an error, never a silent skip, and it is reported once: `make test-e2e` preflights the target with a single `curl` and bails with a one-line message, instead of letting every suite fail in `beforeAll` and print a socket-error dump each. The per-suite `beforeAll` guard stays as the backstop for a direct `npm run test:e2e` run. Both probe **`/v1/version`**, deliberately: a bare runner serves the origin-level `/health` and a hosted origin does not (it answers `/v1/health` instead), so `/v1/version` is the one unauthenticated route both answer — probing anything else reports a perfectly reachable hosted origin as down. The target itself is resolved once, in the Makefile, and exported along with `PIPELEX_API_KEY`, so the URL the preflight checks is always the URL the suites call: the shell environment (or a `make test-e2e PIPELEX_E2E_BASE_URL=...` override) wins, then `.env`, then `http://localhost:8081`. The run echoes the resolved target before it starts, since `.env` can now supply it invisibly. Note that a hosted origin cannot run the whole suite today — `lint` and `format` are not exposed there (gateway `403`), while the protocol routes and the `resolve` and `codegen` crate routes answer, and `pipe-io` answers only once the platform's proxy and the gateway list it — so a fully green run means a local runner. The upload-grant suite needs the same hosted platform: it requests a grant, sends a small PDF with `uploadWithGrant`, reads the bytes back equal through `fetchArtifact`, and proves the refusals the error mapping reads — the `412` of a used grant, the `403 SignatureDoesNotMatch` of a file one byte longer than declared, and the `413` of a declared size over the cap. It runs in Node, whose `fetch` sends no CORS preflight, so the bucket's CORS rule is proven by a page, not by it. The artifact suite is the mirror image: it uploads a file with `prepareInputs`, runs a pass-through and brings the file back down with `downloadArtifacts` over `working_memory`, bytes equal, so it needs a hosted platform carrying `POST /v1/pipe-io` (which `prepareInputs` reads), the upload, run-lifecycle and bulk resolve routes, and a key for it, and it fails honestly rather than skipping against a bare runner. The error-verdict suite proves that the verdict of a platform refusal is the one the server sent: an `ApiResponseError` takes each member from the problem document when it carries one and from the fallback table otherwise, and nothing on the error says which, so each case reads `retryable` and `error_domain` on `problemDocument` as well as on the error. A run of a method id the catalog does not hold (`404 not_found`) and a request body the platform's own validation refuses (`422 validation_failed`, here a storage reference over the resolve route's length limit) must carry both members, `input` and not retryable. A rejected API key is refused before the platform sees it, by the gateway's authorizer, with a bare `403 {"message":"Forbidden"}`, so its case asserts the fallback's `config`, not retryable, and a document without either member. It needs a hosted platform and a key for it, and fails honestly against a bare runner.
180
+ - **E2E suites (`tests/e2e/*.e2e.ts`, `make test-e2e`):** no mocks — they drive a **live pipelex-api server** (`PIPELEX_E2E_BASE_URL`, default `http://localhost:8081`) and verify the diagnostic-endpoint contract end-to-end with correct and malformed MTHDS bundles: clean content lints with zero diagnostics and formats to itself (idempotently), malformed content comes back as a 200 verdict (`diagnostics[]` on lint; `diagnostics[]` plus the content echoed back unchanged with `changed: false` on format; `is_valid: false` + `errors[]` on validate), and malformed formatter options surface as a 422 `ApiResponseError`. The crate suite adds the case only a live server can settle: the `kind` / `target` vocabulary is a mirror of a Python `StrEnum` in another repo, so nothing but a real call proves the two still agree — and it cross-checks that `codegen`'s `crate_fingerprint` equals the `fingerprint` `resolve` returns for the same closure. It then runs `runCodegenCheck` over the artifacts and lock the server just emitted — verbatim first (current, with the lock header's fingerprint matching the response's), then mutated in memory once per drift category, for a `.ts` target and a `.py` one. The unit suite pins the port against vendored bytes; only this pins it against the stamp and lock bytes the server writes **today**, which is the pairing that matters when a consumer commits the server's artifacts and gates CI on the SDK's verdict about them. The same suite drives `pipeIo` across all three selectors: the artifacts under the resolved ref, their equality with `validate`'s views for the same closure, the `include_files` echo, `all_pipes` on a method with no entry pipe, a `method_ref` package's manifest entry pipe, the invalid arm, and the typed selection `422`. Its `method_id` case, like the `prepareInputs` suite's, runs only when `PIPELEX_E2E_METHOD_ID` names a stored method the key's organization owns, since only a hosted origin can resolve one, and is skipped otherwise. The distinct `.e2e.ts` suffix + `vitest.e2e.config.ts` keep them out of the default `vitest run`, so `make test` / CI never require a server. An unreachable server is an error, never a silent skip, and it is reported once: `make test-e2e` preflights the target with a single `curl` and bails with a one-line message, instead of letting every suite fail in `beforeAll` and print a socket-error dump each. The per-suite `beforeAll` guard stays as the backstop for a direct `npm run test:e2e` run. Both probe **`/v1/version`**, deliberately: a bare runner serves the origin-level `/health` and a hosted origin does not (it answers `/v1/health` instead), so `/v1/version` is the one unauthenticated route both answer — probing anything else reports a perfectly reachable hosted origin as down. The target itself is resolved once, in the Makefile, and exported along with `PIPELEX_API_KEY`, so the URL the preflight checks is always the URL the suites call: the shell environment (or a `make test-e2e PIPELEX_E2E_BASE_URL=...` override) wins, then `.env`, then `http://localhost:8081`. The run echoes the resolved target before it starts, since `.env` can now supply it invisibly. Note that a hosted origin cannot run the whole suite today — `lint` and `format` are not exposed there (gateway `403`), while the protocol routes and the `resolve` and `codegen` crate routes answer, and `pipe-io` answers only once the platform's proxy and the gateway list it — so a fully green run means a local runner. The upload-grant suite needs the same hosted platform: it requests a grant, sends a small PDF with `uploadWithGrant`, reads the bytes back equal through `fetchArtifact`, and proves the refusals the error mapping reads — the `412` of a used grant, the `403 SignatureDoesNotMatch` of a file one byte longer than declared, and the `413` of a declared size over the cap. It runs in Node, whose `fetch` sends no CORS preflight, so the bucket's CORS rule is proven by a page, not by it. The artifact suite is the mirror image: it uploads a file with `prepareInputs`, runs a pass-through and brings the file back down with `downloadArtifacts` over `working_memory`, bytes equal, so it needs a hosted platform carrying `POST /v1/pipe-io` (which `prepareInputs` reads), the upload, run-lifecycle and bulk resolve routes, and a key for it, and it fails honestly rather than skipping against a bare runner. The problems suite proves that the problem on a platform refusal is the one the server sent: an `ApiResponseError` reads a problem document as received, and builds one from the status by the edge's rules when the body is none, so each case asserts which it was against `responseBody`. A run of a method id the catalog does not hold and a request body the platform's own validation refuses are the platform's problems, the second's items naming the field. A rejected API key is refused before the platform sees it, by the gateway's authorizer, with a bare `403 {"message":"Forbidden"}`, so its problem is the SDK's reading of the status, `auth.invalid_credential`. It needs a hosted platform and a key for it, and fails honestly against a bare runner.
@@ -82,7 +82,7 @@ Resolves the reference fresh and returns the object store's response as a bounde
82
82
 
83
83
  It is **header-neutral**: the status, status text and headers are the store's own. The one change is that a `Content-Encoding` the fetch already decoded is dropped with the encoded `Content-Length`, since the body handed on is the decoded bytes. A proxy relaying it therefore owns the response hygiene — `X-Content-Type-Options: nosniff`, a sandboxing CSP on the asset response, a controlled `Content-Disposition`, private caching — and must set them itself, as the example does.
84
84
 
85
- Only a `2xx` is returned. Anything else throws an `ArtifactFetchError` whose `code` says why, in the same closed vocabulary the download verdict uses per item: the route's `invalid_storage_uri` / `forbidden`, then `unsupported_url` (not a URL, not http(s), or carrying credentials), `plain_http_refused`, `redirect_refused`, `store_refused` (a 401/403 from the store — the link is freshly minted, so this is the store refusing a fresh signature, not an expired link), `not_found` (404/410), `store_error` (any other non-2xx, with `status`), `too_large`, `timeout` and `network`. The caller's abort propagates as-is, and the body reader is cancelled with it. `downloadArtifacts` and a proxy share this boundary: the download is the same fetch followed by a write.
85
+ Only a `2xx` is returned. Anything else throws an `ArtifactFetchError` whose `reason` says why, beside its problem ([errors.md](./errors.md#each-classs-problem)), in the same closed vocabulary the download verdict uses per item: the route's `invalid_storage_uri` / `forbidden`, then `unsupported_url` (not a URL, not http(s), or carrying credentials), `plain_http_refused`, `redirect_refused`, `store_refused` (a 401/403 from the store — the link is freshly minted, so this is the store refusing a fresh signature, not an expired link), `not_found` (404/410), `store_error` (any other non-2xx, with `status`), `too_large`, `timeout` and `network`. Its problem follows its `reason` and `status`: a `store_error` or a `store_refused` is `runtime.unavailable`, `retry` `yes`, when its `status` is a `408`, a `429` or a `5xx` other than `501`, `runtime.unsupported` for a `501`, and `client.response_unreadable` naming the status, `retry` `no`, for any other status, so a `400`, a `405`, a `401` or a `403` is not worth asking again; `timeout` and `network` are `runtime.unavailable`, `retry` `yes`. The caller's abort propagates as-is, and the body reader is cancelled with it. `downloadArtifacts` and a proxy share this boundary: the download is the same fetch followed by a write.
86
86
 
87
87
  ### `downloadArtifacts({ run_id | results, dir, … })` — the files on disk
88
88
 
@@ -156,17 +156,17 @@ Files are **never overwritten**: a name already on disk gets a numeric suffix (`
156
156
 
157
157
  `artifacts.length` is the count of references walked, errors included. An empty walk over a present scope — an output that references no stored file — is a verdict with empty lists and `all_saved: true`, not an error, and it touches neither the network nor the disk. `found_at` is the reference's paths in the walked scope, exactly as `locateArtifacts` reports them, and `content_type` is the platform's guess from the reference, known before the fetch; both are on both arms, so an item that was not saved still says which field it would have filled.
158
158
 
159
- Per-item `error.code` is the fetch vocabulary above plus the download's own: `resolve_failed` (an expired link could not be re-resolved, for a reason that is not the credential), `total_limit_exceeded` (the item that would take the call past `maxTotalBytes`, and, once the files already saved leave no room, every item not yet started, whose `detail` says it was skipped; an item refused only because files still in flight hold the room stops nothing else, since one of them may yet fail and give it back), `write_failed` (the file could not be created, written or closed) and `aborted` (in flight or not yet started when the signal fired).
159
+ Per-item `error.code` is the fetch vocabulary above plus the download's own: `resolve_failed` (an expired link could not be re-resolved, for a reason that is not the credential), `total_limit_exceeded` (the item that would take the call past `maxTotalBytes`, and, once the files already saved leave no room, every item not yet started, whose `detail` says it was skipped; an item refused only because files still in flight hold the room stops nothing else, since one of them may yet fail and give it back), `write_failed` (the file could not be created, written or closed), `aborted` (in flight or not yet started when the signal fired) and `unknown` (a failure the SDK cannot place, never one a retry can pass).
160
160
 
161
161
  **What it throws.** Only conditions with no verdict, all typed:
162
162
 
163
- - `RunStillRunningError` (with the retry hint) or `RunFailedError` (with the run's status and its stored error report as `error`) — a `run_id` naming a run that has not completed;
163
+ - `RunStillRunningError` (with the retry hint) or `RunFailedError` (carrying the run's status, the `run.failed` problem as `problem`, and the run's own problem as `runError`) — a `run_id` naming a run that has not completed;
164
164
  - `ScopeUnavailableError` — the requested scope's artifact is `null` or missing from the body (`scope` and `runId` on the error). Reading by `run_id`, a null `main_stuff` is already `MissingMainStuffError` from `getRunResult`;
165
- - `ArtifactAuthenticationError` — the resolve route refused the credential (`401` / `403`), on the first resolve or on a re-resolve part-way through. It carries `verdict`, the result as it stood: the refusal stops the workers taking new items but lets the fetches already running finish, since they are on presigned links that do not carry the credential, so every file saved is real and listed and the rest are marked `aborted` with a detail naming the credential failure. Its `verdict` member is the download's result so far, not the error's: that is `retryable` and `errorDomain`, `config` and not retryable;
166
- - `ArtifactOperationError` — outside Node, an unusable `dir`, both selectors or neither, an unknown `scope`, or nonsense bounds; its verdict is `config` for the first two, since the environment must change, and `input` for the rest, the caller's arguments;
165
+ - `ArtifactAuthenticationError` — the resolve route refused the credential (`401` / `403`), on the first resolve or on a re-resolve part-way through. It carries `verdict`, the result as it stood: the refusal stops the workers taking new items but lets the fetches already running finish, since they are on presigned links that do not carry the credential, so every file saved is real and listed and the rest are marked `aborted` with a detail naming the credential failure. Its `verdict` member is the download's result so far, not the error's: that is its `problem`, the server's own when the resolve route answered a problem document, and otherwise read by its status: `auth.missing_credential` for a bare `401` and `auth.invalid_credential` for a bare `403`;
166
+ - `ArtifactOperationError` — outside Node, an unusable `dir`, both selectors or neither, an unknown `scope`, or nonsense bounds; its problem is `runtime.unsupported` outside Node and `client.write_failed` for an unusable `dir`, since the environment must change, and `client.argument_invalid` for the rest, the caller's arguments;
167
167
  - and the transport and lifecycle errors of the reads it makes, unchanged: `ApiResponseError` for a deployment without the bulk route, `RunLifecycleUnavailableError` for a bare runner asked by `run_id`, `ApiUnreachableError`.
168
168
 
169
- Everything else that can go wrong with one reference is that reference's `error`. Each thrown error carries the verdict every SDK error does, `retryable` and `errorDomain`, listed per class on [errors.md](./errors.md#each-classs-verdict); a per-reference `error` is a value with a `code`, and carries none.
169
+ Everything else that can go wrong with one reference is that reference's `error`. Each thrown error carries its problem, as every SDK error does, listed per class on [errors.md](./errors.md#each-classs-problem); a per-reference `error` is a value with a `code`, and carries none.
170
170
 
171
171
  **Options and defaults.**
172
172
 
package/docs/cli.md CHANGED
@@ -80,7 +80,7 @@ Every error goes to stderr, its first line `Error: <sentence>` and the lines und
80
80
  - **An unreachable API**: `Error: could not reach the Pipelex API at <base URL> (<code>).`
81
81
  - **A failed upload** of a file the inputs name: `Error: the upload of "<file>" failed (status <n>).`, the status left out when no answer came back, then `Reason: <the SDK's message>`, which says what the status means for an upload (a file past the size limit, a deployment without file upload, a credential refused), and `Next step:` and `Request id:` when the API's answer carries them. It covers the SDK's `RejectedAssetError`, `UploadAuthenticationError`, `UnsupportedUploadCapabilityError` and `UploadTransportError`, each of which names the file it was sending. It exits `1`, and no run starts.
82
82
  - **A wait that fails once the run exists**, on an unreachable API, a refused poll or an answer the command cannot read: the failure's own lines, then `Run <id> may still be going on the server, so do not start it again.`, since the run was started and nothing says it ended. A run that ended in failure (`RunFailedError`) and a run that ended without its main output (`MissingMainStuffError`) get no such line. It exits `1`.
83
- - **A start whose outcome is unknown**: once the command has reached a request that may create a run, the start or a bare runner's blocking execute, and before the API names the run, a failure the SDK reads as one that may have started a run, its error's `runMayHaveStarted` ([`errors.md`](errors.md#a-failed-run-start--runmayhavestarted)), adds `A run may have started on the server without the command learning of it, so check before starting it again.` under its own lines. That is an answer the command cannot read, a `408`, which a proxy in front of the platform may answer for a request it forwarded, any `5xx` but a `501` and the platform's `503` with the code `pipeline_start_unavailable`, which says the runner was never reached, a request the gateway cut off, and a transport failure that does not prove the request never left, such as an answer lost to a time limit or to a connection that closed once the request had left. Timing plays no part: a `503` or `504` that came back at once reads the same as one at the gateway's limit, since the platform can answer a `503` after the runner has started the run. Any other answer from the API, and a transport failure that proves the request never left, add nothing, since no run was created. Such a failure names a step of the connection's set-up, which no request follows: an unknown host (`ENOTFOUND`, `EAI_AGAIN`), a refused connection (`ECONNREFUSED`, from every address the host has included), no route to the host or to the network (`EHOSTUNREACH`, `ENETUNREACH`, from the `connect` call), the connection given up by the system's or undici's own connect time limit (`ETIMEDOUT` from `connect`, `UND_ERR_CONNECT_TIMEOUT`), a server certificate the TLS handshake refused (`CERT_HAS_EXPIRED`, `DEPTH_ZERO_SELF_SIGNED_CERT`, `ERR_TLS_CERT_ALTNAME_INVALID` and the other verdicts of the certificate's check Node names, then `UNSPECIFIED`, its code for every verdict it does not name, such as an unhandled critical extension). Both commands read the same failures the same way, each through its SDK's member, so where one command cannot tell whether the request had left, both take the reading that a run may exist: a connection dropped during the TLS handshake, which Node reports as the reset (`ECONNRESET`) of a connection dropped once the request had left, and another TLS failure. A host that drops packets is a connection that never comes, and each command meets its transport's connect time limit first, which proves nothing was sent: here undici's own, ten seconds for the TCP connect and the TLS handshake together (`UND_ERR_CONNECT_TIMEOUT`), which runs out before the SDK's own time limit (`ABORT_TIMEOUT`), at least 30 seconds on every request the command sends; in the Python command, httpx's connect timeout, which the one time limit the Python client passes bounds. Fetch cannot say whether the SDK's own time limit ran out before the connection was made or once the request had left, so the command reads it as an answer lost to a time limit; with Node's own dispatcher it is one, since undici gives up a connection that does not come sooner. It exits `1`.
83
+ - **A start whose outcome is unknown**: once the command has reached a request that may create a run, the start or a bare runner's blocking execute, and before the API names the run, a failure the SDK reads as one that may have started a run, its error's problem having the `retry` `outcome_unknown` ([`errors.md`](errors.md#a-run-that-may-have-started--outcome_unknown)), adds `A run may have started on the server without the command learning of it, so check before starting it again.` under its own lines. That is a problem document from the platform whose own `retry` says so, an answer the command cannot read, and, from the hosted edge, a `408`, which a proxy in front of the platform may answer for a request it forwarded, any `5xx` but a `501`, a request the gateway cut off, and a transport failure that does not prove the request never left, such as an answer lost to a time limit or to a connection that closed once the request had left. Timing plays no part: a `503` or `504` that came back at once reads the same as one at the gateway's limit, since the gateway can answer one after the runner has started the run. Any other answer from the API, and a transport failure that proves the request never left, add nothing, since no run was created. Such a failure names a step of the connection's set-up, which no request follows: an unknown host (`ENOTFOUND`, `EAI_AGAIN`), a refused connection (`ECONNREFUSED`, from every address the host has included), no route to the host or to the network (`EHOSTUNREACH`, `ENETUNREACH`, from the `connect` call), the connection given up by the system's or undici's own connect time limit (`ETIMEDOUT` from `connect`, `UND_ERR_CONNECT_TIMEOUT`), a server certificate the TLS handshake refused (`CERT_HAS_EXPIRED`, `DEPTH_ZERO_SELF_SIGNED_CERT`, `ERR_TLS_CERT_ALTNAME_INVALID` and the other verdicts of the certificate's check Node names, then `UNSPECIFIED`, its code for every verdict it does not name, such as an unhandled critical extension). Both commands read the same failures the same way, each through its SDK's member, so where one command cannot tell whether the request had left, both take the reading that a run may exist: a connection dropped during the TLS handshake, which Node reports as the reset (`ECONNRESET`) of a connection dropped once the request had left, and another TLS failure. A host that drops packets is a connection that never comes, and each command meets its transport's connect time limit first, which proves nothing was sent: here undici's own, ten seconds for the TCP connect and the TLS handshake together (`UND_ERR_CONNECT_TIMEOUT`), which runs out before the SDK's own time limit (`ABORT_TIMEOUT`), at least 30 seconds on every request the command sends; in the Python command, httpx's connect timeout, which the one time limit the Python client passes bounds. Fetch cannot say whether the SDK's own time limit ran out before the connection was made or once the request had left, so the command reads it as an answer lost to a time limit; with Node's own dispatcher it is one, since undici gives up a connection that does not come sooner. It exits `1`.
84
84
 
85
85
  The checks that need no request come first, so a mistake costs nothing. `run` checks, in this order, its flags, `--pipe`'s form, the method (reading a path from disk), the inputs, the poll interval, the key and the base URL. `script` checks its flags, `--pipe`'s form, a control character in any value, the method's form, `--name`, `--dir`, the target file when its name is already known, the key and the base URL. The flags are read in command-line order and the first problem is the one reported: `--help` or `-h` anywhere before the `--` that ends the options asks for the help whatever else is there, and an argument that is exactly one of the two is never taken as a flag's value, so `run --method --help` prints the help, and so does `run --pipe -- --help`, where the `--` is `--pipe`'s value and ends nothing; an unknown option or a positional argument is refused; a flag given twice is refused rather than the last one winning; a string flag needs a non-empty value, given as the next argument or after `=`, and a next argument starting with `-` is not taken as one, except `-` alone, which `--inputs` reads as stdin. Before a subcommand, `--help` prints the command's help whatever follows it, as a subcommand's does, while `--version` must stand alone: `pipelex-sdk --version run …` is a usage error, since printing the version would leave the rest of the command line unanswered.
86
86
 
@@ -6,7 +6,7 @@ Three routes project a **closure** of MTHDS files into the artifacts downstream
6
6
  >
7
7
  > Measured 2026-08-23 against `api.pipelex.com` (`pipelex-hosted@0.10.1`) with a real API key: an empty body to either route comes back as an RFC 7807 `422` naming the missing fields — a request-shape verdict only a route that reached the service can produce — and a real closure comes back as a `200` carrying the artifacts. `api-dev.pipelex.com` has served them since 2026-08-13. Use a key when re-measuring: unauthenticated, every path answers `401` whether or not it is allowlisted, so a keyless probe cannot tell the two states apart.
8
8
  >
9
- > **`pipe-io` is newer.** A runner serves it from `pipelex-api` v0.33.0, and types its pipe-selection refusals `EntryPipeNotFoundError` and `EntryPipeAmbiguousError` from v0.33.1, which is what `prepareInputs` branches on (see [What throws](#what-throws)); v0.33.0 typed them as the generic `ValidationError`. A hosted origin serves the route once the platform's tooling proxy and the gateway's allowlist both list it, and until then it answers the gateway's `403 {"message":"Forbidden"}`. `prepareInputs` reads this route, so it needs an origin that serves it.
9
+ > **`pipe-io` is newer.** A runner serves it from `pipelex-api` v0.33.0, and codes its pipe-selection refusals `request.pipe_not_found`, `request.pipe_unspecified` and `request.pipe_ambiguous` from v0.90.0, which is what `prepareInputs` branches on (see [What throws](#what-throws)). A hosted origin serves the route once the platform's tooling proxy and the gateway's allowlist both list it, and until then it answers the gateway's `403 {"message":"Forbidden"}`. `prepareInputs` reads this route, so it needs an origin that serves it.
10
10
  >
11
11
  > **`lint` / `format` are the exception and answer `403` on both origins.** That blocks nothing, because linting and formatting `.mthds` are toolchain capabilities rather than hosted ones: `plxt` carries both, and the post-edit hook this repo builds (`npm run build:hook`, vendored into `pipelex-plugins`) runs them offline through `@pipelex/tools-wasm` with no credentials — from the published package, `client.lint` / `client.format` are the documented fallback, against a runner. Exposing them on the hosted origins is a known, non-critical item on the platform's list, tracked in the workspace ledger as L-260929-b58f26.
12
12
 
@@ -45,13 +45,13 @@ When the request carries a `read_scope`, the blob route is asked under it, as `P
45
45
 
46
46
  **Stopping a call.** Each route takes a second argument, `CrateRouteOptions`, whose `signal` is read before each step a `directory`, `tree` or `contents` costs — the version handshake, the directory's listing, the blob route, each read and each upload of a missing blob — and before the request itself, and is handed to each of those steps, so an abort stops the handshake, the blob route, a read or an upload already under way. Once it has aborted, nothing further starts and the call throws the abort; the route's own request, once sent, runs to its end, since the routes take no transport options (a static route's budget is the client's own). `prepareInputs` passes its `signal` here, so an abort during the handshake stops it and leaves the blob route, the uploads and `POST /v1/pipe-io` unasked.
47
47
 
48
- When the engine can attribute a diagnostic to a file, the `validation_errors[]` item's `source` names it by its tree path. Treat that attribution as best-effort, not a guarantee — which is why `ValidationErrorItem.source` is typed optional and why the `resolve` sample below reads `err.source ?? "?"`. Some arms don't populate it: graph-level `dry_run` and `pipe_factory` items have no single owning file, and a `main_pipe` naming a nonexistent pipe currently reports its provenance in the message prose while leaving the structured field unset.
48
+ When the engine can attribute a fault to a file, the `errors[]` problem item's `location.file` names it by its tree path. Treat that attribution as best-effort, not a guarantee — which is why `location` and its `file` are optional and why the `resolve` sample below reads `item.location?.file ?? "?"`. Some items don't carry it: a fault of the graph as a whole has no single owning file, and a `main_pipe` naming a nonexistent pipe may report its provenance in the `detail` while leaving the location unset.
49
49
 
50
50
  `dependencies` carries the method packages the tree calls by address, each a `files` map keyed by its address, posted as given. They are loaded beside the method, never merged into it, so a cross-package call resolves without the server fetching anything. A call into a package nothing shipped makes the closure invalid, a `200` verdict whose `unresolved_package_dependency` item names the package; `dependencies` without the tree is the server's `422`.
51
51
 
52
52
  An **address-form** `method_ref` (`github.com/<owner>/<repo>[/<selector>][@<tag>]`) is resolved by the server through the same fetch path as a `method_ref` run: the package's whole tree is read from the commit at the tag and loaded as a sent tree is, with the packages it ships under its own `.mthds/methods/` beside it. Any non-address reference stays reserved and answers `501`.
53
53
 
54
- `method_id` is the hosted platform's selector, and it is a **pass-through**: nothing is expanded client-side — the platform resolves the id against the org's catalog and injects the stored tree as `files` before the runner sees the request. A bare `mt_…` names the method's latest published version, `mt_…@<n>` the fixed version `n` and `mt_…@draft` its draft, and the suffix rides the string untouched. It is meaningless against a bare runner (no catalog). An unknown or foreign-org id is a `404` (indistinguishable by design); a bare id of a method never published is a `409` `method_not_published`, whose detail names `mt_…@draft`; a version the method never published is a `404` `method_version_not_found`; a malformed suffix is a `422`; a stored method with no MTHDS source is a `422`.
54
+ `method_id` is the hosted platform's selector, and it is a **pass-through**: nothing is expanded client-side — the platform resolves the id against the org's catalog and injects the stored tree as `files` before the runner sees the request. A bare `mt_…` names the method's latest published version, `mt_…@<n>` the fixed version `n` and `mt_…@draft` its draft, and the suffix rides the string untouched. It is meaningless against a bare runner (no catalog). An unknown or foreign-org id is a `404` `pipelex.catalog.not_found` (indistinguishable by design), as is a version the method never published; a bare id of a method never published is a `409` `pipelex.catalog.not_published`, whose detail names `mt_…@draft`; a malformed suffix is a `422`; a stored method with no MTHDS source is a `422`.
55
55
 
56
56
  `read_scope` is a Pipelex-API extension naming the scope the request reads the method under: a hash entry of the tree resolves in that scope's blob store. A caller of a bare runner may send it; **the hosted platform stamps it from the caller's organization and drops one the caller sent**, so a hosted caller never needs it.
57
57
 
@@ -71,7 +71,7 @@ Resolution is a first-class language operation alongside validation: the closure
71
71
  const result = await client.resolve({ files: { "method.mthds": src } });
72
72
 
73
73
  if (!result.is_valid) {
74
- for (const err of result.validation_errors) console.error(`${err.source ?? "?"}: ${err.message}`);
74
+ for (const item of result.errors) console.error(`${item.location?.file ?? "?"}: ${item.detail}`);
75
75
  return;
76
76
  }
77
77
 
@@ -132,7 +132,7 @@ The request is the shared envelope plus three optional fields, posted verbatim:
132
132
  | `all_pipes` | `false` | Describe every pipe the closure loads instead of the selected one. |
133
133
  | `include_files` | `false` | Echo the text of the method's own `.mthds` bundles as `files`, a map from tree path to text. |
134
134
 
135
- **Selection.** The request's `pipe_ref`; else the `main_pipe` of the `METHODS.toml` at the tree's root, sent in the tree or fetched with a package; else the closure's `main_pipe`, when exactly one domain declares one. A `pipe_ref` that names no pipe, and — without `all_pipes` — a chain that finds no entry pipe or several, are refused with a `422` whose `errorType` names the refusal (see [What throws](#what-throws)). The server does not refuse a bare code yet: a bare ref that matches one pipe resolves across domains, and the answer reports the qualified ref. With `all_pipes: true` the route never refuses for want of an entry pipe, so a method that declares none is still describable.
135
+ **Selection.** The request's `pipe_ref`; else the `main_pipe` of the `METHODS.toml` at the tree's root, sent in the tree or fetched with a package; else the closure's `main_pipe`, when exactly one domain declares one. A `pipe_ref` that names no pipe, and — without `all_pipes` — a chain that finds no entry pipe or several, are refused with a `422` whose `code` names the refusal (see [What throws](#what-throws)). The server does not refuse a bare code yet: a bare ref that matches one pipe resolves across domains, and the answer reports the qualified ref. With `all_pipes: true` the route never refuses for want of an entry pipe, so a method that declares none is still describable.
136
136
 
137
137
  The valid arm (`PipeIOValidReport`):
138
138
 
@@ -146,25 +146,24 @@ The valid arm (`PipeIOValidReport`):
146
146
 
147
147
  ## The response is a verdict, not a payload
148
148
 
149
- All three routes return a **discriminated 200**, the same discipline as `validate`: an unresolvable closure is the _successful product_ of the call (the request was well-formed; the library was not), so it rides a 200 with `is_valid: false` and the shared `CrateInvalidReport`, carrying the same structured `validation_errors[]` as `validate`'s invalid arm.
149
+ All three routes return a **discriminated 200**, the same discipline as `validate`: an unresolvable closure is the _successful product_ of the call (the request was well-formed; the library was not), so it rides a 200 with `is_valid: false` and the shared `CrateInvalidReport`, carrying the same `errors[]` of problem items as `validate`'s invalid arm, each read member by member ([errors.md](./errors.md#a-verdicts-problem-items)).
150
150
 
151
151
  **Branch on `is_valid` before reading the arm.** A consumer that only catches throws will render a success over an unusable result, because nothing threw.
152
152
 
153
153
  ## What throws
154
154
 
155
- Only a **no-verdict** condition, as the typed `ApiResponseError` — branch on its `status` and `errorType`, never on its message:
155
+ Only a **no-verdict** condition, as the typed `ApiResponseError` — branch on its problem's `code`, never on its status or its message:
156
156
 
157
157
  | Status | Cause |
158
158
  | ------------- | --------------------------------------------------------------------------------------------------------- |
159
- | `422` | Request shape: no closure selector or more than one, `dependencies` without the tree, an unknown `kind`/`target`, a `pipe_ref` on `kind: "types"`, a fetched package with no `.mthds` file, a stored method with no MTHDS source, a `method_id` whose version suffix is malformed. |
160
- | `422` | The tree's refusals, as on `validate`: `InvalidMethodFiles` for a key or an entry the tree's rule refuses (a `files` that is not a map among them), `MissingBlobs` for a hash the server does not hold, `MethodTreeTooLarge` past the tree's limits, `InvalidBundle` for a tree holding no `.mthds` bundle. |
161
- | `422` | On `pipeIo`, a pipe selection the route refuses: `errorType` `EntryPipeNotFoundError` for a `pipe_ref` that names no pipe or a method with no entry pipe, `EntryPipeAmbiguousError` for a code that matches pipes in several domains or several `main_pipe` declarations. The `serverMessage` names the candidates where there are some. |
162
- | `404` | Unknown or foreign-org `method_id` (indistinguishable by design); no package at a `method_ref` address. |
163
- | `404` | `code` `method_version_not_found`: a `mt_…@<n>` naming a version the method never published. |
164
- | `409` | `code` `method_not_published`: a bare `method_id` of a method never published; `mt_…@draft` reaches its draft. |
159
+ | `422` | Request shape, `request.invalid` with its `location.field`: no closure selector or more than one, `dependencies` without the tree, an unknown `kind`/`target`, a `pipe_ref` on `kind: "types"`, a `method_id` whose version suffix is malformed; and a fetched package with no `.mthds` file or a stored method with no MTHDS source. |
160
+ | `422` | The tree's refusals, as on `validate`: `method.package_invalid` for a key, an entry or a size the tree's rule refuses (a `files` that is not a map among them), `request.blobs_missing` for a hash the server does not hold, and a tree holding no `.mthds` bundle. |
161
+ | `422` | On `pipeIo`, a pipe selection the route refuses: `request.pipe_not_found` for a `pipe_ref` that names no pipe, `request.pipe_unspecified` for a method with no entry pipe, `request.pipe_ambiguous` for a code that matches pipes in several domains or several `main_pipe` declarations. Its `params` name the candidates where there are some. |
162
+ | `404` | `pipelex.catalog.not_found`: an unknown or foreign-org `method_id` (indistinguishable by design), or a `mt_…@<n>` naming a version the method never published; no package at a `method_ref` address. |
163
+ | `409` | `pipelex.catalog.not_published`: a bare `method_id` of a method never published; `mt_…@draft` reaches its draft. |
165
164
  | `501` | Registry-form `method_ref` — reserved, not implemented (the address form resolves). |
166
165
  | `401` / `403` | Auth, or on a hosted origin a route the gateway does not list yet (`403 {"message":"Forbidden"}`). |
167
- | `403` | `CustomCodeRequiresSandbox` for a tree holding a `.py` file on a deployment that is not sandbox-hosted, `MethodStructuresRefusedError` for one whose Python declares a structure class on one that is; `ReadScopeForbidden` for a `read_scope` a `jwt` caller may not read under. |
166
+ | `403` | A tree holding a `.py` file on a deployment that is not sandbox-hosted, one whose Python declares a structure class on one that is, or a `read_scope` a `jwt` caller may not read under. |
168
167
  | `502` | On a hosted origin, a cold `method_ref` clone that outran the gateway's 30-second cap; a retry clears it. |
169
168
  | `5xx` | Server fault, including on `pipeIo` a pipe whose artifacts cannot be derived. |
170
169