docs: data-model + event-flow reference

data-model.md: entities, the canonical-amount_sat and derived-availability
invariants, booking lifecycle diagram. event-flow.md: actor/kind map, the
happy-path sequence diagram, why payment is the commit point, and the
check-then-hold concurrency requirement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
This commit is contained in:
Padreug 2026-07-19 00:16:57 +02:00
commit dfd54123bb
2 changed files with 205 additions and 0 deletions

108
docs/event-flow.md Normal file
View file

@ -0,0 +1,108 @@
# Chatelet — Event Flow
How rooms, guests, LNbits, and relays interact. The guiding rule:
> **LNbits is the booking authority. Nostr carries discovery, requests, and
> receipts — it never holds the lock.** The DB write is the lock; the
> Lightning payment is the confirmation.
Two doors lead into the *same* booking flow — the REST API
([`../views_api.py`](../views_api.py)) and the Nostr subscription
([`../nostr/service.py`](../nostr/service.py)). Both funnel through
[`../crud.py`](../crud.py) so arbitration + quoting live in one place.
## Actors
- **Operator** — the castle. Owns the LNbits account + Nostr identity that
signs listings and reservation receipts (via `resolve_signer`).
- **Guest** — a Nostr user (npub) discovering and booking a room.
- **LNbits (Chatelet ext)** — the authority: arbitrates availability, holds
dates, issues invoices, confirms on payment.
- **Relays** — dumb transport for discovery + messaging.
## Kinds at a glance
| Kind | NIP | Who signs | Purpose |
|---|---|---|---|
| `30402` | 99 | operator | Room listing (public, addressable) |
| `30078` | 78 | operator | Guest's reservation object (NIP-44 encrypted, addressable) |
| `31923/31924` | 52 | operator | Public availability calendar (no PII) |
| `1059` | 17/59 | both | Private booking DMs (request → quote → confirm → check-in) |
| `22000/22001` | aiolabs | guest/operator | Live availability query + response (ephemeral) |
## Happy path
```mermaid
sequenceDiagram
participant O as Operator (LNbits)
participant R as Relays
participant G as Guest
Note over O,R: Discovery
O->>R: publish kind:30402 listing (per room)
O->>R: publish kind:31923 blocked ranges (no PII)
G->>R: subscribe kind:30402 (+ optional 31923)
Note over G,O: Availability (live)
G->>R: kind:22000 query {room, in, out} (NIP-44)
R->>O: deliver query
O->>O: crud.is_available()
O->>R: kind:22001 {available, quote_sat} (NIP-44)
R->>G: deliver response
Note over G,O: Booking request → hold
G->>R: NIP-59 giftwrap: booking request
R->>O: deliver request
O->>O: is_available? → write `held` booking (amount_sat canonical)
O->>O: create LNbits invoice (deposit_sat)
O->>R: NIP-59 giftwrap: quote {bolt11, expires_at}
R->>G: deliver quote (status: awaiting_payment)
Note over G,O: Payment confirms (NOT a nostr event)
G->>O: pay bolt11 (Lightning)
O->>O: invoice listener → status = confirmed, dates hard-blocked
O->>R: publish/replace kind:30078 reservation (NIP-44 to guest)
O->>R: NIP-59 giftwrap: check-in details (address, gate code, times)
R->>G: deliver receipt + check-in
```
If the guest never pays, the hold-expiry sweep
([`../tasks.py`](../tasks.py) `expire_holds_loop`) flips the booking to
`expired` after `default_hold_minutes` and the dates free themselves.
## Why payment (not a Nostr confirm event) is the commit point
A "confirm" event could be forged, replayed, or arrive out of order, and
relays give no ordering or delivery guarantees. The Lightning payment is
unforgeable and already the thing we actually care about. So the invoice
listener is the *only* writer that sets `confirmed`. Nostr just announces
the result.
## Concurrency — the check-then-hold lock
`is_available()` followed by writing the `held` row is the critical section.
Two simultaneous requests for the same nights could both pass the read
before either writes. Required mitigation (marked `TODO(concurrency)` in
`views_api.py`):
- wrap check + insert in a DB transaction, **or**
- take a per-room `asyncio.Lock` around the section.
Because the check reads *occupying* bookings (`held` included), once one
request wins and writes `held`, the loser's re-check fails → `409`. The DB
is the arbiter; the lock just makes the read-write atomic.
## Cancellation & refunds (design intent)
- Cancel frees dates immediately (status → `cancelled`) and republishes the
`kind:30078` reservation with the new status so the guest's copy updates.
- Refund policy (`settings.cancellation_policy`) drives whether/how much is
returned — via LNURL-withdraw or a manual payout. Not auto-refunding on
chain; kept operator-mediated for a small castle.
## Nostr-native direction
Per the workspace long-term goal (webapp ↔ LNbits over Nostr, no HTTP), the
Nostr door is primary and REST is transitional. Keep new booking logic in
`crud.py` so both doors share it — don't grow HTTP-only paths that later
need ripping out.