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

97
docs/data-model.md Normal file
View file

@ -0,0 +1,97 @@
# Chatelet — Data Model
Tables live in `ext_chatelet.sqlite3` (SQLite) or the `chatelet` Postgres
schema. Defined in [`../migrations.py`](../migrations.py); typed in
[`../models.py`](../models.py).
## Entities
```
settings (1 row)
rooms ─────────────< bookings
│ (guest reservations; amount_sat canonical)
└──────────────< blocks
(manual owner-side unavailability)
```
### `settings` — one row, castle/operator-wide
| Field | Purpose |
|---|---|
| `operator_id` | LNbits account whose Nostr signer publishes on the castle's behalf (resolved via `resolve_signer` — LocalSigner now, NIP-46 bunker later) |
| `relays` | relays we publish to / subscribe on |
| `default_hold_minutes` | how long a `held` booking survives before the sweep expires it |
| `deposit_percent` | 100 = full prepay; `<100` = deposit now, balance later |
| `checkin_time` / `checkout_time` | surfaced in listing + check-in DM |
| `cancellation_policy` | free-form markdown |
| `publish_availability` | mirror blocked dates to a public NIP-52 calendar |
### `rooms` — the rentable unit (one NIP-99 `kind:30402` listing each)
`id` (short hash) doubles as the listing's `d` tag, so re-publishing
replaces the same addressable event. Holds price (`amount`/`currency`/
`frequency` map straight onto the NIP-99 `price` tag), capacity
(`max_guests`, `min_nights`), presentation (`images`, `amenities``t` tags,
`location`, `geohash``g` tag), `status` (`active`/`inactive`), and
`listing_event_id` (last published event id).
### `bookings` — a reservation (one NIP-78 `kind:30078` object each)
`id` doubles as the reservation object's `d` tag. Key fields:
- **`amount_sat` — canonical total.** Computed once at hold/quote time from
price × nights × FX, then propagated as-is to the invoice, the reservation
event, and any receipt. **Never re-derived** from `price_fiat` downstream
(FX drifts, rounding accumulates — workspace source-of-truth rule).
`price_fiat` is a display snapshot only.
- **`deposit_sat`** — portion invoiced now (`== amount_sat` when deposit is
100%).
- **`status`** — lifecycle below.
- **`payment_hash`** — LNbits invoice covering the deposit.
- **`request_event_id` / `reservation_event_id`** — inbound request and our
published reservation object.
- **`expires_at`** — hold expiry (set while `held`/`awaiting_payment`).
### `blocks` — manual owner unavailability
Maintenance, personal use, off-season. Half-open `[start_date, end_date)`.
## Booking lifecycle
```
request
┌───────────────────┐ invoice sent ┌────────────────────┐
│ held │ ────────────────▶ │ awaiting_payment │
└───────────────────┘ └────────────────────┘
│ │ │ │
hold │ │ declined paid │ │ hold
expires│ ▼ ▼ │ expires
│ ┌──────────┐ ┌────────────┐ │
└─▶│ expired │ │ confirmed │◀───┘ (paid before expiry)
└──────────┘ └────────────┘
check-in │ cancelled
┌────────────┐
│ checked_in │──▶ completed
└────────────┘
```
`OCCUPYING_STATUSES = {held, awaiting_payment, confirmed, checked_in}` — the
statuses that block a room's calendar. Terminal negatives (`declined`,
`cancelled`, `expired`) free the dates.
## Availability is derived, not stored
There is **no availability table**. `crud.is_available(room, in, out)`
returns true iff the room is `active` and no *occupying* booking and no
block overlaps `[check_in, check_out)`. Half-open intervals mean
back-to-back stays (one guest out, next guest in, same day) don't collide.
This keeps a single source of truth: the only way to make dates unavailable
is to write a booking or a block. See
[`event-flow.md`](event-flow.md) § Concurrency for the check-then-hold
locking requirement.

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.