# Event & Job System

This is the concrete mechanism behind `../architecture/05-module-interaction-model.md`'s "asynchronous domain event" pattern, and the Postgres-backed job queue named in `../architecture/03-reference-architecture.md`. Everything here lives in a `system` schema, owned by no single business module.

## Tables

| Table | Key columns | Purpose |
|---|---|---|
| `system.event` | id (ULID), event_type, subject_type, subject_id, payload (jsonb), occurred_at | Durable, append-only record that something happened. Never updated after insert. |
| `system.job` | id (ULID), job_type, event_id (nullable), payload (jsonb), status (pending/running/succeeded/failed), attempts, max_attempts, available_at, locked_at, locked_by, last_error, created_at, completed_at | The unit of work a background worker picks up and executes. `event_id` is null for scheduled/cron-triggered jobs that don't originate from a domain event. |

## Publishing: transactional outbox, written directly as jobs

When a module publishes an event, three things happen **in the same database transaction** as the business write that caused it:

1. The business row(s) are written in the owning module's schema (e.g., `orders.order` status flips to `paid`).
2. One row is inserted into `system.event`.
3. One row is inserted into `system.job` **per statically registered subscriber** for that `event_type`, each referencing the same `event_id`.

Because all of this commits atomically, there is no window where the business change succeeds but the fact that subscribers need to react to it is lost — the classic "dual write" problem (write to the database, then separately fail to publish to a broker) doesn't exist here, because there is no separate broker. This is what makes "Postgres as the message broker" (`../architecture/03-reference-architecture.md`) concrete rather than aspirational.

## Subscriber registration

Subscribers are a static, code-level registry — not configured at runtime — mapping `event_type -> [handler_name, ...]`. Example shape (illustrative, not final syntax):

```
order.placed -> [
  inventory.finalize_stock_decrement,
  notifications.send_order_confirmation,
  automation.evaluate_cross_sell_signal,
  automation.resolve_abandoned_cart,
  analytics.record_transaction,
]
```

This registry is what turns "Orders publishes Order Placed" into concrete job rows at publish time. Adding a new subscriber to an existing event is a one-line registry change plus a new handler — it never requires the publishing module to know its consumers exist.

## Consuming: SKIP LOCKED polling

Each module's background worker(s) poll only their own `job_type` prefix:

```sql
SELECT * FROM system.job
WHERE job_type LIKE 'inventory.%'
  AND status = 'pending'
  AND available_at <= now()
ORDER BY available_at
FOR UPDATE SKIP LOCKED
LIMIT 10;
```

A claimed row is marked `running` with `locked_at`/`locked_by` set, processed, then marked `succeeded` or, on failure, returned to `pending` with `attempts` incremented and `available_at` pushed out by an exponential backoff — until `attempts` exceeds `max_attempts`, at which point it's marked `failed` permanently and surfaced to Merchant Health/ops visibility rather than retried forever.

`SKIP LOCKED` means multiple worker processes (or threads within the single deployable — see `../architecture/03-reference-architecture.md`'s horizontal scaling story) can poll the same table concurrently without contending on the same rows or needing external coordination.

## Delivery guarantee: at-least-once, not exactly-once

A crash between "processed successfully" and "marked succeeded" results in a job being retried. **Every job handler must be idempotent** — safe to run twice with the same payload without double-effect (e.g., "send order confirmation" checks whether a confirmation was already sent for this order before sending again; "decrement stock" uses the order's line items as the source of truth rather than blindly subtracting twice). This is a design requirement on every subscriber's handler, not an edge case to handle later.

## Scheduled (cron-style) jobs

Recurring work that isn't triggered by a specific event — nightly low-stock scans, Store Health Score recomputation, scheduled report generation — is inserted directly into `system.job` by a lightweight scheduler process on a fixed cadence, with `event_id` left null. These jobs flow through the exact same worker/`SKIP LOCKED` mechanism as event-triggered jobs; there is no separate scheduling infrastructure.

## Event catalog (from Phase 1, now concrete)

The event types named informally in `../architecture/04-domain-modules.md` map directly to `event_type` values: `product.created`, `product.updated`, `product.deleted`, `stock.low`, `stock.out_of_stock`, `stock.back_in_stock`, `order.placed`, `order.paid`, `order.fulfilled`, `order.cancelled`, `order.refunded`, `customer.registered`, `wishlist.item_added`, `payment.succeeded`, `payment.failed`, `payment.refunded`, `shipment.created`, `shipment.delivered`, `discount.applied`, `campaign.sent`, `notification.delivered`, `notification.failed`, `content.published`, `setting.changed`, `automation.recommendation_generated`, `automation.alert_raised`, `analytics.metric_computed`, `report.generated`, `health_score.updated`, `user.action_recorded`, `asset.processed`.

## Worked example: Order Placed, mechanically

Revisiting `../architecture/05-module-interaction-model.md`'s worked example with the actual mechanism:

1. Orders' checkout-completion code, inside one transaction, updates `orders.order.status = 'paid'`, inserts `system.event(event_type='order.placed', subject_type='order', subject_id=<order id>, payload={...order summary...})`, and — per the subscriber registry — inserts 5 rows into `system.job` (one each for Inventory, Notification Service, Automation Engine ×2, Analytics), all referencing that event's id. Transaction commits.
2. Each subscribing module's own worker independently polls, claims, and processes its `system.job` row on its own schedule — no coordination between them, no shared consumer state.
3. If Notification Service's worker crashes mid-send, its job row is retried (at-least-once); its handler must check "was this order's confirmation already marked sent" before sending, per the idempotency requirement above.
