# Transactional outbox

`event/outbox` defines the storage and delivery protocol for durable,
at-least-once event publication. It does not claim durability without a
database-backed `Store`.

Create the immutable serialized message before application work commits, then
enqueue it through the same transaction executor:

```go
err := transactions.Within(ctx, boundary, func(ctx context.Context, tx data.Executor) error {
    if err := orders.Save(ctx, tx, order); err != nil {
        return err
    }
    message, err := outbox.NewMessage(outbox.MessageSpec{
        ID:          command.ID,
        Topic:       "orders.OrderPlaced",
        Module:      "example.com/shop/orders",
        ContentType: "application/json",
        Payload:     payload,
        OccurredAt:  clock(),
    })
    if err != nil {
        return err
    }
    return store.Enqueue(ctx, tx, message)
})
```

Message IDs are caller-owned idempotency keys. Payloads are copied and limited
to 1 MiB; metadata is validated and bounded. A store atomically claims the
oldest available messages ordered by occurrence time and ID, returning opaque
lease receipts and one-based attempts.

`Dispatcher.RunOnce` publishes a bounded batch sequentially, completes
successful leases, and releases failed publishes with an explicit delay. It
starts no goroutine; applications can invoke it through Spice scheduling or
their own lifecycle loop. Cancellation stops before the next message.

Delivery is deliberately at least once. If publishing succeeds and completion
fails, the lease eventually expires and the message can be published again.
Transport publishers must therefore use the message ID as their downstream
idempotency key.

Publisher panics are observed and re-raised; the lease is left to expire.
Observations contain topic/module/attempt and outcome metadata, never payloads
or lease receipts.

## SQL store

`SQLStore` accepts a long-lived `data.Executor` plus four trusted,
dialect-owned statements. It performs no database operation during
construction.

- Insert arguments: ID, topic, module, content type, payload, occurrence time.
- Claim arguments: owner, current time, lease expiry, limit.
- Claim columns: ID, topic, module, content type, payload, occurrence time,
  opaque receipt, one-based attempt.
- Complete arguments: owner, receipt.
- Release arguments: owner, receipt, next availability time.

The claim statement must atomically select and lease rows in occurrence-time/ID
order. Spice reconstructs and validates every message, rejects duplicate or
unordered results, closes and checks rows, and requires completion/release to
affect exactly one row. Locking syntax and migrations remain dialect-owned.

## PostgreSQL

[`github.com/spice-framework/starter-postgres`](https://github.com/spice-framework/starter-postgres)
supplies reviewed SQL and deterministic initial DDL:

```go
schemaSQL, err := postgres.OutboxSchemaSQL(postgres.OutboxOptions{
    Schema: "orders",
})
if err != nil {
    return err
}
// Commit schemaSQL through an application-owned migration.

store, err := postgres.NewOutboxStore(database, postgres.OutboxOptions{
    Schema: "orders",
})
```

Empty options select `public.spice_event_outbox`. Identifiers are validated
PostgreSQL identifiers and are always quoted. Construction never connects or
applies schema. Enqueue uses the executor supplied by the application
transaction; claims use `FOR UPDATE SKIP LOCKED`, stable occurrence-time/ID
ordering, unique receipts, bounded leases, and one-based attempts. Release
clears ownership and sets the explicit next availability time. Completion and
release reject stale owner/receipt pairs by requiring exactly one affected row.

The tagged PostgreSQL race integration proves transaction rollback, committed
visibility, deterministic ordering, delayed retry with a fresh receipt,
stale-receipt rejection, and exclusive concurrent claims.
