JSON Form
Every value type has one plain JSON form. The journal, to_jsonable, Pydantic (dump_json, model_dump(mode="json"), FastAPI) and the generated JSON Schema all agree on it.
| Value | JSON |
|---|---|
Ok(v) / Err(e) | {"ok": v} / {"err": e} |
Some(v) / Nothing | v / null |
Valid(v) / Invalid([e, ...]) | {"valid": v} / {"invalid": [e, ...]} (never empty) |
Success(v) / Failure(exc) | {"success": v} / {"failure": exc} |
IList, NonEmptyList, IntList, FloatList, ISet, IntSet | a list |
IMap | an object |
| an exception | {"type": "module:Qualname", "message": "...", "args": [...], "attrs": {...}} |
a @sealed case | {"CaseName": {field: value, ...}} |
Some(None) has no form because it would read back as Nothing; writing one is an error. A field declared init=False is part of a case’s form.
Type optional fields as Option[T] and default them to Nothing. With = None the attribute stays a raw None, because Pydantic does not validate defaults.
Reading it back
Section titled “Reading it back”An unannotated @step result comes back as plain JSON. A persistent store writes it to JSON; the in-memory store keeps the live value, so exceptions nested inside a core type, bytes, and an IMap with non-string keys can fail or change shape only in a persistent store. Tool, sub-agent and model results are always written to JSON, on every store.
Annotate the return type and the value comes back as the declared type, on first run and on replay:
@sealedclass OrderError: ...
@caseclass OutOfStock(OrderError): sku: str
@stepdef reserve(sku: str) -> Either[int, OrderError]: return Err(OutOfStock(sku))
match await reserve("x"): case Err(OutOfStock(sku=sku)): ...On a @step, a sealed type whose cases share a name raises TypeError at decoration. A @tool return annotation or @agent output_type is checked on first use instead. A sealed case that holds instance state and is not a dataclass has no JSON form and fails on first write.
Exceptions
Section titled “Exceptions”An exception is rebuilt as its class only if the class is already loaded, subclasses the declared error type, and rebuilds from args and attrs to an equal instance. Otherwise:
- Under a declared
ExceptionorBaseException, you get aRecordedErrorwith the originaltype_pathand message. - Under a narrower type, writing fails, and a
@stepfails withStepResultTypeErrorbefore anything is journaled as done. - Under
Any, the value is the payload dict.
A module imported only inside a step body is rebuilt on first run but is a RecordedError on a fresh replay worker, so match on type_path:
match await attempt(): case Failure(RecordedError(type_path="botocore.exceptions:ClientError")): ... case Failure(exc): ...A FastAPI body typed Try[...] or Either[..., Exception] imports the exception class named in client JSON and runs its constructor. Type the error side of a request body as a @case error instead.
Sealed cases
Section titled “Sealed cases”A case is named by its class name, so two cases of one sealed type cannot share a name. Exception and Pydantic-model hierarchies keep their own forms; a Pydantic model or dataclass cannot be a case of a plain @sealed type, so declare it with @case.