# Add a contract

**Goal:** module A answers a synchronous question or runs a command for module B, through a versioned contract such as `stats.quota.v1`, with a typed stub that B imports instead of A's code.

**Use it** when the caller needs an answer now: a quota check, a lookup, a command that must succeed or fail before the caller goes on.

**Do not use it** when the caller only needs to tell others that something happened (publish an [event](<https://sandloom.dev/docs/modules/between/add-event/index.md>)), or to read many rows of another module for a list page (build a [projection](<https://sandloom.dev/docs/modules/between/add-projection/index.md>)). A contract call runs in the provider's own unit of work, never in the caller's transaction: a command is not atomic with the caller's write. A multi-step change with undo is a [saga](<https://sandloom.dev/docs/modules/between/add-saga/index.md>).

## Prerequisites

- The providing module exists ([Add a module](<https://sandloom.dev/docs/modules/create/index.md>)).
- If `contracts.py` already exists, it must parse and import `command, contract` from `sandloom.sdk`.

## Steps

### 1\. Run the generator in the providing module

```
sandloom add contract stats quota
```

The contract name is `<module>.<name>.v1`, here `stats.quota.v1`. The generator writes, inside the module package:

- the provider class, **appended** to `<module>/contracts.py`;
- its DTO, a pydantic model **appended** to `<module>/schemas.py`: a provider authors the models and Enums its signatures use in its own `schemas`.

Then `sandloom add` runs `sandloom generate`, which writes the generated files in the shared DTO package:

- the typed stub (`<app>/shared/contracts/stats_quota_v1.py`), rendered from the provider;
- a wire-shape copy of the module's contract DTOs (`<app>/shared/contracts/stats_dto.py`), which the stub names and callers receive.

Generated files are never edited by hand; `sandloom generate` rewrites them.

### 2\. Read what it wrote

The provider, with two stand-in methods. A public `async def` is a **read**; `@command(idempotency_key=...)` marks a **command**, deduplicated by that argument and committed in the provider's own unit:

```
"""Contracts the stats module provides."""

from sandloom.sdk import command, contract

from .schemas import QuotaStatus


@contract("stats.quota.v1")
class Quota:
    """Contract ``stats.quota.v1``: public ``async def`` methods are reads,
    ``@command`` marks a command (deduplicated by its key, a saga step)."""

    async def status(self) -> QuotaStatus:
        return QuotaStatus(ready=True)

    @command(idempotency_key="operation_key")
    async def record(self, amount: int, operation_key: str) -> int:
        return amount
```

The stub callers import: a `Protocol` and a typed reference to it, which names the DTO's generated copy:

```
# Generated by `sandloom generate` (Public API 1.0): never edit by hand.
"""Contract ``stats.quota.v1``: the typed stub callers import.

Generated from ``demo.modules.stats.contracts.Quota``.
"""

from __future__ import annotations

from typing import Protocol

from demo.shared.contracts.stats_dto import QuotaStatus
from sandloom.core.descriptors.contract_refs import ContractRef


class StatsQuotaV1(Protocol):
    """Contract ``stats.quota.v1``: public ``async def`` methods are reads,
    ``@command`` marks a command (deduplicated by its key, a saga step).
    """

    async def record(self, amount: int, operation_key: str) -> int: ...
    async def status(self) -> QuotaStatus: ...


STATS_QUOTA_V1: ContractRef[StatsQuotaV1] = ContractRef("stats.quota.v1", StatsQuotaV1)
```

The copy carries the fields, their types and constraints, defaults, aliases, `Literal` / `Enum` values, nested models and the `model_config` options; not methods or validators (they stay the provider's):

```
# Generated by `sandloom generate` (Public API 1.0): never edit by hand.
"""Contract DTOs of module ``stats``: the wire-shape copies callers import.

Generated from the provider's own classes in:

- ``demo.modules.stats.schemas``

A copy carries the fields, their types and constraints, defaults,
aliases, Literal / Enum values, nested models and model_config options;
not the methods, properties or validators (the provider's own).
"""

from __future__ import annotations

from pydantic import BaseModel

from sandloom.core.descriptors.contract_refs import dto_copy


@dto_copy("demo.modules.stats.schemas:QuotaStatus")
class QuotaStatus(BaseModel):
    """What ``stats.quota.v1.status`` returns. A contract DTO lives in its
    provider; `sandloom generate` copies it into the shared DTO package.
    """

    ready: bool
```

### 3\. Write the real methods

Replace the stand-ins with your API. The class stays stateless: no `__init__`, no instance attributes (`ClassVar` constants are allowed). Every parameter and return type must be a wire type; models and Enums go in your module's `schemas`, inside the closed set of wire shapes ([Contracts](<https://sandloom.dev/docs/modules/between/contracts/#wire-types-and-dtos>)). A caller receives the generated copy, locally and over RPC; the provider receives its own class.

The tutorial app's `stats` module provides one read:

```
@contract("stats.quota.v1")
class Quota:
    """Contract ``stats.quota.v1``: public ``async def`` methods are reads,
    ``@command`` marks a command (deduplicated by its key, a saga step)."""

    async def may_create_item(self) -> bool:
        return await services.item_count() < services.ITEM_LIMIT
```

### 4\. Regenerate the stub

`sandloom generate` regenerates the stub and the DTO copies from the provider. It compares the new stub with the file on disk first: an added method is written, but a removed or changed method is refused (SANDLOOM-T007), because callers rely on it, and nothing is written. `sandloom test` never writes: it fails on a stale file ("run `sandloom generate`").

Replacing the generator's stand-ins removes `status` and `record`. While no caller uses the contract yet and the stub is not committed, delete the stub file and generate it again:

```
rm <app>/shared/contracts/stats_quota_v1.py
sandloom generate stats
```

The result in the tutorial app:

```
# Generated by `sandloom generate` (Public API 1.0): never edit by hand.
"""Contract ``stats.quota.v1``: the typed stub callers import.

Generated from ``tasklist.modules.stats.contracts.Quota``.
"""

from __future__ import annotations

from typing import Protocol

from sandloom.core.descriptors.contract_refs import ContractRef


class StatsQuotaV1(Protocol):
    """Contract ``stats.quota.v1``: public ``async def`` methods are reads,
    ``@command`` marks a command (deduplicated by its key, a saga step).
    """

    async def may_create_item(self) -> bool: ...


STATS_QUOTA_V1: ContractRef[StatsQuotaV1] = ContractRef("stats.quota.v1", StatsQuotaV1)
```

Once the contract is committed and called, change it only compatibly (add methods); a breaking change is a new contract, `stats.quota.v2`.

### 5\. Call it from another module

Declare the caller once, at module level, with the generated reference. `define_module` adds it to the module's `consumes`, and startup fails if the contract is neither provided in the process nor bound to a remote service (SANDLOOM-B005):

```
# Required: items must never be created without the quota check.
quota = Contract(STATS_QUOTA_V1)
```

Then await its methods. The call is typed by the `Protocol`:

```
@router.post("/items", status_code=201)
async def create_item(body: ItemIn, user: CurrentUser) -> ItemOut:
    if not await quota.may_create_item():
        raise HTTPException(status_code=402, detail="item quota reached")
    return await services.create_item(body, owner_id=user.user_id)


@router.get("/items")
async def list_items(
    user: CurrentUser, limit: Annotated[int, Query(ge=1, le=100)] = 20
) -> list[ItemOut]:
    """A page of the tenant's items; the limit caps each request."""
    return await services.list_items(limit=limit)


@router.get("/items/{item_id}")
async def get_item(item_id: UUID, user: CurrentUser) -> ItemOut:
    item = await services.get_item(item_id)
    if item is None:
        raise HTTPException(status_code=404, detail="item not found")
    return item
```

The call works the same whether the provider runs in the same process or in another service behind RPC (configured with `SERVICE_BINDINGS`; see [Service RPC](<https://sandloom.dev/docs/operate/service-rpc/index.md>)). A provider that is deployed but down answers `503`.

### 6\. Required or optional

`Contract(REF)` is **required**. Use it whenever the caller has no correct answer without the provider. A gate whose absence must deny, such as the quota above, is always required: an absent quota service must not silently allow writes.

`Contract(REF, optional=True)` is for the rare caller that has a correct business answer without the provider, such as a feature that is off or an enrichment that is skipped:

```
recommendations = Contract(RECO_ITEMS_V1, optional=True)

client = recommendations.client()   # RecoItemsV1 | None
extra = [] if client is None else await client.for_item(item_id)
```

`client()` returns `None` only when the provider is neither local nor bound in this deployment, which is decided at startup. It does not hide an outage: a deployed provider that fails still raises.

## Verify

Test the caller with a stand-in for the contract, so the test does not need the provider:

```
async def test_create_and_read_an_item(client: Any) -> None:
    _, headers = as_user(tenant_id=uuid4())
    with fake_contract(STATS_QUOTA_V1, Quota(allow=True)):
        created = await client.post(
            "/api/v1/tasks/items", json={"title": "first"}, headers=headers
        )
    assert created.status_code == 201, created.text
    assert len(published(ItemCreated)) == 1
    item = created.json()
    fetched = await client.get(f"/api/v1/tasks/items/{item['id']}", headers=headers)
    assert fetched.status_code == 200
    assert fetched.json()["title"] == "first"


async def test_the_quota_refuses_a_new_item(client: Any) -> None:
    _, headers = as_user(tenant_id=uuid4())
    with fake_contract(STATS_QUOTA_V1, Quota(allow=False)):
        response = await client.post(
            "/api/v1/tasks/items", json={"title": "x"}, headers=headers
        )
    assert response.status_code == 402
    assert published(ItemCreated) == []


async def test_items_need_an_authenticated_user(client: Any) -> None:
    response = await client.post("/api/v1/tasks/items", json={"title": "x"})
    assert response.status_code == 401
```

Then run both modules' tests and the gates:

```
sandloom test stats
sandloom test tasks
```

`sandloom generate` also writes `contracts.snapshot.json` for the provider; commit it with the stub and the DTO copy.

## Errors you may hit

- [SANDLOOM-B005](<https://sandloom.dev/docs/reference/error-codes/#sandloom-b005>): a required contract is neither provided in this process nor bound in `SERVICE_BINDINGS`.
- [SANDLOOM-B018](<https://sandloom.dev/docs/reference/error-codes/#sandloom-b018>): the provider class has state, a non-async member, a `@command` without `idempotency_key`, or a base class outside the module.
- [SANDLOOM-C012](<https://sandloom.dev/docs/reference/error-codes/#sandloom-c012>): a signature DTO is outside the closed set of wire shapes (the field path and the fix are named), belongs to another module or a function, or its generated copy is missing or hand-edited (run `sandloom generate`).
- [SANDLOOM-R005](<https://sandloom.dev/docs/reference/error-codes/#sandloom-r005>): the contract is not available in this deployment at call time.
- [SANDLOOM-T004](<https://sandloom.dev/docs/reference/error-codes/#sandloom-t004>): the provider's contract snapshot changed incompatibly.
- [SANDLOOM-T007](<https://sandloom.dev/docs/reference/error-codes/#sandloom-t007>): the stub is out of date, or would lose or change a method.

## Related

- [Contracts](<https://sandloom.dev/docs/modules/between/contracts/index.md>): reads, commands, wire types, local equals remote.
- [Service RPC](<https://sandloom.dev/docs/operate/service-rpc/index.md>): calling a provider in another service.
- [Add a saga](<https://sandloom.dev/docs/modules/between/add-saga/index.md>): command steps with compensation.
- [Testing modules](<https://sandloom.dev/docs/toolchain/testing/index.md>).
