# JSON Form

> How the core types and sealed cases are written as plain JSON.

Source: https://docs.pyrula.io/primitives/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

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:

```python
@sealed
class OrderError: ...

@case
class OutOfStock(OrderError):
    sku: str

@step
def 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

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 `Exception` or `BaseException`, you get a `RecordedError` with the original `type_path` and message.
- Under a narrower type, writing fails, and a `@step` fails with `StepResultTypeError` before 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`:

```python
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

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`.
