# Add an event

**Goal:** a new typed event that your module publishes, such as `notes.note.archived.v1`, so other modules can react to it.

**Use it** when something happened in your module that other modules may care about, and the producer does not need their answer.

**Do not use it** when you need an answer now (use a [contract](<https://sandloom.dev/docs/modules/between/add-contract/index.md>)), or to react to another module's event (that is a [consumer](<https://sandloom.dev/docs/modules/between/add-consumer/index.md>): a *view* of the other module's channel, never a second producer). To change the payload of an event you already publish, see [Change an event's payload](<https://sandloom.dev/docs/modules/between/event-versioning/index.md>).

## Prerequisites

- The module exists ([Add a module](<https://sandloom.dev/docs/modules/create/index.md>)).
- If `events.py` already exists, it must parse and import `Event` from `sandloom.sdk` (and `UUID` from `uuid` for the default fields). The generator refuses otherwise and names the missing import.

## Steps

### 1\. Run the generator

```
sandloom add event notes NoteArchived
sandloom add event notes NoteArchived --field note_id:UUID --field reason:str   # explicit fields
```

- The channel is derived from the module and the class name: `NoteArchived` in `notes` gives `notes.note.archived.v1`.
- Without `--field`, the event gets `<first word>_id: UUID` (here `note_id`). A `tenant_id: UUID` field is always added; a consumer binds the tenant from it.
- `--field name:type` takes `str`, `int`, `float`, `bool`, `UUID`, `datetime`, `date` or `Decimal`.
- The class is **appended** to `events.py` (created when absent). Existing code is never rewritten.

### 2\. Read what it wrote

The appended class (the rest of the file is the module's existing events):

```
class NoteArchived(Event, channel="notes.note.archived.v1"):
    note_id: UUID
    tenant_id: UUID
```

The channel's first segment is the module's name, so this class is the channel's **producer**: `sandloom generate` (run last by `sandloom add`) snapshots its schema and exports it to the shared DTO package (`<app>/shared/events/notes.note.archived.v1.json`), where consumers in other repositories verify their views against it.

### 3\. Publish it

Publish from a service, inside the unit of work that makes the change. The event is written to the transactional outbox and is delivered only if the unit commits. The tutorial app's `tasks` module does it like this:

```
async def create_item(body: ItemIn, *, owner_id: UUID) -> ItemOut:
    item = TasksItem(title=body.title, owner_id=owner_id)
    db().add(item)
    await db().flush()
```

`.publish()` outside a unit of work raises (SANDLOOM-R002). In a route, the request's unit is already open.

### 4\. Test it

`published(E)` returns the events of `E`'s channel that the code under test published:

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

## Verify

```
sandloom test notes
```

The first run writes the producer's schema to `module.snapshot.json` and to the shared DTO package. Commit both. From then on, only an added optional field is accepted on the same `.v1`; anything else fails with SANDLOOM-T005.

## Errors you may hit

- [SANDLOOM-B015](<https://sandloom.dev/docs/reference/error-codes/#sandloom-b015>): the channel is not `<module>.<entity>.<verb>.vN`, a field is not a wire type, two producer classes share a channel, or the producer lives outside its module.
- [SANDLOOM-R002](<https://sandloom.dev/docs/reference/error-codes/#sandloom-r002>): `.publish()` ran outside a unit of work.
- [SANDLOOM-R004](<https://sandloom.dev/docs/reference/error-codes/#sandloom-r004>): the payload does not match the channel's schema.
- [SANDLOOM-T005](<https://sandloom.dev/docs/reference/error-codes/#sandloom-t005>): a breaking change on a channel that already has a committed schema; publish a `.v2`.

## Related

- [Events and consumers](<https://sandloom.dev/docs/modules/between/events/index.md>): delivery, ordering, the dead-letter queue.
- [Add a consumer](<https://sandloom.dev/docs/modules/between/add-consumer/index.md>).
- [Change an event's payload](<https://sandloom.dev/docs/modules/between/event-versioning/index.md>).
- [Units of Work and `db()`](<https://sandloom.dev/docs/modules/database/units/index.md>): why the event commits with the row.
