diff --git a/.gitignore b/.gitignore
index 1fd50e2..8297260 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@ dist/
.env
.env.local
.env.production
+.env.harden
.env.*.local
# IDE
diff --git a/README.md b/README.md
index 08fa843..915f72d 100644
--- a/README.md
+++ b/README.md
@@ -1,1207 +1,14 @@
-
-
-
+# HAIP — Hotel AI Platform
-HAIP — Hotel AI Platform
+Open-source, API-first hotel PMS. Full project documentation lives on [`main`](https://github.com/TelivityAI/haip/blob/main/README.md).
-
- The open-source, API-first hotel PMS where AI agents are first-class citizens.
-
+## Production checklist
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- What is HAIP ·
- Architecture ·
- AI Agents ·
- Features ·
- Tech Stack ·
- Quick Start ·
- API Reference ·
- OTAIP Integration ·
- Integrations ·
- Community & Support ·
- Contributing
-
-
----
-
-## What is HAIP
-
-The hotel industry runs on closed-source, legacy PMS platforms that charge per-room fees, lock data behind proprietary APIs, and treat integrations as an afterthought. Hotels pay $5–15/room/month just for the privilege of managing their own operations.
-
-HAIP is a **complete, production-grade hotel Property Management System** built from scratch with modern architecture. Reservation lifecycle, folio & billing, rate plans, housekeeping with digital checklists, night audit, groups & commercial (allotment + rooming lists), accounting & cashiering (A/R, deposits, cash drawers, GL codes), channel distribution to 450+ OTAs, a **full commission-free direct booking engine** (guest-facing widget + public booking API) so hotels take reservations straight from their own website, **pluggable payment gateways** (Stripe and other PSPs), Keycloak authentication, local user & role administration, media management for property and room photos, tax calculation, fiscal invoicing & guest-registration providers, an **integration registry** for adapters and providers, door-lock credential hooks, revenue management — and **12 built-in AI agents** that orchestrate revenue strategy, optimize pricing, predict cancellations, detect audit anomalies, prioritize receivables collections, forecast group pickup, schedule housekeeping, automate guest communications, and draft review responses. It even ships a **ChatGPT gateway** so guests can search and book a room by chatting. Dashboard locales include English, German, and Portuguese (BR). All open source under Apache 2.0.
-
-What makes HAIP different is that **AI agents are built into the architecture from day one** — not as a bolt-on, but as first-class citizens with their own lifecycle, decision logging, and per-property calibration (deterministic engines that learn each hotel's own rates from its history — not LLMs pretending to be agents). HAIP is the sister project to [OTAIP](https://github.com/telivity-otaip/otaip) (Open Travel AI Platform). Together they form **Telivity's open-source travel infrastructure**. OTAIP agents connect to HAIP via the Connect API — the PMS works without AI, but the AI makes it extraordinary.
-
-### What HAIP is NOT
-
-HAIP is not a wrapper around another PMS. It's not a SaaS dashboard with "AI" slapped on the marketing page. It's a real PMS with real hotel operations logic — night audits at 3am, folio routing rules, rate parity enforcement, guest registration compliance across jurisdictions. And it ships a **commission-free direct booking engine** so a hotel can take bookings straight from its own website — keeping the 15–25% an OTA would take.
-
----
-
-## Architecture
-
-```mermaid
-graph TB
- subgraph Clients
- Dashboard["React Dashboard
(Vite + TanStack Query)"]
- BookingWidget["Booking Widget
(Direct Booking Engine)"]
- ChatGPT["ChatGPT Gateway
(Connect GPT Actions)"]
- OTAIP["OTAIP Agents
(Hotel Search · Rate Compare · Booking)"]
- ThirdParty["Third-Party Systems
(Webhooks · Adapters)"]
- end
-
- subgraph HAIP["HAIP PMS (NestJS)"]
- direction TB
- REST["REST API
OpenAPI 3.0"]
- WS["WebSocket Gateway
Real-time Events"]
- Connect["Connect API
OTAIP Agent Layer"]
- Auth["Keycloak Auth
OAuth 2.0 + RBAC"]
-
- subgraph Modules
- direction LR
- Reservation["Reservations
Lifecycle + Availability"]
- Folio["Folio & Billing
Charges + Routing"]
- Room["Rooms
Status State Machine"]
- Guest["Guest Profiles
VIP + Preferences"]
- RatePlan["Rate Plans
BAR + Derived + Restrictions"]
- Payment["Payments
Pluggable Gateways"]
- Tax["Tax Engine
Jurisdiction-based"]
- HK["Housekeeping
Tasks + Checklists + Inspection"]
- NA["Night Audit
Automated Day Close"]
- Channel["Channel Manager
ARI Sync + Rate Parity"]
- BookingEngine["Booking Engine
Public API + Widget"]
- Groups["Groups & Commercial
Allotment + Rooming Lists"]
- Accounting["Accounting & Cashier
A/R · Deposits · GL"]
- Fiscal["Fiscal & Compliance
Invoicing + Guest Reg"]
- Integrations["Integration Registry
Adapters + Providers"]
- DoorLocks["Door Locks
Credential Hooks"]
- Reports["Reports
Revenue + Occupancy + KPIs"]
- end
-
- subgraph AI["AI Agent Framework — 12 agents"]
- direction LR
- RManager["Revenue Manager
(Orchestrator)"]
- DemandAgent["Demand
Forecasting"]
- PricingAgent["Dynamic
Pricing"]
- ChannelMix["Channel-Mix
Optimization"]
- Overbooking["Overbooking
Management"]
- GroupPickup["Group Pickup
Forecasting"]
- AuditAnomaly["Night Audit
Anomaly Detection"]
- HKOptimizer["Housekeeping
Optimization"]
- CancelPredict["Cancellation
Prediction"]
- ARCollections["A/R Collections
Prioritization"]
- GuestComms["Guest
Communication"]
- ReviewResp["Review
Response"]
- end
-
- Webhook["Webhook Engine
entity.action Events"]
- end
-
- subgraph Infrastructure
- PG["PostgreSQL 16
(Multi-tenant)"]
- Redis["Redis 7
(Cache + Pub/Sub + Queues)"]
- Keycloak["Keycloak
(Identity Provider)"]
- Payments["Payment Gateways
(Stripe + pluggable PSPs)"]
- OTAs["OTA Channels
Direct + Aggregators · 450+"]
- end
-
- Dashboard -->|HTTP + WebSocket| REST
- Dashboard -->|Real-time| WS
- BookingWidget -->|HTTP| BookingEngine
- ChatGPT -->|REST| Connect
- OTAIP -->|REST| Connect
- ThirdParty -->|REST| REST
- ThirdParty -->|REST| Integrations
-
- Auth --> Keycloak
- REST --> Auth
- REST --> Modules
- Connect --> Modules
- Modules --> PG
- Modules --> Redis
- AI --> PG
- AI --> Modules
- Channel --> OTAs
- Payment --> Payments
- Webhook -->|POST| ThirdParty
- WS -->|Broadcast| Dashboard
-```
-
-**Option B Architecture** — The PMS is standalone. It works without OTAIP. OTAIP agents sit on top via the Connect API, using purpose-built endpoints for AI agent workflows.
-
-### Key Design Decisions
-
-- **Multi-tenant from day one** — `property_id` on every table, designed for portfolio operators managing multiple hotels
-- **Event-driven** — Webhook events on every state change (`reservation.created`, `folio.charge_posted`, `room.status_changed`). Build anything on top.
-- **AI agents as first-class citizens** — 12 built-in agents with a common interface: `analyze() → recommend() → execute()`, coordinated by a Revenue Manager orchestrator. Three operating modes: manual, suggest, autopilot. Decision logging for continuous learning.
-- **ChannelAdapter pattern** — Same abstraction as OTAIP's ConnectAdapter. Booking.com + Expedia (EQC) direct adapters plus SiteMinder and DerbySoft aggregators for 450+ OTA reach — distributing **both** ARI and descriptive content (photos/descriptions/amenities).
-- **Layered RBAC** — Keycloak JWT authentication **plus HAIP's own local users, roles & permissions**: a code-defined permission catalog, operator-defined custom roles, and guards (`@Roles` + `@RequirePermissions`) on every endpoint.
-- **Polymorphic media** — One image model for properties, room types & rooms; add by URL (zero infra) or upload to S3/MinIO, with one enforced primary per owner.
-- **Compliance as infrastructure** — PCI tokenization via pluggable payment gateways, GDPR audit trails, jurisdiction-based tax calculation, fiscal document hooks, guest registration per jurisdiction. Not bolted on — built in.
-- **Integration registry** — channels, payments, messaging, fiscal, door locks, and more as discoverable adapters/providers — not one-off hardwires.
-- **Real-time dashboard** — WebSocket broadcasting per property. Room status changes, new reservations, AI agent decisions — all pushed instantly.
-
----
-
-## AI Agents
-
-HAIP includes **12 built-in AI agents** — 5 for revenue management (including the Revenue Manager orchestrator), 5 for operations intelligence, and 2 for guest engagement. Every agent follows the `HaipAgent` interface:
-
-```
-analyze() → recommend() → execute() → recordOutcome() → train()
-```
-
-### Orchestration (HAIP-hosted)
-
-HAIP orchestrates its own agents — there is no generic pipeline engine. **RManager** runs the revenue subgraph (`demand_forecast` → pricing / overbooking / channel_mix / group_pickup → one strategy). Ops and guest agents run on external cron or events. **OTAIP** orchestrates OTAIP agents over the Connect API only (Option B). Graph source of truth: `apps/api/src/modules/agent/agent-graph.ts`. Details: [`docs/agents-orchestration.md`](./docs/agents-orchestration.md).
-
-### Operating Modes
-
-| Mode | Behavior |
-|------|----------|
-| **Manual** | Agent analyzes and recommends. Human approves/rejects via dashboard. |
-| **Suggest** | Agent recommends with confidence score. High-confidence decisions auto-execute. |
-| **Autopilot** | Agent executes autonomously. All decisions logged for review. |
-
-### Revenue Management Agents
-
-| Agent | What It Does |
-|-------|-------------|
-| **Revenue Manager (RManager)** | The revenue **orchestrator**. Runs the levers below in dependency order (demand first, then pricing, overbooking, channel mix, group pickup) and reconciles them into one coherent strategy grounded in established RM doctrine: optimizes **GOPPAR** (profit per available room) over raw revenue, moves price with demand band and booking pace, protects peak dates with length-of-stay controls and zero overbooking, keeps the rate grid internally consistent, evaluates group displacement on net contribution, and treats discounting as a last resort. Surfaces conflicts between levers and projects RevPAR/GOPPAR across the horizon. |
-| **Demand Forecasting** | Predicts future occupancy using weighted moving averages with day-of-week seasonality, booking pace, and last-minute demand signals. Heuristic model → statistical model progression. |
-| **Dynamic Pricing** | Calculates optimal room rates based on demand tier, booking pace, lead-time decay, and weekend premiums. Enforces floor/ceiling rate constraints. |
-| **Channel-Mix Optimization** | Ranks OTA channels by net revenue (gross × (1−commission) × (1−cancel_rate)). Recommends allocation shifts and stop-sell when occupancy exceeds thresholds. |
-| **Overbooking Management** | Calculates optimal overbooking level using expected value optimization: (overbook revenue × fill probability) vs (walk cost × walk probability). Respects walk cost constraints. |
-
-### Operations Intelligence Agents
-
-| Agent | What It Does |
-|-------|-------------|
-| **Night Audit Anomaly Detection** | Scans checked-in reservations, folios, and closed cashier shifts for 8 anomaly types: unposted charges, missing tax, payment mismatches, stale check-ins, duplicate folios, no-show candidates, unusual charges, and cash-drawer variance outliers (z-score > 2.5 statistical outlier detection). Ranked by severity (critical/warning/info) and confidence. |
-| **Housekeeping Optimization** | Builds workload-balanced cleaning schedules. Prioritizes VIP and early check-in rooms, groups by floor for route efficiency, estimates cleaning times by task type (checkout 30min, stayover 20min, deep clean 60min, suite 45min). |
-| **Cancellation Prediction** | Scores every active reservation with a cancellation probability based on booking source (OTA 25% base vs direct 8%), deposit status, repeat guest history, VIP level, lead time, and days until arrival. Adds **deposit-forfeit risk** scoring on held deposits (likely-forfeit vs likely-refund exposure). Aggregates risk by date for overbooking decisions. |
-| **A/R Collections Prioritization** | Ranks open Accounts Receivable ledgers by collection priority — weighing outstanding balance, days overdue beyond payment terms, and open transfer count — into low/medium/high risk tiers with a recommended action (monitor, send reminder, send final notice). |
-| **Group Pickup Forecasting** | Projects final pickup vs. wash/attrition for each allotment block ahead of its cutoff date, using current pickup pace and historical pickup rate. Recommends hold / partial-release / full-release with a suggested release quantity to recover unsold inventory. |
-
-### Guest Engagement Agents
-
-| Agent | What It Does |
-|-------|-------------|
-| **Guest Communication** | Template-based lifecycle emails: confirmation, pre-arrival (3 days), day-of arrival, welcome (on check-in), post-stay, and win-back (90 days). **Event-driven drafts** on `reservation.created` / `checked_in` / `checked_out` via `GuestCommsListener`; scheduled types still from manual/cron `guest_comms/run`. Repeat vs first-time personalization. GDPR opt-out enforcement. Duplicate prevention via decision log. Configurable SMTP (defaults to draft-only). Outbound **SMS** and **WhatsApp** templates via Twilio providers (console fallback when not configured). |
-| **Review Response** | Drafts professional responses to guest reviews entered by staff. Keyword-based topic extraction across 10 categories (cleanliness, staff, value, noise, food, wifi, etc.). Sentiment classification from rating (1-2 negative, 3 mixed, 4-5 positive). Three response styles (formal/friendly/casual). Matches guests to reservations for stay-specific references. Template-based assembly — no LLM freeform text, no hallucination risk. |
-
-### Decision Logging & per-property calibration
-
-Every agent decision is persisted (input snapshot, recommendation, confidence, outcome)
-as an audit trail. On top of that, agents **calibrate to each property's own history**:
-running `train()` recomputes an agent's parameters from that hotel's real outcomes and
-stores them in `agent_configs.modelState`, which `analyze()` then uses instead of the
-cold-start defaults.
-
-> **What this is — and isn't (read this).** The agents are **deterministic decision
-> engines**, not LLMs — that's the point: they're the auditable guardrail. "Learning" here
-> means **statistical calibration from your hotel's own data** (e.g. the cancellation agent
-> learns this property's real cancel rates by booking source), not a neural net. Today the
-> cancellation agent calibrates for real; the other history-rich agents follow the same
-> pattern. The customer-comms and review-response agents are deterministic templates (no
-> LLM, no hallucination) and do **not** "learn."
-
-#### What learns vs. what's deterministic
-
-| Agent | Today |
-|---|---|
-| Cancellation Predictor | **Calibrates** per-property cancel rates by source from history |
-| Demand · Overbooking · Channel Mix | Deterministic; same calibration pattern is the roadmap |
-| Pricing · Revenue Manager · Group Pickup · AR · Housekeeping · Night Audit | Deterministic math/rules |
-| Guest Comms · Review Response | Deterministic templates (no LLM) |
-
-**HAIP AI (optional):** a small, purpose-built **local** model (served via Ollama) that
-adds a plain-language *explanation + suggestions* layer over any agent decision — strictly
-grounded in that agent's own numbers, with the deterministic agent vetoing any figure it
-didn't compute (so it can't invent a rate, a policy, or a number). It **explains and
-suggests; it never executes** — approval always runs the agent's own recommendation. It
-runs entirely on the property's own hardware (no cloud calls, no per-use fees, and no guest
-data leaves the building) and is **off by default** — the PMS works fully without it.
-
-Enable it:
-
-```bash
-# pull the model (Apache-2.0, ~5 GB), then turn it on
-ollama pull hf.co/telivity/haip-ai
-export HAIP_AI_ENABLED=true
-export HAIP_AI_MODEL=haip-ai
-```
-
----
-
-## Features
-
-### Shipped product slices
-
-Recent backlog deliveries, mapped to the feature sections below. Each slice is a mergeable vertical cut — API + dashboard + tests — not a rewrite of the domain.
-
-| # | Slice | Merged PR | What it does |
-|---|-------|-----------|--------------|
-| 1 | **Upsells / ancillaries** | [#174](https://github.com/telivityai/haip/pull/174) | Property services catalog, package components, attach extras to a stay, post on check-in / night audit, booking-engine extras step, optional pre-arrival upsell prompt. See **Stay Extras & Packages**. |
-| 2 | **Money policy** | [#175](https://github.com/telivityai/haip/pull/175) | Cancellation policies on rate plans, shared cancel/no-show evaluator, deposit refund/forfeit/apply on cancel · no-show · check-in. See **Reservation Management** and **Accounting & Cashiering**. |
-| 3 | **Front desk stay ops** | [#181](https://github.com/telivityai/haip/pull/181) | Arrivals / in-house queues, walk-in, in-house room move, registration card at check-in, operational notes at the desk. See **Reservation Management**. |
-| 4 | **A/R & cashier polish** | [#180](https://github.com/telivityai/haip/pull/180) | List cash drawers/sessions, A/R ledger CRUD + aging UX, folio→A/R from folio detail, reverse-transfer picker. See **Accounting & Cashiering** and **Folio & Billing**. |
-| 5 | **Commercial profiles** | [#180](https://github.com/telivityai/haip/pull/180) (also [#179](https://github.com/telivityai/haip/pull/179)) | Standing-account billing terms on group profiles; link A/R ledgers and negotiated rates; Commercial dashboard page. See **Groups & Commercial Profiles**. |
-| 6 | **Distribution polish** | [#202](https://github.com/telivityai/haip/pull/202) | Channels dashboard: rate-parity grid + overrides, connection mapping editor, content-push error surfacing. See **Channel Manager**. |
-| 7 | **Guest journey** | [#202](https://github.com/telivityai/haip/pull/202) | Advance pre-register API, reservation SMS via Twilio (or console in demo), loyalty number / VIP display polish. See **Reservation Management** and **Guest Profiles**. |
-| 8 | **Rates depth** | [#183](https://github.com/telivityai/haip/pull/183) | Rate-plan restrictions CRUD (MinLOS / MaxLOS / CTA / CTD / stop-sell + day-of-week overrides); derived-rate create; effective-rate calculator fix; PMS `reservation.create` calls `assertSellable`. See **Rate Plans & Pricing**. |
-| 9 | **Property ops** | [#202](https://github.com/telivityai/haip/pull/202) | Lost & found, room status discrepancy compute, service requests, door-lock credentials, HK checklist / VIP / forecast depth. See **Housekeeping** and **Room Management**. |
-| 10 | **Room discrepancies** | [#205](https://github.com/telivityai/haip/pull/205) | Housekeeping occupancy observation, persisted discrepancy cases, resolve / dismiss, night-audit open-count. See **Room Management**. |
-| 11 | **Demand capture** | [#205](https://github.com/telivityai/haip/pull/205) | Turnaway logging with reason codes + summary; non-deducting waitlist → offer → convert to reservation. See **Reservation Management**. |
-| 12 | **Loyalty ledger** | [#205](https://github.com/telivityai/haip/pull/205) | Organization loyalty program, guest accounts, earn/burn/adjust transactions (folio rebate burn). See **Guest Profiles**. |
-| 13 | **WhatsApp messaging** | [#205](https://github.com/telivityai/haip/pull/205) | Outbound WhatsApp templates via Twilio (console fallback in demo); marketing uses existing GDPR marketing consent. See **Guest Engagement Agents**. |
-| 14 | **Folio inbound posting** | [#205](https://github.com/telivityai/haip/pull/205) | Property-scoped inbound charges (e.g. phone / minibar) to in-house folios with vendor transaction idempotency. See **Folio & Billing**. |
-| 15 | **Booking deep links** | [#205](https://github.com/telivityai/haip/pull/205) | Direct-booking deep-link helper for marketing / metasearch landings; channel activation notes under `docs/channels/`. See **Direct Booking Engine**. |
-| 16 | **Agent orchestration** | [#262](https://github.com/telivityai/haip/pull/262) | Agent dependency graph (`agent-graph.ts`), RManager lever order + `upstreamResults`, external cron via `scripts/cron/agent-runs.sh`, `GET .../graph` + `orchestration-performance`, Revenue UI graph / RManager runs. See **AI Agents** and [`docs/agents-orchestration.md`](./docs/agents-orchestration.md). |
-
-### Direct Booking Engine (commission-free)
-- A **public, guest-facing booking API** (`/api/v1/booking-engine/*`) a hotel puts behind its own website — search → quote → book → pay → confirm — capturing direct reservations with **zero OTA commission**.
-- Authenticated by a per-property **publishable key** (`x-booking-key`): property-scoped, low-trust (it ships in client-side HTML), and restricted to search/quote/book and read-or-cancel-own-confirmation — it can never enumerate other reservations or tenants.
-- Reuses the existing availability, rate, tax, reservation, folio and payment engines — prices are **re-quoted server-side** (never trusts a client price), payments are classified as a **deposit** liability (KB §10.5), and `reservation.created` fires so the channel manager pushes updated availability everywhere.
-- **Embeddable widget app** (`apps/booking`) plus a dashboard **Settings → Booking Engine** tab to generate/rotate keys, choose sellable room types & rates, set branding, and configure the deposit policy.
-- **Deep links** — build guest-facing search/book URLs (property, dates, occupancy, optional room/rate preselect, click attribution) for the hotel’s own booking page.
-
-### Data Migration & Import
-- A generic **CSV import** on-ramp (`/api/v1/import/*`) for hotels switching from another PMS: upload a CSV, map columns, **dry-run** to validate, then commit — with **per-row error reporting** (one bad row never aborts the batch). Importers for guests, room types and rate plans, trivially extensible to more entities.
-
-### Accounting Export
-- Plain **CSV export** of the daily revenue journal and trial balance (`/api/v1/accounting-export/*`) to import into your own books (QuickBooks/Xero/spreadsheet) — no hosted connector required.
-
-### Reservation Management
-- Full lifecycle state machine: `pending → confirmed → assigned → checked_in → stayover → due_out → checked_out`
-- Real-time availability engine with room type inventory
-- Room assignment with automatic status transitions
-- **Front desk stay ops** — arrivals / in-house queues (multi-status filters), walk-in create→assign→check-in, in-house **room move** (`PATCH …/move-room`, respects `doNotMove`), operational notes at the desk and on reservation detail
-- **Guest registration at check-in** — registration card fields + `registrationSigned`; required when the property has `guestRegistrationRequired`
-- Group check-in (batch operations)
-- Bulk actions across multiple reservations (check-in / check-out / cancel) with per-reservation success/error results
-- Reservation notes with active-count tracking
-- Guest messaging from a reservation (email / SMS / WhatsApp templates; GDPR marketing opt-out enforced)
-- Advance **pre-register** fields on a reservation before arrival
-- **Waitlist** — non-deducting entries (offer → convert to a real reservation when inventory allows)
-- **Turnaways** — append-only denied / regret demand with reason codes and summary reporting
-- Unassigned-reservation finder (confirmed/assigned reservations with no room)
-- Batch reservation import with per-row error handling
-- Express checkout
-- **Money policy** — property cancellation policies (free-cancel window, penalty type, deposit handling) linked from rate plans; shared evaluator for PMS / Connect / booking engine; deposit settlement on cancel, no-show, and check-in auto-apply
-- No-show and cancellation handling with policy enforcement (reservation "un-cancel" is intentionally unsupported — a payment-integrity hazard; create a new reservation instead)
-- Every state transition fires a webhook event and is audit-logged
-
-### Stay Extras & Packages (Upsells)
-- Property **services catalog** — sellable extras with charge type, price, posting rule (`once` / `per_night` / `on_consumption` / `included_in_rate`), and sell channels (`booking_engine` / `front_desk` / `pre_arrival`)
-- **Package rate plans** can bundle catalog services as components
-- Attach extras to a reservation from front desk or booking; price snapshot + status lifecycle
-- **Posting** — check-in posts `once` / included lines; night audit posts `per_night` (idempotent); folio routing and tax apply as usual
-- Booking engine + embeddable widget optional extras step; guest-comms can prompt pre-arrival upsells when enabled
-
-### Folio & Billing
-- Guest folios, master folios, and city ledger accounts
-- Charge posting with department codes and revenue categories
-- Charge routing rules (e.g., room & tax to company, incidentals to guest)
-- Charge reversal, transfer between folios, and city ledger transfer
-- Folio settlement and close workflows
-- Charge locking for night audit
-- **Transfer folio balance to A/R** from folio detail (direct-bill handoff)
-- **Inbound posting** — property-scoped webhook posts (e.g. phone / minibar) onto the in-house guest folio, idempotent by vendor transaction id
-
-### Split Folios & House Accounts
-- **Split folio** — multiple folios per reservation with config-driven routing rules (e.g. room & tax → company folio, incidentals → guest folio) and the ability to move transactions between folios individually or by charge type (night-audit-locked charges are protected).
-- **House accounts** — a non-guest ledger for walk-in retail, bar/restaurant, vendor, or internal sales not tied to any reservation. Open/close lifecycle, a product catalog for retail sales, and charge/payment posting on the same unified ledger as folios (keeps room vs. non-room revenue distinct in reports).
-- **Correction matrix** — a payment-state-aware correction policy that picks the safe operation automatically: **void** uncaptured authorizations (and same-day cash), **refund** captured card payments, or post a compensating **adjustment** when neither applies. Illegal overrides (e.g. voiding a captured card) are rejected.
-
-### Groups & Commercial Profiles
-- **Group profiles** — master records for corporate, travel-agent, wholesale, and event business, with an optional group (master) folio and computed group invoices.
-- **Commercial standing accounts** — billing address + payment terms on group profiles; optional links from **A/R ledgers** and **negotiated rate plans** (`groupProfileId`); Commercial dashboard page lists corporate / travel-agent / wholesale accounts and can create a linked A/R ledger (`GET /groups/profiles/:id/commercial`).
-- **Allotment blocks** — hold a quantity of rooms per date and room type at negotiated rates, with cutoff dates, shoulder dates, and Min/Max LOS. Inventory is validated against live availability so a block can't over-allot.
-- **Cutoff & auto-release** — release unsold rooms back to general inventory at the cutoff date, per block or via a sweep endpoint that processes all expired auto-release blocks.
-- **Pickup tracking** — rooms allotted vs. picked up, per date and room type, with pickup rate.
-- **Rooming lists** — batch-import a group's guest roster; each row creates and links a reservation and increments pickup, with per-row success/error handling that never aborts the batch.
-
-### Accounting & Cashiering
-- **Deposit Ledger** — advance deposits tracked as a liability (not revenue) with a full recognition lifecycle: `held → applied` (at check-in/checkout), `refunded`, or `forfeited`. Refundable vs. non-refundable handling, with status-transition guards. Cancel/no-show/check-in settlement follows the property money policy.
-- **Accounts Receivable** — named A/R ledgers for post-stay direct billing; transfer an outstanding folio balance to A/R (zeroing the folio), record A/R payments, reverse transfers with a preserved audit trail, aging buckets (0–30 / 31–60 / 61–90 / 90+), and property-wide aging (`GET /ar/aging`). Dashboard create/close ledgers and reverse-transfer picker via `GET /ar/ledgers/:id/transactions`.
-- **Cash Drawer & Cashiering** — per-drawer cash tracking with shift sessions, cash movements (payment, refund, paid-out, drop), shift close with expected-vs-counted **variance** detection, and a cashier's report. Dashboard lists drawers (`GET /cash/drawers`) and can resume open sessions (`GET /cash/sessions`) using the drawer starting float.
-- **Daily Trial Balance** — reconciliation across the Deposit, Guest, and A/R ledgers (opening + activity = closing).
-- **Custom Accounting Codes** — user-defined transaction and General Ledger (GL) codes for export to external accounting systems.
-
-### Rate Plans & Pricing
-- BAR (Best Available Rate), derived rates, and negotiated rates
-- Rate derivation: amount or percentage adjustments from parent plans
-- Restrictions: MinLOS, MaxLOS, CTA (Closed to Arrival), CTD (Closed to Departure), stop-sell (`isClosed`), optional day-of-week overrides — dashboard detail panel + existing REST sub-resource
-- Effective rate calculation (resolves derived parent chain); calculator UI passes `propertyId` and reads `effectiveRate`
-- PMS reservation create enforces restrictions via `RatePlanService.assertSellable` (same gate as Connect / booking-engine BOOK)
-- Occupancy-based rate adjustments
-
-### Room Management
-- Room type configuration with amenities, max occupancy, and base rates
-- Room status state machine: `vacant_clean → occupied → vacant_dirty → clean → inspected → guest_ready`
-- Connecting room support
-- ADA/accessible room tracking
-- Real-time status summary dashboard
-- Per-room photo and editable features/amenities from the room detail panel (primary image falls back to the room type's photo)
-- **Discrepancy workflow** — housekeeping occupancy observation vs front-office in-house state; open cases with resolve / dismiss; open-count for night audit
-- Optional door-lock credential issuance hooks for in-house stays
-
-### Media & Photos
-- Image management for **properties, room types, and rooms** — a polymorphic `media` model with a denormalized `property_id` on every row for multi-tenant scoping
-- Add images **by URL** (zero infra) or **upload files** to S3-compatible object storage (AWS S3 / MinIO) when configured — the driver is selected by env, so the default demo runs on stock URLs with no storage backend and no committed binaries
-- Per-owner ordering, captions, alt text, and categories (hero, exterior, room, amenity, dining), with a single enforced **primary** image per owner (partial unique index)
-- Dashboard photo galleries wired into Room Types, individual Rooms, and Property Settings — reorder, set-primary, and delete
-- Image mutations are admin-gated; reads are available to any authenticated user
-
-### Guest Profiles
-- Full guest profiles with contact, preferences, company, and stay history
-- ID document fields captured at check-in (type, number, issuing country, expiry) and shown on the guest profile
-- VIP level tracking (standard, silver, gold, platinum, diamond)
-- Loyalty membership number on the guest profile
-- **Loyalty points ledger** — organization-scoped program, guest accounts, earn / burn / adjust transactions (burn as folio rebate)
-- Do Not Rent (DNR) flagging
-- GDPR consent tracking (including marketing opt-in/out) and data retention controls
-- Property setting `guestRegistrationRequired` — when on, check-in requires a signed registration card
-- Guest search with flexible filters
-
-### Housekeeping
-- Task CRUD with 6 task types: checkout clean, stayover, deep clean, inspection, turndown, maintenance
-- Auto-task creation on checkout (event-driven via `room.status_changed`)
-- Digital checklists with templates per task type
-- ADA and VIP-aware checklist augmentation (automatic extra items)
-- Staff assignment, auto-assignment (round-robin by floor/priority), and workload tracking
-- Task lifecycle: `pending → assigned → in_progress → completed → inspected`
-- Inspection pass/fail with automatic re-clean on failure
-- Room status integration — completing a task transitions the room through `clean → inspected → guest_ready`
-- Stayover task generation for occupied rooms
-- **Lost & found** — held / returned / disposed items, including baggage / parcel / valet categories
-- **Service requests** — maintenance and guest-service tickets linked to rooms / reservations
-- Dashboard with room summary, task summary, housekeeper performance, and urgent rooms
-- Analytics: average turn time, median turn time, inspection pass rate, maintenance issue rate, breakdown by room type and housekeeper
-
-### Night Audit & Reporting
-- Automated night audit: room revenue posting, no-show processing, rate validation, day close
-- AI anomaly detection: 8 anomaly types (incl. cash-drawer variance) with severity ranking and confidence scores
-- Daily revenue reports with department breakdown
-- Occupancy reports with ADR (Average Daily Rate) and RevPAR
-- Financial summaries with revenue categories
-- Occupancy trend analysis over date ranges
-- KPI dashboard in the admin UI
-
-### Channel Manager
-- ARI (Availability, Rates, Inventory) push to connected OTAs
-- **Content distribution** — push descriptive content (photos, descriptions, amenities) to OTAs via each adapter's content API, with content-sync logging and **auto-resync** when property or media content changes (`property.content_updated` / `roomtype.content_updated` events)
-- Channel connection management with credentials and mapping
-- Inbound reservation processing from OTA channels
-- Reservation pull from channels
-- Rate parity monitoring and enforcement
-- Rate override capabilities per channel
-- Stop-sell functionality
-- Sync logging for audit trails (ARI **and** content pushes)
-
-#### OTA Adapters
-
-| Adapter | Type | Coverage |
-|---------|------|----------|
-| **Booking.com** | Direct integration | OTA XML for ARI + inbound reservation webhooks/cancellations; JSON **Photo API** for content (photos with validation, plus room/property descriptions & amenities). |
-| **Expedia (EQC)** | Direct integration | EQC Availability & Rates (XML) for ARI, Booking Notification push for inbound reservations, and the **Image API** for content (with Expedia's own image limits). |
-| **SiteMinder** | Aggregator (pmsXchange) | SOAP / OTA XML. Connect once, distribute to 450+ OTAs — ARI push, reservation delivery, rate parity. (Content is managed in the SiteMinder extranet — no PMS content push.) |
-| **DerbySoft** | Aggregator (Property Connector) | REST/JSON + OAuth Bearer. ARI (inventory/rate/availability) with Delta/Overlay, property profile sync, inbound LiveCheck/Book/Modify/Cancel. See [`docs/channels/derbysoft.md`](./docs/channels/derbysoft.md). |
-
-Operator notes for activating existing adapters, metasearch landings on the direct booking engine, and GDS via a channel manager: [`docs/channels/`](./docs/channels/).
-
-### Payments (pluggable gateways)
-- PCI DSS compliant — never stores raw card data
-- Pluggable payment gateway adapters — **Stripe** is the default full path (PaymentIntents, customer creation, tokenization); additional PSP adapters are available via the integration registry
-- Authorization, capture, void, and refund workflows
-- Payment recording with method tracking (card, cash, bank transfer)
-- Linked to folio charges
-
-### Tax Calculation Engine
-- Jurisdiction-based tax rules (state, city, county levels)
-- Tax types: sales tax, occupancy/lodging tax, tourism tax, VAT
-- Inclusive and exclusive tax calculation
-- Rate-based and fixed-amount taxes
-- Tax-exempt guest handling
-- Automatic tax application on charge posting
-- Tax breakdown on folio output
-
-### Authentication & Authorization (Keycloak)
-- OAuth 2.0 / OpenID Connect via Keycloak identity provider
-- JWT validation with RS256 public key verification
-- Keycloak roles (`admin`, `front_desk`, `housekeeping`, `revenue_manager`) **plus HAIP's own local roles & permissions** (see *Users, Roles & Permissions* below)
-- `@Roles()` and `@RequirePermissions()` decorators guard every controller
-- `@Public()` decorator for unauthenticated endpoints (health checks)
-- `@CurrentUser()` decorator for extracting authenticated user context
-
-### Users, Roles & Permissions (Admin Console)
-- **Local identity & authorization** layered on top of Keycloak login — HAIP owns its own `users`, `roles`, `role_permissions`, and `user_roles` tables (property-scoped, multi-tenant)
-- **Code-defined permission catalog** (e.g. `reservations.write`, `rooms.read`, `housekeeping.manage`, `channels.manage`, `media.manage`, `admin.users.manage`) mapped 1:1 to API capabilities and dashboard nav items
-- **Custom roles** — operators create roles and grant granular permissions via a permission matrix; built-in system roles are protected from edits/deletion
-- `PermissionsGuard` + `@RequirePermissions()` augment the Keycloak JWT guard; permissions drive both API authorization **and** which nav items/pages each user sees
-- **Admin console** in Settings: a **Users** tab (create/invite users, assign roles) and a **Roles** tab (permission matrix)
-- Works fully in the demo with `AUTH_ENABLED=false` (all permissions granted); binds to Keycloak subjects when auth is enabled
-
-### Webhook Engine
-- Real-time webhook delivery on every entity state change
-- 73 event types including accounting events (`deposit.received`, `ar.transfer_created`, `cashdrawer.session_closed`), house-account & folio events (`houseaccount.opened`, `folio.transactions_moved`, `payment.corrected`), fiscal document events (`invoice.requested`, `invoice.issued`, `invoice.voided`), group events (`group.block_created`, `group.rooming_list_imported`), reservation-ops events (`reservation.note_added`, `reservation.message_sent`, `reservation.bulk_action_completed`), and AI agent events (`agent.decision_made`, `agent.cancellation_forecast_updated`, `guest.communication_drafted`, `guest.review_response_drafted`)
-- Event format: `entity.action` (e.g., `reservation.created`, `housekeeping.task_completed`)
-- Subscription management for external consumers
-- HMAC-signed deliveries with retries; permanent failures raise a critical staff notification
-- See **[`docs/webhooks.md`](./docs/webhooks.md)** for the integration guide (signature verification, payload conventions, fiscal documents, regional compliance examples)
-
-### ChatGPT Gateway (Connect GPT)
-- A standalone, deployable **gateway that exposes HAIP hotel search & booking as a ChatGPT Custom GPT Action** (`tools/haip-connect-gpt`) — guests search availability and create/modify/cancel reservations by chatting
-- A thin, typed client over HAIP's existing **Connect API** (`/api/v1/connect/*`) — no hotel logic is reimplemented; it builds a ChatGPT-importable **OpenAPI 3.1** spec for 6 operations (`searchHotels`, `getProperty`, `create`/`get`/`modify`/`cancelReservation`)
-- **Secure by design** — the gateway injects HAIP's API key server-side (the GPT never sees it), and response guards ensure **only selling prices** reach the model (net/wholesale/cost stripped)
-- **PII-scrubbed tool-call logging** for training, with an optional Supabase/Postgres sink
-- Host-agnostic — ships as a **Vercel** serverless function, a plain Node server, or a Docker container
-
-### Admin Dashboard
-- React SPA including Dashboard, Front Desk, Reservations, Guests, Rooms, Housekeeping, Folios, Groups, **Commercial**, Cashier, House Accounts, Accounting, Tax, Rate Plans, Night Audit, Reports, Channel Manager, Revenue Management, Communications, Reviews, Settings
-- Revenue Management page: KPI cards, pending AI recommendations with approve/reject, agent performance metrics, per-agent configuration
-- Night Audit page: AI anomaly detection section with severity-coded alerts
-- Communications page: email draft preview, send / approve / reject workflow, manual **Run guest-comms** for scheduled drafts, delivery stats
-- Reviews page: add reviews, AI-drafted responses, edit/approve/mark-posted workflow, rating stats
-- Settings page: property settings (incl. registration-card requirement), photo gallery, plus **Users & Roles administration** (user management + permission matrix)
-- Reservations detail: operational notes + compose guest email (GDPR marketing flag enforced server-side)
-- Rooms & Room Types: photo galleries with primary/reorder, per-room editable features
-- Real-time updates via WebSocket (new reservations, room status changes, AI agent decisions)
-- Responsive layout with mobile sidebar drawer
-- Calendar view for reservations (day/week/month)
-- Housekeeping kanban board
-- KPI cards with trend indicators
-- Skeleton loading states, toast notifications, error boundaries
-- Served as static files in production (single Docker image)
-
----
-
-## Tech Stack
-
-| Layer | Technology | Purpose |
-|-------|-----------|---------|
-| Language | TypeScript (strict mode) | End-to-end type safety |
-| Runtime | Node.js ≥ 20 | Server runtime |
-| API Framework | NestJS | Modules, DI, decorators, OpenAPI generation |
-| Frontend | React 19 + Vite | Admin dashboard SPA |
-| UI State | TanStack Query + TanStack Router | Server state + file-based routing |
-| Styling | Tailwind CSS 4 | Utility-first CSS |
-| Database | PostgreSQL 16 | Multi-tenant relational storage |
-| ORM | Drizzle ORM | TypeScript-native, no magic |
-| Cache & Queue | Redis 7 + BullMQ | Caching, job queues, pub/sub |
-| Real-time | Socket.IO (via NestJS Gateway) | WebSocket broadcasting per property |
-| API Spec | OpenAPI 3.0 (auto-generated) | Swagger UI at `/docs` |
-| Auth | Keycloak (OAuth 2.0 / OIDC) | Identity provider, JWT, RBAC |
-| Payments | Pluggable gateways (Stripe default) | PCI DSS compliant tokenization; additional PSP adapters |
-| OTA Channels | Booking.com + Expedia (EQC) + SiteMinder + DerbySoft | Direct + aggregated OTA connectivity (ARI + content) |
-| XML Processing | fast-xml-parser | Booking.com OTA XML protocol |
-| Package Manager | pnpm workspaces | Monorepo management |
-| Testing | Vitest (1417 tests across 200 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |
-| Containers | Docker + docker-compose | Local dev and production deployment |
-| CI/CD | GitHub Actions | Automated testing, builds, and releases |
-
----
-
-## Quick Start
-
-### 🚀 Try it in one command
-
-The only prerequisite is **Docker**. No Node, no pnpm, no API keys, no config.
-
-```bash
-git clone https://github.com/TelivityAI/haip.git
-cd haip
-docker compose up
-```
-
-That's it. Compose builds the image, initializes the database, **seeds a full demo
-hotel with the AI agents already running**, and serves everything at one URL:
-
-- **Dashboard:** `http://localhost:3000`
-- **Swagger / OpenAPI:** `http://localhost:3000/docs`
-- **Booking:** `http://localhost:3000/booking/` — live guest booking widget (search → quote → book)
-- **Preview page:** `http://localhost:3000/booking-preview.html` — optional static walkthrough of every booking screen
-
-**Wire a shipped or adapter integration in one command** (API must be up):
-
-```bash
-./integrations/demos/run.sh list shipped # 21 product paths
-./integrations/demos/run.sh list adapters # 37 console fiscal/guest-reg packs
-./integrations/demos/run.sh stripe # toggle ON + mock/console path
-./integrations/demos/run.sh all shipped
-```
-
-Each pack has \`GO_LIVE.md\` for live credentials. Details: [\`integrations/demos/\`](./integrations/demos/).
-
-Dashboard UI: English and Deutsch (header language switcher).
-
-The first run builds the image and can take a few minutes; subsequent starts are
-fast. A one-shot `init` container pushes the schema and seeds the demo before the
-API starts (idempotent — safe to re-run). Auth is **off by default** so you land
-straight in the app; to explore the Keycloak login/RBAC flow instead, run:
-
-```bash
-docker compose -f docker-compose.yml -f docker-compose.auth.yml --profile auth up -d --build
-```
-
-See [`docs/deployment.md`](./docs/deployment.md) for production self-host.
-
-> One-click cloud demo: see [Deploy to the cloud](#deploy-to-the-cloud) to spin up
-> a hosted instance with zero local setup.
-
-### Production self-host
-
-See **[`docs/deployment.md`](./docs/deployment.md)** for the full production guide (compose files, env vars, TLS, backups, upgrades, GHCR images).
-
-Quick start:
-
-```bash
-cp .env.production.example .env.production
-# Edit .env.production — set DATABASE_URL, Stripe keys, Keycloak, CONNECT_API_KEY, etc.
-
-docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile auth up -d --build
-```
-
-Auth is on (`AUTH_ENABLED=true`); do not set `HAIP_ALLOW_INSECURE`.
-
-### Local development
-
-For hot-reload development against the source (requires **Node ≥ 20** and **pnpm ≥ 9**):
-
-```bash
-git clone https://github.com/TelivityAI/haip.git
-cd haip
-
-# Start only the infra (Postgres + Redis); add `keycloak` if testing auth
-docker compose up -d postgres redis
-
-pnpm install
-cp .env.example .env # defaults work as-is — no keys needed for a demo
-
-pnpm build # build workspace packages
-pnpm db:migrate # push schema (same as --filter @telivityhaip/database run migrate)
-pnpm seed # seed demo data
-
-pnpm dev # API with hot reload (:3000)
-pnpm --filter @telivityhaip/dashboard dev # dashboard dev server (:5173)
-pnpm --filter @telivityhaip/booking dev # booking widget dev server (:5174)
-```
-
-| Service | URL |
-|---------|-----|
-| API | `http://localhost:3000` |
-| Dashboard (dev) | `http://localhost:5173` |
-| Booking widget (dev) | `http://localhost:5174` |
-| Keycloak (`--profile auth`) | `http://localhost:8080` |
-
-> **Local dev dashboard is `:5173`, not `:3000`.** The API at `:3000` serves Swagger
-> and the REST API only unless you set `SERVE_DASHBOARD=true` on the API process.
-
-> Payments default to `STRIPE_MODE=mock` and guest email to draft-only, so no
-> Stripe or SMTP credentials are required. Set real keys in `.env` only when you
-> want live payments/email.
-
-### Deploy to the cloud
-
-One-click deploy a hosted demo (provisions Postgres + Redis, builds, migrates, and
-seeds automatically) using the included [`render.yaml`](./render.yaml) blueprint:
-
-[](https://render.com/deploy?repo=https://github.com/TelivityAI/haip)
-
-Any Docker host works too — the [`apps/api/Dockerfile`](./apps/api/Dockerfile)
-builds a single image serving the API, dashboard (`/`), and booking engine (`/booking/`) on port 3000.
-
-### Production checklist
-
-Before going live, verify the items in [`docs/deployment.md`](./docs/deployment.md#required-environment-variables) and [`docs/operations/cron.md`](./docs/operations/cron.md).
-
-### Troubleshooting
-
-| Symptom | Fix |
-|---------|-----|
-| API won't start locally | Is Postgres/Redis up? `docker compose up -d postgres redis` |
-| Dashboard empty in local dev | You opened `:3000` instead of `:5173` |
-| `migrate` script missing on api package | Use `@telivityhaip/database`, not api: `pnpm db:migrate` |
-| Docker API unhealthy | Check init logs: `docker compose logs init` (migrate/seed may have failed) |
-| Booking returns 401 with auth on | Copy the booking key from **Settings → Booking Engine** (or pass `?key=` in the URL) |
-
-### Run tests
-
-```bash
-# All tests (1417 tests across 200 test files)
-
-# API tests only
-pnpm --filter @telivityhaip/api test
-
-# Dashboard component tests
-pnpm --filter @telivityhaip/dashboard test
-
-# Type checking
-pnpm typecheck
-
-# Linting
-pnpm lint
-```
-
----
-
-## Demo Hotel — Telivity Grand Hotel
-
-The seed script creates a fully configured demo property with realistic data:
-
-| Entity | Count | Details |
-|--------|-------|---------|
-| Property | 1 | Telivity Grand Hotel — 5-star, 40 rooms, Miami Beach, USD, America/New_York |
-| Room Types | 4 | Standard King, Deluxe Ocean View, Junior Suite, Penthouse Suite |
-| Rooms | 40 | Across 4 floors with real room numbers, mixed statuses, 2 ADA-accessible |
-| Rate Plans | 5 | BAR per room type + a seasonal promotional rate, with LOS/weekend restrictions |
-| Guests | 15 | Mix of VIP levels (none through diamond), international, loyalty tiers |
-| Reservations | 23 | Past, in-house, arrivals, future, no-show, and cancelled |
-| Folios | 16 | With charges (room, tax, minibar, spa) and payments |
-| Housekeeping Tasks | 18 | Checkout cleans, stayovers, deep cleans, inspections with checklists |
-| Tax Profiles | 4 | Miami Beach 13%, Barcelona IVA+tourist, Amsterdam BTW+tourist, Berlin split |
-| **AI Agents** | **12** | **Enabled in suggest mode — incl. the Revenue Manager orchestrator** |
-| **Agent Decisions** | **10** | **A live decision log: revenue strategy, pricing, forecast, overbooking…** |
-| **Guest Reviews** | **5** | **Two with AI-drafted responses, the rest awaiting action** |
-
-Everything is populated on first boot, so the dashboard — including the AI layer
-(Revenue Manager, agent decision log, review responses) — is alive immediately,
-not empty. Use it to explore the API and dashboard right after `docker compose up`.
-
----
-
-## Project Structure
-
-```
-haip/
-├── apps/
-│ ├── api/ # NestJS API application
-│ │ ├── src/
-│ │ │ ├── database/ # Drizzle connection + module
-│ │ │ └── modules/
-│ │ │ ├── agent/ # AI Agent framework
-│ │ │ │ ├── demand/ # Demand forecasting agent
-│ │ │ │ ├── pricing/ # Dynamic pricing agent
-│ │ │ │ ├── channel-mix/# Channel-mix optimization agent
-│ │ │ │ ├── overbooking/# Overbooking management agent
-│ │ │ │ ├── night-audit/# Night audit anomaly detection agent
-│ │ │ │ ├── housekeeping/# Housekeeping optimization agent
-│ │ │ │ ├── cancellation/# Cancellation prediction agent
-│ │ │ │ ├── guest-comms/ # Guest communication agent + email service
-│ │ │ │ ├── review-response/ # Review response agent
-│ │ │ │ ├── training/ # Agent training/learning utilities
-│ │ │ │ ├── interfaces/ # HaipAgent interface definition
-│ │ │ │ └── dto/ # Agent config + decision DTOs
-│ │ │ ├── admin/ # Local users, roles & permissions (admin console)
-│ │ │ ├── auth/ # Keycloak JWT + RBAC + permission guards
-│ │ │ ├── channel/ # Channel manager (ARI, content, rate parity)
-│ │ │ │ └── adapters/ # OTA channel adapters
-│ │ │ │ ├── booking-com/ # Booking.com (OTA XML + Photo API)
-│ │ │ │ ├── expedia/ # Expedia EQC (AR XML + Image API)
-│ │ │ │ ├── siteminder/ # SiteMinder pmsXchange (SOAP/OTA XML)
-│ │ │ │ └── derbysoft/ # DerbySoft Property Connector (REST/JSON)
-│ │ │ ├── connect/ # OTAIP agent API layer
-│ │ │ ├── media/ # Images for property / room types / rooms (URL + S3)
-│ │ │ ├── events/ # WebSocket gateway
-│ │ │ ├── folio/ # Folios, charges, routing, city ledger
-│ │ │ ├── guest/ # Guest profiles, VIP, preferences
-│ │ │ ├── health/ # Health check
-│ │ │ ├── housekeeping/ # Tasks, checklists, inspection, dashboard
-│ │ │ ├── night-audit/ # Automated night audit + day close
-│ │ │ ├── payment/ # Pluggable payment gateways (Stripe default)
-│ │ │ ├── property/ # Multi-property configuration
-│ │ │ ├── rate-plan/ # Rates, derivation, restrictions
-│ │ │ ├── reports/ # Revenue, occupancy, financial reports
-│ │ │ ├── reservation/ # Booking lifecycle + availability
-│ │ │ ├── room/ # Room types, inventory, status machine
-│ │ │ ├── tax/ # Tax calculation engine
-│ │ │ └── webhook/ # Event dispatch engine
-│ │ ├── Dockerfile # Multi-stage build (API + Dashboard)
-│ │ └── tsconfig.json
-│ └── dashboard/ # React admin dashboard
-│ ├── src/
-│ │ ├── components/ # Layout, UI primitives, modals
-│ │ ├── context/ # PropertyContext for multi-tenant
-│ │ ├── hooks/ # useApi, useSocket, data hooks
-│ │ ├── pages/ # 15 page components
-│ │ └── lib/ # API client, utilities
-│ ├── tailwind.config.ts
-│ └── vite.config.ts
-├── packages/
-│ ├── database/ # Drizzle ORM schema + migrations
-│ │ └── src/schema/ # Table files (property, room, guest, agent, etc.)
-│ └── shared/ # Shared types, enums, webhook events
-├── tools/
-│ └── haip-connect-gpt/ # ChatGPT Custom GPT gateway over the Connect API (Vercel)
-├── docker-compose.yml # PostgreSQL + Redis + Keycloak + API
-├── CLAUDE.md # AI agent constitution
-└── .env.example # Environment template
-```
-
----
-
-## API Reference
-
-All endpoints are prefixed with `/api/v1/` and documented via OpenAPI 3.0. Run the API and visit `http://localhost:3000/docs` for the interactive Swagger UI.
-
-### Core Endpoints (~167 total)
-
-
-AI Agents — 16 endpoints
-
-```
-GET /api/v1/agents/:propertyId # List all agents with status
-GET /api/v1/agents/:propertyId/graph # Dependency graph (nodes + edges + status)
-GET /api/v1/agents/:propertyId/orchestration-performance # All-agent metrics + RManager summary
-GET /api/v1/agents/:propertyId/:agentType/config # Get agent configuration
-PUT /api/v1/agents/:propertyId/:agentType/config # Update config (mode, enabled, threshold)
-POST /api/v1/agents/:propertyId/:agentType/run # Run agent (?triggeredBy=manual|schedule)
-POST /api/v1/agents/:propertyId/:agentType/train # Train one agent (writes modelState)
-POST /api/v1/agents/:propertyId/train-all # Train every enabled agent
-GET /api/v1/agents/:propertyId/:agentType/decisions # Decision history
-POST /api/v1/agents/:propertyId/decisions/:id/approve # Approve recommendation
-POST /api/v1/agents/:propertyId/decisions/:id/reject # Reject recommendation
-POST /api/v1/agents/:propertyId/decisions/:id/explain # HAIP AI grounded explanation
-GET /api/v1/agents/:propertyId/:agentType/performance # Performance metrics
-POST /api/v1/agents/:propertyId/reviews # Submit guest review
-GET /api/v1/agents/:propertyId/reviews # List reviews (filter by status/source)
-PATCH /api/v1/agents/:propertyId/reviews/:id # Update review response
-```
-
-
-
-Reservations — 21 endpoints
-
-```
-POST /api/v1/reservations/search-availability # Real-time availability check
-POST /api/v1/reservations/group-check-in # Batch check-in
-POST /api/v1/reservations/bulk-action # Bulk check-in/out/cancel
-GET /api/v1/reservations/unassigned # Find unassigned reservations
-POST /api/v1/reservations/import # Batch import reservations
-POST /api/v1/reservations # Create reservation
-GET /api/v1/reservations # List (filtered, paginated)
-GET /api/v1/reservations/:id # Get with guest/room/rate
-PATCH /api/v1/reservations/:id # Modify dates/room/rate
-PATCH /api/v1/reservations/:id/confirm # Confirm
-PATCH /api/v1/reservations/:id/assign-room # Assign room
-PATCH /api/v1/reservations/:id/check-in # Check in
-PATCH /api/v1/reservations/:id/check-out # Check out
-POST /api/v1/reservations/:id/express-checkout # Express checkout
-PATCH /api/v1/reservations/:id/cancel # Cancel
-PATCH /api/v1/reservations/:id/no-show # Mark no-show
-POST /api/v1/reservations/:id/notes # Add a note
-GET /api/v1/reservations/:id/notes # List notes (with active count)
-PATCH /api/v1/reservations/notes/:noteId # Update a note
-DELETE /api/v1/reservations/notes/:noteId # Delete a note
-POST /api/v1/reservations/:id/messages # Send a message to the guest
-```
-
-
-
-Folios & Billing — 12 endpoints
-
-```
-POST /api/v1/folios # Create folio
-GET /api/v1/folios # List with filters
-GET /api/v1/folios/:id # Get folio
-PATCH /api/v1/folios/:id # Update folio
-PATCH /api/v1/folios/:id/settle # Settle folio
-PATCH /api/v1/folios/:id/close # Close folio
-POST /api/v1/folios/:id/charges # Post charge
-GET /api/v1/folios/:id/charges # List charges
-POST /api/v1/folios/:id/charges/:chargeId/reverse # Reverse charge
-POST /api/v1/folios/:id/charges/lock # Lock charges
-POST /api/v1/folios/:id/transfer-charge # Transfer charge
-POST /api/v1/folios/:id/transfer-to-city-ledger # Transfer to city ledger
-POST /api/v1/folios/routing-rules # Create a split-folio routing rule
-GET /api/v1/folios/routing-rules # List routing rules (by reservation)
-POST /api/v1/folios/:id/move-transactions # Move charges between folios
-```
-
-
-
-House Accounts & Products — 11 endpoints
-
-```
-POST /api/v1/products # Create retail product
-GET /api/v1/products # List products
-GET /api/v1/products/:id # Get product
-PATCH /api/v1/products/:id # Update product
-POST /api/v1/house-accounts # Open a house account
-GET /api/v1/house-accounts # List house accounts
-GET /api/v1/house-accounts/:id # Get house account
-POST /api/v1/house-accounts/:id/close # Close house account
-POST /api/v1/house-accounts/:id/charges # Post a charge
-POST /api/v1/house-accounts/:id/payments # Record a payment
-POST /api/v1/house-accounts/:id/sell # Sell a product (retail)
-```
-
-
-
-Groups & Allotment — 16 endpoints
-
-```
-# Group Profiles
-POST /api/v1/groups/profiles # Create group profile
-GET /api/v1/groups/profiles # List group profiles
-GET /api/v1/groups/profiles/:id # Get group profile
-GET /api/v1/groups/profiles/:id/commercial # Commercial links (A/R + rates)
-PATCH /api/v1/groups/profiles/:id # Update group profile
-POST /api/v1/groups/profiles/:id/reservations # Link a reservation to the group
-GET /api/v1/groups/profiles/:id/folio # Get the group (master) folio
-POST /api/v1/groups/profiles/:id/invoice # Generate a group invoice
-# Allotment Blocks
-POST /api/v1/groups/blocks # Create allotment block
-GET /api/v1/groups/blocks # List blocks
-POST /api/v1/groups/blocks/process-cutoffs # Release all expired auto-release blocks
-GET /api/v1/groups/blocks/:id # Get block
-PATCH /api/v1/groups/blocks/:id # Update block
-PUT /api/v1/groups/blocks/:id/inventory # Set per-date/room-type allotment
-GET /api/v1/groups/blocks/:id/pickup # Pickup vs. allotted
-POST /api/v1/groups/blocks/:id/release # Release block (free unsold rooms)
-POST /api/v1/groups/blocks/:id/rooming-list # Import rooming list
-```
-
-
-
-Accounting — Deposits, A/R & GL Codes — 20 endpoints
-
-```
-# Deposit Ledger
-POST /api/v1/deposits # Record a deposit (held liability)
-GET /api/v1/deposits # List deposits (filtered)
-GET /api/v1/deposits/:id # Get deposit
-POST /api/v1/deposits/:id/apply # Apply deposit to folio (recognize)
-POST /api/v1/deposits/:id/refund # Refund a refundable deposit
-POST /api/v1/deposits/:id/forfeit # Forfeit (non-refundable → revenue)
-# Accounts Receivable
-POST /api/v1/ar/ledgers # Create A/R ledger
-GET /api/v1/ar/ledgers # List A/R ledgers
-GET /api/v1/ar/ledgers/:id # Get A/R ledger
-PATCH /api/v1/ar/ledgers/:id # Update A/R ledger
-POST /api/v1/ar/ledgers/:id/close # Close A/R ledger
-POST /api/v1/ar/transfer # Transfer folio balance to A/R (zero folio)
-POST /api/v1/ar/transactions/:id/reverse # Reverse a transfer (audit-safe)
-POST /api/v1/ar/ledgers/:id/payments # Record an A/R payment
-GET /api/v1/ar/aging # Property-wide aging buckets
-GET /api/v1/ar/ledgers/:id/aging # Aging buckets (0–30/31–60/61–90/90+)
-GET /api/v1/ar/ledgers/:id/transactions # List A/R ledger transactions
-# Accounting Codes
-POST /api/v1/accounting/codes # Create transaction/GL code
-GET /api/v1/accounting/codes # List codes
-GET /api/v1/accounting/codes/:id # Get code
-PATCH /api/v1/accounting/codes/:id # Update code
-POST /api/v1/accounting/codes/:id/archive # Archive code
-```
-
-
-
-Cashier — Cash Drawer — 7 endpoints
-
-```
-POST /api/v1/cash/drawers # Create cash drawer
-GET /api/v1/cash/drawers # List drawers for property
-GET /api/v1/cash/drawers/:id # Get drawer
-POST /api/v1/cash/sessions # Open a cashier shift session
-GET /api/v1/cash/sessions # List sessions (filter: drawer, status)
-GET /api/v1/cash/sessions/:id # Get session
-POST /api/v1/cash/sessions/:id/movements # Record cash movement
-POST /api/v1/cash/sessions/:id/close # Close shift (variance check)
-GET /api/v1/cash/sessions/:id/report # Cashier's report
-```
-
-
-
-Housekeeping — 15 endpoints
-
-```
-GET /api/v1/housekeeping/dashboard # Room + task + staff summary
-GET /api/v1/housekeeping/analytics # Turn times, pass rates, trends
-POST /api/v1/housekeeping/generate-stayover-tasks # Generate daily stayover tasks
-POST /api/v1/housekeeping/auto-assign # Round-robin task assignment
-POST /api/v1/housekeeping/tasks # Create task
-GET /api/v1/housekeeping/tasks # List tasks (filtered)
-GET /api/v1/housekeeping/tasks/:id # Get task
-PATCH /api/v1/housekeeping/tasks/:id # Update task
-DELETE /api/v1/housekeeping/tasks/:id # Delete task
-PATCH /api/v1/housekeeping/tasks/:id/assign # Assign to housekeeper
-PATCH /api/v1/housekeeping/tasks/:id/start # Start task
-PATCH /api/v1/housekeeping/tasks/:id/unassign # Unassign task
-PATCH /api/v1/housekeeping/tasks/:id/complete # Complete with checklist
-PATCH /api/v1/housekeeping/tasks/:id/inspect # Inspect (pass/fail)
-PATCH /api/v1/housekeeping/tasks/:id/skip # Skip task
-```
-
-
-
-Channel Manager — 17 endpoints
-
-```
-POST /api/v1/channels/connections # Create channel connection
-GET /api/v1/channels/connections # List connections
-GET /api/v1/channels/connections/:id # Get connection
-PATCH /api/v1/channels/connections/:id # Update connection
-DELETE /api/v1/channels/connections/:id # Deactivate connection
-POST /api/v1/channels/connections/:id/test # Test connection
-POST /api/v1/channels/push/availability # Push availability to OTAs
-POST /api/v1/channels/push/rates # Push rates/restrictions
-POST /api/v1/channels/push/full # Push full ARI
-POST /api/v1/channels/push/stop-sell # Push stop-sell
-GET /api/v1/channels/sync-logs/:connectionId # Get sync logs
-POST /api/v1/channels/inbound/reservation # Process OTA reservation
-POST /api/v1/channels/inbound/pull # Pull reservations
-GET /api/v1/channels/rate-parity # Check rate parity
-GET /api/v1/channels/rate-parity/effective-rate # Get effective rate
-POST /api/v1/channels/rate-parity/override # Set rate override
-DELETE /api/v1/channels/rate-parity/override # Remove override
-```
-
-
-
-Tax Calculation — 4 endpoints
-
-```
-POST /api/v1/tax/calculate # Calculate taxes for a charge
-GET /api/v1/tax/rules # List tax rules for property
-POST /api/v1/tax/rules # Create tax rule
-PATCH /api/v1/tax/rules/:id # Update tax rule
-```
-
-
-
-Guests, Rooms, Rate Plans, Payments, Reports, Night Audit, Properties
-
-```
-# Guests — 5 endpoints
-POST /api/v1/guests # Create guest
-GET /api/v1/guests # Search guests
-GET /api/v1/guests/:id # Get guest
-PATCH /api/v1/guests/:id # Update guest
-DELETE /api/v1/guests/:id # Delete guest
-
-# Rooms — 10 endpoints
-POST /api/v1/rooms/types # Create room type
-GET /api/v1/rooms/types # List room types
-GET /api/v1/rooms/types/:id # Get room type
-GET /api/v1/rooms/status-summary # Status summary counts
-GET /api/v1/rooms/by-status # Rooms filtered by status
-POST /api/v1/rooms # Create room
-GET /api/v1/rooms # List rooms
-GET /api/v1/rooms/:id # Get room
-PATCH /api/v1/rooms/:id # Update room
-PATCH /api/v1/rooms/:id/status # Transition room status
-
-# Rate Plans — 9 endpoints
-POST /api/v1/rate-plans # Create rate plan
-GET /api/v1/rate-plans # List rate plans
-GET /api/v1/rate-plans/:id # Get rate plan
-GET /api/v1/rate-plans/:id/effective-rate # Calculate effective rate
-PATCH /api/v1/rate-plans/:id # Update rate plan
-GET /api/v1/rate-plans/:id/restrictions # Get restrictions
-POST /api/v1/rate-plans/:id/restrictions # Create restriction
-PATCH /api/v1/rate-plans/:id/restrictions/:rid # Update restriction
-DELETE /api/v1/rate-plans/:id/restrictions/:rid # Delete restriction
-
-# Payments — 7 endpoints
-POST /api/v1/payments # Record payment
-POST /api/v1/payments/authorize # Authorize card
-GET /api/v1/payments # List payments
-GET /api/v1/payments/:id # Get payment
-POST /api/v1/payments/:id/capture # Capture authorized
-POST /api/v1/payments/:id/void # Void payment
-POST /api/v1/payments/:id/refund # Refund payment
-POST /api/v1/payments/:id/correct # Correct payment (void/refund/adjust)
-
-# Reports — 5 endpoints
-GET /api/v1/reports/daily-revenue # Daily revenue report
-GET /api/v1/reports/trial-balance # Daily trial balance (deposit/guest/A-R)
-GET /api/v1/reports/occupancy # Occupancy report
-GET /api/v1/reports/financial-summary # Financial summary
-GET /api/v1/reports/occupancy-trend # Occupancy trend
-
-# Night Audit — 3 endpoints
-POST /api/v1/night-audit/run # Execute night audit
-GET /api/v1/night-audit/runs # List audit runs
-GET /api/v1/night-audit/runs/:id # Get audit run
-
-# Properties — 4 endpoints
-POST /api/v1/properties # Create property
-GET /api/v1/properties # List properties
-GET /api/v1/properties/:id # Get property
-PATCH /api/v1/properties/:id # Update property
-```
-
-
----
-
-## OTAIP Integration — Connect API
-
-The Connect API (`/api/v1/connect/`) is purpose-built for [OTAIP](https://github.com/telivity-otaip/otaip) AI agents. It provides higher-level abstractions over the core PMS endpoints, designed for agent workflows.
-
-### Agent Endpoints
-
-```
-# Property Search (Agent 4.1 — Hotel Search)
-POST /api/v1/connect/search # Search properties by criteria
-GET /api/v1/connect/properties # List all properties
-GET /api/v1/connect/properties/:id # Full property detail
-
-# Booking (Agent 4.5 — Booking)
-POST /api/v1/connect/book # Book a room
-GET /api/v1/connect/bookings/:confirmationNumber/verify # Verify booking
-PATCH /api/v1/connect/bookings/:confirmationNumber # Modify booking
-DELETE /api/v1/connect/bookings/:confirmationNumber # Cancel booking
-
-# Event Subscriptions
-POST /api/v1/connect/subscriptions # Subscribe to events
-GET /api/v1/connect/subscriptions # List subscriptions
-DELETE /api/v1/connect/subscriptions/:id # Unsubscribe
-POST /api/v1/connect/subscriptions/:id/test # Test subscription
-GET /api/v1/connect/events # Poll for events
-
-# AI Insights
-GET /api/v1/connect/insights/revenue # Revenue optimization hints
-GET /api/v1/connect/insights/guest-triggers # Guest communication triggers
-GET /api/v1/connect/insights/housekeeping # Housekeeping optimization
-```
-
-### How OTAIP Connects
-
-OTAIP's `packages/connect` will have a HAIP adapter (like `AmadeusAdapter`, `DuffelAdapter`). Lodging agents (Domain 4) connect to HAIP via this adapter. Both projects share TypeScript, pnpm, Vitest, and strict TS config.
-
-```
-OTAIP Agent → ConnectAdapter (HAIP) → Connect API → HAIP PMS → PostgreSQL
-```
-
-The PMS is the product. OTAIP agents are the intelligence on top.
-
-> **ChatGPT too** — `tools/haip-connect-gpt` wraps this same Connect API as a ChatGPT Custom GPT Action (OpenAPI 3.1, server-side key injection, selling-price-only responses), so a guest can search and book straight from ChatGPT. See [`tools/haip-connect-gpt/README.md`](./tools/haip-connect-gpt/README.md).
-
----
-
-## WebSocket — Real-time Events
-
-HAIP uses Socket.IO for real-time event broadcasting to the dashboard and other connected clients.
-
-### Connection
-
-```typescript
-import { io } from 'socket.io-client';
-
-const socket = io('http://localhost:3000');
-
-// Join a property room to receive its events
-socket.emit('joinProperty', { propertyId: 'uuid-here' });
-
-// Listen for events
-socket.on('reservation.created', (data) => { /* ... */ });
-socket.on('room.status_changed', (data) => { /* ... */ });
-socket.on('housekeeping.task_completed', (data) => { /* ... */ });
-socket.on('agent.decision_made', (data) => { /* ... */ });
-```
-
-### Broadcast Events
-
-All webhook events are simultaneously broadcast via WebSocket to clients subscribed to the relevant property. Events follow the `entity.action` naming convention:
-
-- `reservation.created`, `reservation.checked_in`, `reservation.checked_out`
-- `room.status_changed`
-- `housekeeping.task_assigned`, `housekeeping.task_completed`, `housekeeping.ai_assigned`
-- `folio.charge_posted`
-- `channel.sync_completed`
-- `agent.decision_made`, `agent.cancellation_forecast_updated`
-- `guest.communication_drafted`, `guest.communication_sent`, `guest.review_response_drafted`
-
----
-
-## Security & Compliance (Built In, Not Bolted On)
-
-HAIP is multi-tenant by construction, and isolation is enforced **in depth** — at the request boundary *and* the data layer — so a bug in one place isn't a breach.
-
-| Area | How HAIP Handles It |
-|------|-------------------|
-| **Tenant isolation** | `property_id` on every table. A global guard binds each authenticated request to the `propertyId` it targets (fail-closed), and every property-scoped query independently filters by `propertyId`. Caller-supplied foreign keys are verified to belong to the tenant before any write. |
-| **Authentication & authorization** | Keycloak OIDC (RS256 JWT) **plus** HAIP's own local users/roles/permissions; `@Roles` + `@RequirePermissions` guards on every endpoint. The Connect (agent) API uses per-property API credentials stored as hashes; inbound OTA webhooks are authenticated per connection (Basic-Auth / HMAC). |
-| **Input & transport hardening** | Strict DTO validation (incl. positive-amount checks on monetary fields and UUID-validated ids), an origin **CORS allowlist**, security response headers, **SSRF protection** on outbound webhooks, and rate limiting. The API **refuses to boot with insecure defaults in production** (auth off / mock payments) unless explicitly opted in for the demo. |
-| **PCI DSS** | Never stores raw card data. Stripe tokenization via PaymentIntents. Payments table stores token + last four + brand. |
-| **GDPR** | Audit trail (with actor) on every data modification, consent tracking fields, data retention and right-to-erasure APIs. |
-| **Guest Registration** | Configurable per jurisdiction. ID verification fields. EU police reporting interface planned. |
-| **Tax Calculation** | Jurisdiction-based tax engine. Inclusive/exclusive handling. Sales, occupancy, tourism, and VAT tax types. Per-property rules with exemption support. |
-
-> Security is treated as ongoing work: the codebase is reviewed continuously and hardened as it matures. Before production use, pair it with your own deployment review and a Keycloak configured to issue the `property_ids` claim.
-
----
-
-## Integrations
-
-HAIP is **API-first**: the same REST surface that powers the dashboard is documented via auto-generated **OpenAPI 3.0** at `/docs`, state changes fan out through **HMAC-signed webhooks**, and external systems connect through **pluggable adapters** (channels, payments, messaging, accounting, and more).
-
-See **[`docs/INTEGRATIONS.md`](./docs/INTEGRATIONS.md)** for the full integration catalog (~230 integrations across 19 categories). To wire your stack, start with **[`docs/webhooks.md`](./docs/webhooks.md)** (signature verification, payloads, retries) and the recipes under **[`docs/integrations/`](./docs/integrations/)**. **Fiscalization and guest registration** are first-class categories — so properties can plug in invoice issuance and authority reporting without custom one-offs.
-
----
-
-## Community & Support
-
-Discord is our main support channel. Questions, bugs, feature ideas, or just
-want to talk to the team? Join us:
-
-👉 https://discord.gg/rJQJ5Rh5kH
-
-For confirmed bugs and feature requests, please still open a GitHub issue so we
-can track it. For everything else, Discord is the fastest way to reach us.
-
----
-
-## Contributing
-
-HAIP is built in public and contributions are welcome.
-
-### The One Rule
-
-**DO NOT INVENT HOTEL DOMAIN LOGIC.** All hotel domain knowledge must be researched and verified. Do not invent hotel operations logic. If unsure, open an issue. Don't guess — hotels are weirder than you think.
-
-### How to contribute
-
-1. Check the [open issues](https://github.com/TelivityAI/haip/issues)
-2. Read `CLAUDE.md` for code standards and conventions
-3. Read the relevant KB section before writing business logic
-4. Fork, branch, PR — tests required for all business logic
-5. One module at a time. Don't boil the ocean.
-
-### Development commands
+Before going live, verify [`docs/deployment.md`](./docs/deployment.md) and [`docs/operations/cron.md`](./docs/operations/cron.md). Run the operator harden pack ([`ops/harden/`](./ops/harden/)):
```bash
-pnpm install # Install dependencies
-pnpm build # Build all workspace packages
-pnpm dev # Start API in dev mode (hot reload)
-pnpm test # Run all tests (1417 tests, 200 files)
-pnpm lint # ESLint
+pnpm harden:local
+pnpm harden:live
```
----
-
-## License
-
-Licensed under the [Apache License, Version 2.0](LICENSE).
-
-Copyright 2026 Telivity. You may use, modify, and distribute this software under the terms of the Apache 2.0 license. See the [LICENSE](LICENSE) file for the full text.
-
----
-
-
- Built by Telivity — open-source travel infrastructure for the AI era.
-
+See also [`ops/harden/PRODUCTION.md`](./ops/harden/PRODUCTION.md).
diff --git a/ops/harden/.env.harden.example b/ops/harden/.env.harden.example
new file mode 100644
index 0000000..cb029b4
--- /dev/null
+++ b/ops/harden/.env.harden.example
@@ -0,0 +1,28 @@
+# HAIP operator harden — live probe env
+#
+# cp ops/harden/.env.harden.example .env.harden
+# # edit values, then:
+# set -a && source .env.harden && set +a
+# pnpm harden:live
+#
+# Do NOT commit .env.harden — it contains bearer tokens.
+
+# API base ending in /api (compose default shown)
+HAIP_API_BASE=http://localhost:3000/api
+
+# Keycloak bearer JWTs (property_ids claim must match the property below)
+TOKEN_A=
+TOKEN_B=
+
+# Property UUIDs
+PROPERTY_A=
+PROPERTY_B=
+
+# Optional: reservation that belongs only to property B
+# RESERVATION_IN_B=
+
+# Optional: JWT missing property_ids or from wrong issuer (expect 401)
+# TOKEN_BAD=
+
+# Optional: override request timeout ms (default 15000)
+# HARDEN_TIMEOUT_MS=15000
diff --git a/ops/harden/CHECKLIST.md b/ops/harden/CHECKLIST.md
new file mode 100644
index 0000000..6966506
--- /dev/null
+++ b/ops/harden/CHECKLIST.md
@@ -0,0 +1,56 @@
+# Production go-live checklist
+
+Use this before exposing HAIP to real guests or chargeable traffic.
+Details: [`docs/deployment.md`](../../docs/deployment.md) and
+[`.env.production.example`](../../.env.production.example).
+
+## Compose & boot
+
+- [ ] Copied `.env.production.example` → `.env.production` and filled secrets
+- [ ] Started with prod overlay + auth profile:
+ ```bash
+ docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile auth up -d --build
+ ```
+- [ ] `AUTH_ENABLED=true` (required in production)
+- [ ] `HAIP_ALLOW_INSECURE` is **unset / empty** (never `true` in production)
+- [ ] `STRIPE_MODE` is `test` until ready for real charges; then `live` with live keys
+- [ ] API boots cleanly; `GET /api/v1/health` returns `status: ok`
+
+## Auth (Keycloak)
+
+- [ ] Keycloak runs in production mode with TLS (not `start-dev` exposed publicly)
+- [ ] Strong Keycloak admin credentials (changed from defaults)
+- [ ] Realm `haip` (or your realm) issues JWTs with `property_ids` and `roles`
+- [ ] Unauthenticated `GET /api/v1/reservations?propertyId=` → **401**
+- [ ] Dashboard built / served with auth on (`VITE_AUTH_ENABLED=true` in prod overlay)
+
+## Multi-tenancy
+
+- [ ] At least two test properties exist
+- [ ] User A JWT has only property A; user B only property B
+- [ ] `pnpm harden:live` passes tenant-isolation probes (see [`TENANT_ISOLATION.md`](./TENANT_ISOLATION.md))
+- [ ] SPA routes always include `propertyId` on detail pages
+
+## Payments & Connect
+
+- [ ] Stripe secret + webhook secret match `STRIPE_MODE`
+- [ ] Stripe webhook endpoint receives events (signature verified)
+- [ ] `CONNECT_API_KEY` set when auth is on (OTAIP / Connect agents)
+- [ ] Booking engine keys generated under Settings → Booking Engine (when auth on)
+
+## Network & ops
+
+- [ ] TLS terminated at reverse proxy (Caddy/nginx/etc.)
+- [ ] `CORS_ORIGINS` set if browser origin ≠ API host
+- [ ] Night audit / group cutoff cron configured ([`docs/operations/cron.md`](../../docs/operations/cron.md))
+- [ ] Database backup + restore tested once
+- [ ] Rate limiting considered for public origins
+
+## Product smoke (manual)
+
+- [ ] [`SURFACE_SMOKE.md`](./SURFACE_SMOKE.md) completed on property A — zero Critical on check-in, rooms, folios, night audit, housekeeping
+- [ ] At least three desk [`vignettes/`](./vignettes/) exercised (recommend `base-01`, `base-07`, `base-15`)
+
+## Gate
+
+Do **not** put real hotel traffic on the instance while Critical surface failures or failed tenant-isolation probes remain open.
diff --git a/ops/harden/PRODUCTION.md b/ops/harden/PRODUCTION.md
new file mode 100644
index 0000000..7281b8f
--- /dev/null
+++ b/ops/harden/PRODUCTION.md
@@ -0,0 +1,10 @@
+# Harden pack production pointer
+
+Before going live, run:
+
+```bash
+pnpm harden:local
+pnpm harden:live
+```
+
+See [`ops/harden/`](./) and the root README Production checklist.
diff --git a/ops/harden/README.md b/ops/harden/README.md
new file mode 100644
index 0000000..22ac129
--- /dev/null
+++ b/ops/harden/README.md
@@ -0,0 +1,79 @@
+# HAIP operator harden pack
+
+Playbooks and a small CLI so operators can harden a **self-hosted HAIP**
+deployment (Keycloak + docker compose) before go-live and against a live URL.
+
+## What this is
+
+| Piece | Purpose |
+|-------|---------|
+| [`CHECKLIST.md`](./CHECKLIST.md) | Production go-live checklist |
+| [`TENANT_ISOLATION.md`](./TENANT_ISOLATION.md) | Cross-tenant deny criteria (Keycloak JWTs) |
+| [`SURFACE_SMOKE.md`](./SURFACE_SMOKE.md) | Full SPA surface walk before chargeable use |
+| [`vignettes/`](./vignettes/) | Desk ops stories (guest → staff → delight/block) |
+| [`cli/`](./cli/) | `harden:local` and `harden:live` HTTP probes |
+
+## Quick start
+
+### Pre-go-live (local / compose)
+
+From the HAIP repo root:
+
+```bash
+cp .env.production.example .env.production
+# Edit .env.production — AUTH_ENABLED=true, Stripe, CONNECT_API_KEY, etc.
+
+docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile auth up -d --build
+
+pnpm harden:local
+```
+
+Then walk [`SURFACE_SMOKE.md`](./SURFACE_SMOKE.md) and a few [`vignettes/`](./vignettes/) against the dashboard.
+
+### Live instance
+
+```bash
+cp ops/harden/.env.harden.example .env.harden
+# Fill HAIP_API_BASE, TOKEN_A, TOKEN_B, PROPERTY_A, PROPERTY_B
+
+set -a && source .env.harden && set +a
+pnpm harden:live
+```
+
+Exit code `0` = all probes passed; non-zero = at least one fail. See the printed table.
+
+## Keycloak tokens
+
+Tokens must be JWTs issued by your Keycloak realm (`haip` by default) for the
+API client (`haip-api`). Claims the API expects:
+
+| Claim | Meaning |
+|-------|---------|
+| `property_ids` | Array of property UUIDs this user may access |
+| `roles` | HAIP roles (e.g. `admin`, `front_desk`, `readonly`) |
+
+**User A** should have `property_ids=[PROPERTY_A]` only.
+**User B** should have `property_ids=[PROPERTY_B]` only.
+
+How you mint tokens is up to your deployment — the CLI only needs the bearer strings.
+
+`HAIP_API_BASE` is the API origin **including** `/api` (compose default: `http://localhost:3000/api`).
+Probes call `{HAIP_API_BASE}/v1/...`.
+
+## Modes
+
+| Command | Needs running stack? | Needs tokens? |
+|---------|----------------------|---------------|
+| `pnpm harden:local` | Preferred (for HTTP checks); file checks always run | Optional |
+| `pnpm harden:live` | Yes (your URL) | **Required** |
+
+## Manual ops (after CLI green)
+
+1. Complete [`SURFACE_SMOKE.md`](./SURFACE_SMOKE.md) on property A.
+2. Run several vignettes from [`vignettes/`](./vignettes/) (start with `base-01`, `base-07`, `base-15`).
+3. Re-run `pnpm harden:live` after any auth or multi-tenancy change.
+
+## Non-goals
+
+- Automated full-SPA Playwright campaigns (use surface smoke + vignettes manually in v1)
+- Exhaustive penetration testing — this pack is an operator readiness gate, not a red-team suite
diff --git a/ops/harden/SURFACE_SMOKE.md b/ops/harden/SURFACE_SMOKE.md
new file mode 100644
index 0000000..d473aa5
--- /dev/null
+++ b/ops/harden/SURFACE_SMOKE.md
@@ -0,0 +1,61 @@
+# Full-surface smoke (self-hosted HAIP)
+
+Run this on every candidate production (or staging twin) deploy before putting
+real guests or chargeable traffic on the instance. Desk vignettes alone are
+**not** enough.
+
+## Environment
+
+- Dashboard served by your HAIP API (compose: `http://localhost:3000`)
+- Sign in via Keycloak with a staff/admin user for **property A**
+- Always open routes with `?propertyId=`
+- Never put passwords in notes or screenshots you share
+
+## Rule
+
+Click the real control. If nothing happens → **BROKEN**.
+Pretty empty page with no path to do the job → **EMPTY_SHELL**.
+Nav that lies / no feedback after a critical action → **CONFUSING**.
+
+Classifications: `BROKEN` | `EMPTY_SHELL` | `CONFUSING` | `PARTIAL` | `WORKS`
+
+## Checklist (engine SPA — no skipping)
+
+| # | Surface | Route | Must prove |
+|---|---------|-------|------------|
+| 1 | Dashboard | `/` | Loads for the property; not a dead shell |
+| 2 | Check-in / Front desk | `/front-desk` | Confirm check-in completes; walk-in path works; clear errors |
+| 3 | Reservations | `/reservations` | List + open a reservation; create/modify path clear |
+| 4 | Guests | `/guests` | Profile useful mid-shift (stay context / notes — not name-only) |
+| 5 | Rooms status | `/rooms` | Change status sticks + visible feedback |
+| 6 | Room types | `/rooms/types` | Types list/edit operable |
+| 7 | Housekeeping | `/housekeeping` | Tasks load (or clear actionable empty + generate works) |
+| 8 | Folios | `/folios` | See bill; post/settle path clear |
+| 9 | Cashier | `/cashier` | Post/settle flow usable when folio has balance |
+| 10 | Night audit | `/night-audit` | After run: success state + history; not “run again?” forever |
+| 11 | Rate plans | `/rate-plans` | Calculate/edits not decorative |
+| 12 | Reports | `/reports` | Primary reports load; errors visible |
+| 13 | Groups | `/groups` | Group block list/detail operable |
+| 14 | Channels | `/channels` | Connection path or honest “not configured” |
+| 15 | Communications | `/communications` | Connected path or honest “not configured” |
+| 16 | Reviews | `/reviews` | Sync/manual path clear — not a dead empty |
+| 17 | Settings / Users | `/settings` | Property settings + users/roles operable |
+| 18 | Booking admin | Settings → Booking Engine | Booking key generate/rotate when auth on |
+| 19 | Import | `/import` | Import entry honest (works or clear empty) |
+
+Also smoke Walk-In party link + Guest Details — but **never instead of** this list.
+Pair with [`vignettes/`](./vignettes/) for desk realism.
+
+## Outputs (suggested)
+
+Write under a local folder you keep private (e.g. `harden-runs/YYYY-MM-DD-surface-smoke/`):
+
+1. `AUDIT.json` — `{id, surface, route, classification, notes}` × each row
+2. `SUMMARY.md` — counts of WORKS / BROKEN / EMPTY_SHELL / …
+3. Screenshots only if useful; scrub guest PII
+
+## Gate
+
+**Invalid run:** skipped surfaces, or vignette-only run labeled as full product smoke.
+**Valid run:** every row attempted.
+**Go-live:** zero Critical on check-in, room status, folios, night-audit feedback, housekeeping.
diff --git a/ops/harden/TENANT_ISOLATION.md b/ops/harden/TENANT_ISOLATION.md
new file mode 100644
index 0000000..00674a1
--- /dev/null
+++ b/ops/harden/TENANT_ISOLATION.md
@@ -0,0 +1,49 @@
+# Tenant isolation gate (self-hosted HAIP)
+
+Run before enabling real hotel tenants. Uses two Keycloak users / properties.
+
+## Prerequisites
+
+- HAIP API with `AUTH_ENABLED=true`
+- Two Keycloak users whose JWTs include:
+ - User A: `property_ids=[PROPERTY_A]`, roles include staff or `admin`
+ - User B: `property_ids=[PROPERTY_B]`
+- Environment (see [`.env.harden.example`](./.env.harden.example)):
+
+| Variable | Meaning |
+|----------|---------|
+| `HAIP_API_BASE` | API base ending in `/api` (e.g. `http://localhost:3000/api`) |
+| `TOKEN_A` / `TOKEN_B` | Bearer JWTs for users A and B |
+| `PROPERTY_A` / `PROPERTY_B` | Property UUIDs |
+| `RESERVATION_IN_B` | Optional — reservation id that belongs only to B |
+
+## Automated probe
+
+```bash
+set -a && source .env.harden && set +a
+pnpm harden:live
+```
+
+## Pass criteria
+
+1. `GET /v1/health` → **200** with `status: ok` (public)
+2. `GET /v1/reservations?propertyId=PROPERTY_A` with **no** token → **401**
+3. `GET /v1/reservations?propertyId=PROPERTY_B` with `TOKEN_A` → **403** (or **401** if claim missing)
+4. `GET /v1/reservations?propertyId=PROPERTY_A` with `TOKEN_A` → **200** (A may read A)
+5. If `RESERVATION_IN_B` is set: `GET /v1/reservations/RESERVATION_IN_B?propertyId=PROPERTY_B` with `TOKEN_A` → **403** or **404**
+6. Token without usable `property_ids` / wrong issuer → **401** (configure a bad token as `TOKEN_BAD` optional)
+
+## Multi-property owner invariants (same account)
+
+An owner JWT may hold `property_ids=[A, B]`. Still required:
+
+1. Request with `propertyId=A` + entity id that only exists under B → **404**
+2. `POST /reservations` with `propertyId=A` and a `guestId` only linked at B → **404**
+3. SPA property switch clears cached detail data; detail routes key by `propertyId`
+
+These owner invariants are checklist items in v1 (exercise manually or with your own scripts).
+The CLI covers the two-user cross-tenant deny path above.
+
+## After changes
+
+Re-run `pnpm harden:live` after any change to auth guards, property scoping, or JWT claim mapping.
diff --git a/ops/harden/cli/harden.mjs b/ops/harden/cli/harden.mjs
new file mode 100644
index 0000000..b8589f0
--- /dev/null
+++ b/ops/harden/cli/harden.mjs
@@ -0,0 +1,102 @@
+#!/usr/bin/env node
+/**
+ * HAIP operator harden CLI
+ *
+ * node ops/harden/cli/harden.mjs local
+ * node ops/harden/cli/harden.mjs live
+ * node ops/harden/cli/harden.mjs all
+ *
+ * Env: see ops/harden/.env.harden.example
+ */
+
+import { printReport, env } from './lib.mjs';
+import { runLocalFileProbes } from './probes/local.mjs';
+import { runHealthProbes } from './probes/health.mjs';
+import { runAuthOnProbes } from './probes/auth-on.mjs';
+import { runTenantIsolationProbes } from './probes/tenant-isolation.mjs';
+
+function usage() {
+ console.log(`Usage: haip-harden
+
+ local File/compose checklist (+ optional HTTP if HAIP_API_BASE is set)
+ live Health + auth-on + tenant-isolation against HAIP_API_BASE
+ all local then live
+
+Env: copy ops/harden/.env.harden.example → .env.harden and source it.
+`);
+}
+
+async function runLive() {
+ /** @type {import('./lib.mjs').ProbeResult[]} */
+ const results = [];
+ results.push(...(await runHealthProbes()));
+ results.push(...(await runAuthOnProbes()));
+ results.push(...(await runTenantIsolationProbes()));
+ return results;
+}
+
+async function runLocal() {
+ /** @type {import('./lib.mjs').ProbeResult[]} */
+ const results = [...(await runLocalFileProbes())];
+
+ // If API base is configured, also hit health (and auth/tenant when tokens present)
+ if (env('HAIP_API_BASE')) {
+ console.log('HAIP_API_BASE set — running HTTP probes against local/target API…');
+ results.push(...(await runHealthProbes()));
+ if (env('PROPERTY_A')) {
+ results.push(...(await runAuthOnProbes()));
+ }
+ if (env('TOKEN_A') && env('TOKEN_B') && env('PROPERTY_A') && env('PROPERTY_B')) {
+ results.push(...(await runTenantIsolationProbes()));
+ } else {
+ results.push({
+ id: 'tenant-live-optional',
+ ok: true,
+ skip: true,
+ detail: 'TOKEN_A/B + PROPERTY_A/B not all set — skipped live tenant probes',
+ });
+ }
+ } else {
+ results.push({
+ id: 'http-optional',
+ ok: true,
+ skip: true,
+ detail: 'HAIP_API_BASE not set — file checks only; set it to probe a running API',
+ });
+ }
+
+ return results;
+}
+
+async function main() {
+ const mode = (process.argv[2] ?? '').toLowerCase();
+ if (!['local', 'live', 'all'].includes(mode)) {
+ usage();
+ process.exit(2);
+ }
+
+ console.log(`HAIP harden — mode=${mode}`);
+
+ /** @type {import('./lib.mjs').ProbeResult[]} */
+ let results = [];
+ if (mode === 'local' || mode === 'all') {
+ console.log('\n== local ==');
+ results = results.concat(await runLocal());
+ }
+ if (mode === 'live' || mode === 'all') {
+ console.log('\n== live ==');
+ if (!env('HAIP_API_BASE')) {
+ console.error('HAIP_API_BASE is required for live mode');
+ process.exit(2);
+ }
+ results = results.concat(await runLive());
+ }
+
+ const passed = printReport(results);
+ process.exit(passed ? 0 : 1);
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/ops/harden/cli/lib.mjs b/ops/harden/cli/lib.mjs
new file mode 100644
index 0000000..769fdf6
--- /dev/null
+++ b/ops/harden/cli/lib.mjs
@@ -0,0 +1,97 @@
+/**
+ * Shared helpers for HAIP operator harden probes.
+ * Plain Node fetch — no third-party auth SDKs.
+ */
+
+export function env(name, fallback) {
+ const v = process.env[name];
+ if (v === undefined || v === '') return fallback;
+ return v;
+}
+
+export function requireEnv(name) {
+ const v = process.env[name];
+ if (!v) {
+ throw new Error(`Missing required env: ${name}`);
+ }
+ return v;
+}
+
+/** Normalize to base ending without trailing slash; expect .../api */
+export function apiBase() {
+ const raw = requireEnv('HAIP_API_BASE').replace(/\/+$/, '');
+ return raw;
+}
+
+export function timeoutMs() {
+ const n = Number(env('HARDEN_TIMEOUT_MS', '15000'));
+ return Number.isFinite(n) && n > 0 ? n : 15000;
+}
+
+/**
+ * @param {string} path - path under API base, e.g. `/v1/health`
+ * @param {{ method?: string, token?: string | null, headers?: Record }} [opts]
+ */
+export async function request(path, opts = {}) {
+ const base = apiBase();
+ const url = `${base}${path.startsWith('/') ? path : `/${path}`}`;
+ const headers = { Accept: 'application/json', ...(opts.headers ?? {}) };
+ if (opts.token) {
+ headers.Authorization = `Bearer ${opts.token}`;
+ }
+ const res = await fetch(url, {
+ method: opts.method ?? 'GET',
+ headers,
+ signal: AbortSignal.timeout(timeoutMs()),
+ });
+ let bodyText = '';
+ try {
+ bodyText = await res.text();
+ } catch {
+ bodyText = '';
+ }
+ let json = null;
+ try {
+ json = bodyText ? JSON.parse(bodyText) : null;
+ } catch {
+ json = null;
+ }
+ return { url, status: res.status, ok: res.ok, json, bodyText };
+}
+
+/**
+ * @typedef {{ id: string, ok: boolean, detail: string, skip?: boolean }} ProbeResult
+ */
+
+/** @param {ProbeResult[]} results */
+export function printReport(results) {
+ const width = Math.max(...results.map((r) => r.id.length), 8);
+ console.log('');
+ console.log(`${'PROBE'.padEnd(width)} RESULT DETAIL`);
+ console.log(`${'-'.repeat(width)} ------ ------`);
+ for (const r of results) {
+ const label = r.skip ? 'SKIP' : r.ok ? 'PASS' : 'FAIL';
+ console.log(`${r.id.padEnd(width)} ${label.padEnd(6)} ${r.detail}`);
+ }
+ const failed = results.filter((r) => !r.skip && !r.ok);
+ const skipped = results.filter((r) => r.skip);
+ console.log('');
+ console.log(
+ `Summary: ${results.length - failed.length - skipped.length} pass, ${failed.length} fail, ${skipped.length} skip`,
+ );
+ return failed.length === 0;
+}
+
+/** @param {boolean} expected @param {number} status @param {number[]} codes */
+export function statusIn(status, codes) {
+ return codes.includes(status);
+}
+
+import { fileURLToPath } from 'node:url';
+import path from 'node:path';
+
+export function repoRootFromCli() {
+ // ops/harden/cli → repo root is ../../..
+ const cliDir = path.dirname(fileURLToPath(import.meta.url));
+ return path.resolve(cliDir, '../../..');
+}
diff --git a/ops/harden/cli/package.json b/ops/harden/cli/package.json
new file mode 100644
index 0000000..641e16b
--- /dev/null
+++ b/ops/harden/cli/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "@telivityhaip/harden-cli",
+ "version": "0.1.0",
+ "private": true,
+ "license": "Apache-2.0",
+ "type": "module",
+ "bin": {
+ "haip-harden": "./harden.mjs"
+ },
+ "scripts": {
+ "harden": "node harden.mjs",
+ "harden:local": "node harden.mjs local",
+ "harden:live": "node harden.mjs live"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+}
diff --git a/ops/harden/cli/probes/auth-on.mjs b/ops/harden/cli/probes/auth-on.mjs
new file mode 100644
index 0000000..da979dd
--- /dev/null
+++ b/ops/harden/cli/probes/auth-on.mjs
@@ -0,0 +1,71 @@
+import { env, request, statusIn } from '../lib.mjs';
+
+/** @returns {Promise} */
+export async function runAuthOnProbes() {
+ /** @type {import('../lib.mjs').ProbeResult[]} */
+ const results = [];
+ const propertyA = env('PROPERTY_A');
+
+ if (!propertyA) {
+ results.push({
+ id: 'auth-unauthenticated',
+ ok: false,
+ skip: true,
+ detail: 'PROPERTY_A not set — skip unauth probe',
+ });
+ return results;
+ }
+
+ try {
+ const res = await request(
+ `/v1/reservations?propertyId=${encodeURIComponent(propertyA)}`,
+ );
+ const ok = statusIn(res.status, [401]);
+ results.push({
+ id: 'auth-unauthenticated',
+ ok,
+ detail: ok
+ ? `no token → ${res.status}`
+ : `expected 401 without token, got ${res.status}`,
+ });
+ } catch (err) {
+ results.push({
+ id: 'auth-unauthenticated',
+ ok: false,
+ detail: `request failed: ${err instanceof Error ? err.message : String(err)}`,
+ });
+ }
+
+ const tokenBad = env('TOKEN_BAD');
+ if (tokenBad) {
+ try {
+ const res = await request(
+ `/v1/reservations?propertyId=${encodeURIComponent(propertyA)}`,
+ { token: tokenBad },
+ );
+ const ok = statusIn(res.status, [401]);
+ results.push({
+ id: 'auth-bad-token',
+ ok,
+ detail: ok
+ ? `TOKEN_BAD → ${res.status}`
+ : `expected 401 for TOKEN_BAD, got ${res.status}`,
+ });
+ } catch (err) {
+ results.push({
+ id: 'auth-bad-token',
+ ok: false,
+ detail: `request failed: ${err instanceof Error ? err.message : String(err)}`,
+ });
+ }
+ } else {
+ results.push({
+ id: 'auth-bad-token',
+ ok: true,
+ skip: true,
+ detail: 'TOKEN_BAD not set',
+ });
+ }
+
+ return results;
+}
diff --git a/ops/harden/cli/probes/health.mjs b/ops/harden/cli/probes/health.mjs
new file mode 100644
index 0000000..2d01034
--- /dev/null
+++ b/ops/harden/cli/probes/health.mjs
@@ -0,0 +1,27 @@
+import { request, statusIn } from '../lib.mjs';
+
+/** @returns {Promise} */
+export async function runHealthProbes() {
+ /** @type {import('../lib.mjs').ProbeResult[]} */
+ const results = [];
+ try {
+ const res = await request('/v1/health');
+ const ok =
+ statusIn(res.status, [200]) &&
+ (res.json?.status === 'ok' || res.bodyText.includes('"ok"'));
+ results.push({
+ id: 'health',
+ ok,
+ detail: ok
+ ? `${res.status} ${res.url}`
+ : `expected 200 status=ok, got ${res.status} ${res.bodyText.slice(0, 120)}`,
+ });
+ } catch (err) {
+ results.push({
+ id: 'health',
+ ok: false,
+ detail: `request failed: ${err instanceof Error ? err.message : String(err)}`,
+ });
+ }
+ return results;
+}
diff --git a/ops/harden/cli/probes/local.mjs b/ops/harden/cli/probes/local.mjs
new file mode 100644
index 0000000..73cc7c4
--- /dev/null
+++ b/ops/harden/cli/probes/local.mjs
@@ -0,0 +1,111 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { repoRootFromCli } from '../lib.mjs';
+
+/**
+ * Local / pre-go-live file and compose checks.
+ * @returns {Promise
+ */
+export async function runLocalFileProbes() {
+ const root = repoRootFromCli();
+ /** @type {import('../lib.mjs').ProbeResult[]} */
+ const results = [];
+
+ const requiredFiles = [
+ 'docker-compose.yml',
+ 'docker-compose.prod.yml',
+ 'docker-compose.auth.yml',
+ '.env.production.example',
+ 'docs/deployment.md',
+ 'ops/harden/CHECKLIST.md',
+ 'ops/harden/TENANT_ISOLATION.md',
+ 'ops/harden/SURFACE_SMOKE.md',
+ ];
+
+ for (const rel of requiredFiles) {
+ const full = path.join(root, rel);
+ const ok = fs.existsSync(full);
+ results.push({
+ id: `file:${rel}`,
+ ok,
+ detail: ok ? 'present' : `missing at ${full}`,
+ });
+ }
+
+ // .env.production — warn if missing (operators may use other secret injection)
+ const envProd = path.join(root, '.env.production');
+ if (fs.existsSync(envProd)) {
+ const text = fs.readFileSync(envProd, 'utf8');
+ const checks = [
+ { id: 'env:AUTH_ENABLED', re: /^\s*AUTH_ENABLED\s*=\s*true\s*$/m },
+ {
+ id: 'env:no-insecure',
+ re: null,
+ ok: !/^\s*HAIP_ALLOW_INSECURE\s*=\s*true\s*$/m.test(text),
+ detailFail: 'HAIP_ALLOW_INSECURE=true must not be set in production',
+ },
+ {
+ id: 'env:DATABASE_URL',
+ re: /^\s*DATABASE_URL\s*=\s*.+/m,
+ },
+ {
+ id: 'env:REDIS_URL',
+ re: /^\s*REDIS_URL\s*=\s*.+/m,
+ },
+ ];
+ for (const c of checks) {
+ if (c.re) {
+ const ok = c.re.test(text);
+ results.push({
+ id: c.id,
+ ok,
+ detail: ok ? 'ok' : `check failed in .env.production`,
+ });
+ } else {
+ results.push({
+ id: c.id,
+ ok: c.ok,
+ detail: c.ok ? 'ok' : c.detailFail,
+ });
+ }
+ }
+ } else {
+ results.push({
+ id: 'env:.env.production',
+ ok: true,
+ skip: true,
+ detail:
+ 'no .env.production yet — copy from .env.production.example before go-live',
+ });
+ }
+
+ // Prod compose must force AUTH_ENABLED
+ const prodCompose = path.join(root, 'docker-compose.prod.yml');
+ if (fs.existsSync(prodCompose)) {
+ const text = fs.readFileSync(prodCompose, 'utf8');
+ const authOn = /AUTH_ENABLED:\s*['"]?true['"]?/.test(text);
+ results.push({
+ id: 'compose:prod-auth',
+ ok: authOn,
+ detail: authOn
+ ? 'docker-compose.prod.yml sets AUTH_ENABLED=true'
+ : 'docker-compose.prod.yml should set AUTH_ENABLED=true',
+ });
+ }
+
+ // Vignette pack present
+ const vignetteDir = path.join(root, 'ops/harden/vignettes');
+ let vignetteCount = 0;
+ if (fs.existsSync(vignetteDir)) {
+ vignetteCount = fs
+ .readdirSync(vignetteDir)
+ .filter((f) => f.startsWith('base-') && f.endsWith('.md')).length;
+ }
+ results.push({
+ id: 'vignettes',
+ ok: vignetteCount >= 20,
+ detail: `${vignetteCount} vignette files in ops/harden/vignettes`,
+ });
+
+ return results;
+}
diff --git a/ops/harden/cli/probes/tenant-isolation.mjs b/ops/harden/cli/probes/tenant-isolation.mjs
new file mode 100644
index 0000000..2fc923a
--- /dev/null
+++ b/ops/harden/cli/probes/tenant-isolation.mjs
@@ -0,0 +1,139 @@
+import { env, requireEnv, request, statusIn } from '../lib.mjs';
+
+/** @returns {Promise} */
+export async function runTenantIsolationProbes() {
+ /** @type {import('../lib.mjs').ProbeResult[]} */
+ const results = [];
+
+ let tokenA;
+ let tokenB;
+ let propertyA;
+ let propertyB;
+ try {
+ tokenA = requireEnv('TOKEN_A');
+ tokenB = requireEnv('TOKEN_B');
+ propertyA = requireEnv('PROPERTY_A');
+ propertyB = requireEnv('PROPERTY_B');
+ } catch (err) {
+ results.push({
+ id: 'tenant-env',
+ ok: false,
+ detail: err instanceof Error ? err.message : String(err),
+ });
+ return results;
+ }
+
+ if (propertyA === propertyB) {
+ results.push({
+ id: 'tenant-env',
+ ok: false,
+ detail: 'PROPERTY_A and PROPERTY_B must be different UUIDs',
+ });
+ return results;
+ }
+
+ results.push({
+ id: 'tenant-env',
+ ok: true,
+ detail: 'TOKEN_A/B and PROPERTY_A/B present',
+ });
+
+ // A may read A
+ try {
+ const res = await request(
+ `/v1/reservations?propertyId=${encodeURIComponent(propertyA)}`,
+ { token: tokenA },
+ );
+ const ok = statusIn(res.status, [200]);
+ results.push({
+ id: 'tenant-a-reads-a',
+ ok,
+ detail: ok
+ ? `TOKEN_A + PROPERTY_A → ${res.status}`
+ : `expected 200, got ${res.status}`,
+ });
+ } catch (err) {
+ results.push({
+ id: 'tenant-a-reads-a',
+ ok: false,
+ detail: `request failed: ${err instanceof Error ? err.message : String(err)}`,
+ });
+ }
+
+ // A must not read B
+ try {
+ const res = await request(
+ `/v1/reservations?propertyId=${encodeURIComponent(propertyB)}`,
+ { token: tokenA },
+ );
+ const ok = statusIn(res.status, [403, 401]);
+ results.push({
+ id: 'tenant-a-denied-b',
+ ok,
+ detail: ok
+ ? `TOKEN_A + PROPERTY_B → ${res.status}`
+ : `expected 403 or 401, got ${res.status}`,
+ });
+ } catch (err) {
+ results.push({
+ id: 'tenant-a-denied-b',
+ ok: false,
+ detail: `request failed: ${err instanceof Error ? err.message : String(err)}`,
+ });
+ }
+
+ // B may read B (sanity)
+ try {
+ const res = await request(
+ `/v1/reservations?propertyId=${encodeURIComponent(propertyB)}`,
+ { token: tokenB },
+ );
+ const ok = statusIn(res.status, [200]);
+ results.push({
+ id: 'tenant-b-reads-b',
+ ok,
+ detail: ok
+ ? `TOKEN_B + PROPERTY_B → ${res.status}`
+ : `expected 200, got ${res.status}`,
+ });
+ } catch (err) {
+ results.push({
+ id: 'tenant-b-reads-b',
+ ok: false,
+ detail: `request failed: ${err instanceof Error ? err.message : String(err)}`,
+ });
+ }
+
+ const reservationInB = env('RESERVATION_IN_B');
+ if (reservationInB) {
+ try {
+ const res = await request(
+ `/v1/reservations/${encodeURIComponent(reservationInB)}?propertyId=${encodeURIComponent(propertyB)}`,
+ { token: tokenA },
+ );
+ const ok = statusIn(res.status, [403, 404, 401]);
+ results.push({
+ id: 'tenant-a-denied-res-b',
+ ok,
+ detail: ok
+ ? `TOKEN_A + RESERVATION_IN_B → ${res.status}`
+ : `expected 403/404/401, got ${res.status}`,
+ });
+ } catch (err) {
+ results.push({
+ id: 'tenant-a-denied-res-b',
+ ok: false,
+ detail: `request failed: ${err instanceof Error ? err.message : String(err)}`,
+ });
+ }
+ } else {
+ results.push({
+ id: 'tenant-a-denied-res-b',
+ ok: true,
+ skip: true,
+ detail: 'RESERVATION_IN_B not set',
+ });
+ }
+
+ return results;
+}
diff --git a/ops/harden/vignettes/README.md b/ops/harden/vignettes/README.md
new file mode 100644
index 0000000..d8ab2ce
--- /dev/null
+++ b/ops/harden/vignettes/README.md
@@ -0,0 +1,30 @@
+# Desk ops vignettes
+
+Guest situation → staff role uses HAIP → delight or learn what blocked it.
+
+Run these **after** [`../SURFACE_SMOKE.md`](../SURFACE_SMOKE.md). They do not replace it.
+
+| File | Theme |
+|------|--------|
+| `base-01-multi-room-walk-in.md` | Two rooms, one party (BLOCKER weight) |
+| `base-02-accompanying-same-room.md` | Couple walk-in, both names |
+| `base-03-arrival-unassigned-reg-card.md` | Arrival, room unassigned |
+| `base-04-inhouse-guest-details.md` | Folio / accompanying from stay |
+| `base-05-room-move-dnm.md` | Quiet room move / DNM |
+| `base-06-early-checkout-zero-balance.md` | Early checkout, $0 |
+| `base-07-checkout-blocked-balance.md` | Due out with balance |
+| `base-08-group-multi-res-checkin.md` | Group arrival |
+| `base-09-vip-loyalty-recognition.md` | VIP recognition |
+| `base-10-notes-handover.md` | Shift handover notes |
+| `base-11-locale-pt-BR-walk-in.md` | pt-BR desk UI |
+| `base-12-near-full-second-type.md` | Second type sold out mid-flow |
+| `base-13-hk-stayover-family-waiting.md` | HK dirty, family waiting |
+| `base-14-reservations-corporate-modify.md` | Corporate modify / direct bill |
+| `base-15-night-audit-due-out-balance.md` | Night audit + due-out balance |
+| `base-16-gm-peak-occupancy-flash.md` | GM peak occupancy |
+| `base-17-privacy-adjoining-request.md` | Privacy / adjoining |
+| `base-18-cashier-post-and-settle.md` | Cashier post + settle |
+| `base-19-no-show-morning-release.md` | No-show release |
+| `base-20-reservations-walk-in-handoff.md` | Reservations → desk handoff |
+
+Classify failures with the same language as surface smoke: delight / annoy / block.
diff --git a/ops/harden/vignettes/base-01-multi-room-walk-in.md b/ops/harden/vignettes/base-01-multi-room-walk-in.md
new file mode 100644
index 0000000..9602b92
--- /dev/null
+++ b/ops/harden/vignettes/base-01-multi-room-walk-in.md
@@ -0,0 +1,22 @@
+# base-01 — Exhausted family, two rooms, one pass
+
+## Guest story
+Saturday peak. A family of five walks in after a delayed flight — kids melting down in the lobby. They need **two rooms**, same nights, preferably one bill / one party so they aren’t split across the desk twice.
+
+## Staff job
+`front_desk` on `/front-desk`: create both rooms under **one party**, assign, send them upstairs in one conversation.
+
+## Surfaces
+`/front-desk`
+
+## Delight if
+Shared conf# / party visible; both rooms assigned; family leaves the desk once.
+
+## Annoy if
+Party invisible in the list; extra hops to Reservations/Folios to finish.
+
+## Block if
+Second room requires a second independent walk-in; no party link; double-assign a room.
+
+## Severity if fail
+**BLOCKER**
diff --git a/ops/harden/vignettes/base-02-accompanying-same-room.md b/ops/harden/vignettes/base-02-accompanying-same-room.md
new file mode 100644
index 0000000..ab663ee
--- /dev/null
+++ b/ops/harden/vignettes/base-02-accompanying-same-room.md
@@ -0,0 +1,22 @@
+# base-02 — Couple walk-in, one room, both names
+
+## Guest story
+A couple walks in late with no booking. They want **one room** and both names on the stay so either can request a key or ask about the folio later.
+
+## Staff job
+`front_desk` on `/front-desk`: one walk-in, one room, accompanying / second guest recorded without inventing a second reservation.
+
+## Surfaces
+`/front-desk`
+
+## Delight if
+Both names visible later from Guest Details / in-house without a scavenger hunt.
+
+## Annoy if
+Only one name sticks; second guest buried or requires a note hack.
+
+## Block if
+Product forces a second reservation for the accompanying guest.
+
+## Severity if fail
+**BLOCKER** if accompanying impossible; **FRICTION** if obscure but in-flow.
diff --git a/ops/harden/vignettes/base-03-arrival-unassigned-reg-card.md b/ops/harden/vignettes/base-03-arrival-unassigned-reg-card.md
new file mode 100644
index 0000000..85c1205
--- /dev/null
+++ b/ops/harden/vignettes/base-03-arrival-unassigned-reg-card.md
@@ -0,0 +1,22 @@
+# base-03 — Arrival waiting, room still unassigned
+
+## Guest story
+OTA guest arrives on time. Reservation exists but **no room assigned**. They’re standing at the desk with ID ready; next guest is already in line.
+
+## Staff job
+`front_desk` on `/front-desk`: assign a clean room and complete check-in / reg in one Arrivals pass.
+
+## Surfaces
+`/front-desk`
+
+## Delight if
+Assigned + checked in without leaving Arrivals; guest gets a room number quickly.
+
+## Annoy if
+Must open Reservations or Rooms to finish assign.
+
+## Block if
+Cannot assign from desk flow; check-in succeeds with no room and no hard warning.
+
+## Severity if fail
+**BLOCKER** if check-in cannot complete; **FRICTION** if assign is buried.
diff --git a/ops/harden/vignettes/base-04-inhouse-guest-details.md b/ops/harden/vignettes/base-04-inhouse-guest-details.md
new file mode 100644
index 0000000..35a9862
--- /dev/null
+++ b/ops/harden/vignettes/base-04-inhouse-guest-details.md
@@ -0,0 +1,22 @@
+# base-04 — In-house guest asks for folio / PIN / who’s on the room
+
+## Guest story
+An in-house guest calls the desk: “What’s my folio balance, and can my spouse get a key?” Staff needs Guest Details, folio entry, and accompanying names **from the stay**.
+
+## Staff job
+`front_desk` on `/front-desk` (folio link OK as secondary): open stay context, answer balance/PIN/accompanying without losing the guest on hold.
+
+## Surfaces
+`/front-desk` (optional secondary `/folios`)
+
+## Delight if
+Guest Details + folio path from In-House in a few clicks.
+
+## Annoy if
+Must search Guests/Reservations by name with no stay link.
+
+## Block if
+In-house stay has no usable guest/folio path.
+
+## Severity if fail
+**FRICTION** slow path; **BLOCKER** if unreachable.
diff --git a/ops/harden/vignettes/base-05-room-move-dnm.md b/ops/harden/vignettes/base-05-room-move-dnm.md
new file mode 100644
index 0000000..dd57ea8
--- /dev/null
+++ b/ops/harden/vignettes/base-05-room-move-dnm.md
@@ -0,0 +1,22 @@
+# base-05 — Guest wants a quieter room (move / DNM)
+
+## Guest story
+Stayover guest complains about noise and asks to move. Housekeeping or desk may have marked do-not-move; guest is at the desk now, not “later via email.”
+
+## Staff job
+`front_desk` on `/front-desk`: move to another vacant room; if DNM, explicit override/ack path — not a silent ignore.
+
+## Surfaces
+`/front-desk`
+
+## Delight if
+Move completes on Front Desk; new room shows on the stay immediately.
+
+## Annoy if
+Must bounce to Rooms/Reservations to finish the move.
+
+## Block if
+No in-house room move at all.
+
+## Severity if fail
+**BLOCKER** if move impossible; **FRICTION** if path is buried.
diff --git a/ops/harden/vignettes/base-06-early-checkout-zero-balance.md b/ops/harden/vignettes/base-06-early-checkout-zero-balance.md
new file mode 100644
index 0000000..7ee4a00
--- /dev/null
+++ b/ops/harden/vignettes/base-06-early-checkout-zero-balance.md
@@ -0,0 +1,22 @@
+# base-06 — Early flight, zero balance, just leave
+
+## Guest story
+Guest needs to leave at 6am for a flight. Folio is **$0**. They want a clean checkout and to go — no lecture, no scavenger hunt.
+
+## Staff job
+`front_desk` or `cashier` on `/front-desk` (or folio if required): checkout with zero balance; room released; guest gone.
+
+## Surfaces
+`/front-desk` (optional `/folios`)
+
+## Delight if
+One clear checkout; stay leaves in-house; guest out in under a minute of desk time.
+
+## Annoy if
+Extra unrelated confirms when balance is already zero.
+
+## Block if
+Zero-balance checkout fails with no actionable reason.
+
+## Severity if fail
+**BLOCKER**
diff --git a/ops/harden/vignettes/base-07-checkout-blocked-balance.md b/ops/harden/vignettes/base-07-checkout-blocked-balance.md
new file mode 100644
index 0000000..4bfc340
--- /dev/null
+++ b/ops/harden/vignettes/base-07-checkout-blocked-balance.md
@@ -0,0 +1,22 @@
+# base-07 — Due out but still owes money
+
+## Guest story
+Guest tries to leave with an open minibar / unpaid charge. They’ll get angry if the desk “checks them out anyway” or if nobody can show the balance and take payment.
+
+## Staff job
+`front_desk` / `cashier`: attempt checkout; **block or force settlement**; show amount and next action.
+
+## Surfaces
+`/front-desk`, `/folios`
+
+## Delight if
+Clear balance + pay/settle path; no silent bypass.
+
+## Annoy if
+Error is opaque (“failed”) with no folio jump.
+
+## Block if
+Checkout succeeds while balance remains outstanding with no warning.
+
+## Severity if fail
+**BLOCKER** on silent bypass; **FRICTION** if blocked but settle path is obscure.
diff --git a/ops/harden/vignettes/base-08-group-multi-res-checkin.md b/ops/harden/vignettes/base-08-group-multi-res-checkin.md
new file mode 100644
index 0000000..09333d3
--- /dev/null
+++ b/ops/harden/vignettes/base-08-group-multi-res-checkin.md
@@ -0,0 +1,22 @@
+# base-08 — Small group arrives together
+
+## Guest story
+A wedding party / small group block arrives at once — several rooms, same dates, one organizer at the desk with a list. Lobby is backing up.
+
+## Staff job
+`front_desk` (or reservations → desk): identify the set and check in **multiple rooms in one session** without treating each as a random solo.
+
+## Surfaces
+`/front-desk`
+
+## Delight if
+Batch or guided multi-select check-in; party/group still linked after.
+
+## Annoy if
+Each room is a full separate check-in with no shared context.
+
+## Block if
+Cannot recover the set; forced into unrelated solo check-ins only.
+
+## Severity if fail
+**BLOCKER** if multi-res party cannot check in as a set; **FRICTION** if slow but possible.
diff --git a/ops/harden/vignettes/base-09-vip-loyalty-recognition.md b/ops/harden/vignettes/base-09-vip-loyalty-recognition.md
new file mode 100644
index 0000000..4d5992d
--- /dev/null
+++ b/ops/harden/vignettes/base-09-vip-loyalty-recognition.md
@@ -0,0 +1,22 @@
+# base-09 — Loyalty guest expects to be recognized
+
+## Guest story
+A repeat / VIP guest arrives. They expect the desk to see status without them “proving” who they are for three minutes.
+
+## Staff job
+`front_desk` on `/front-desk` or arrivals: spot VIP/loyalty (if product supports it) and open the profile without losing the arrival.
+
+## Surfaces
+`/front-desk`, `/guests`
+
+## Delight if
+Status visible at arrivals / match; desk greets accordingly.
+
+## Annoy if
+Status exists but never shown at desk surfaces.
+
+## Block if
+Wrong guest’s VIP data shown (trust break).
+
+## Severity if fail
+**FRICTION** missing/hard to see; **BLOCKER** on cross-guest bleed; **NOTE** if feature absent from shipped UI (`NEEDS_DOMAIN` OK).
diff --git a/ops/harden/vignettes/base-10-notes-handover.md b/ops/harden/vignettes/base-10-notes-handover.md
new file mode 100644
index 0000000..9d1c138
--- /dev/null
+++ b/ops/harden/vignettes/base-10-notes-handover.md
@@ -0,0 +1,22 @@
+# base-10 — Mid-shift handover note
+
+## Guest story
+Day shift learns “extra towels + late arrival after 11.” Night shift must see it when the guest shows up — not in someone’s head or Discord.
+
+## Staff job
+`front_desk`: leave a note on the reservation/guest; next agent finds it from Front Desk without archaeology.
+
+## Surfaces
+`/front-desk`
+
+## Delight if
+Note survives navigation and is obvious on reopen.
+
+## Annoy if
+Notes exist but are hard to find.
+
+## Block if
+Note appears saved then disappears.
+
+## Severity if fail
+**BLOCKER** on data loss; **FRICTION** if hard to find.
diff --git a/ops/harden/vignettes/base-11-locale-pt-BR-walk-in.md b/ops/harden/vignettes/base-11-locale-pt-BR-walk-in.md
new file mode 100644
index 0000000..5a9d813
--- /dev/null
+++ b/ops/harden/vignettes/base-11-locale-pt-BR-walk-in.md
@@ -0,0 +1,22 @@
+# base-11 — Brazilian guest, desk in pt-BR
+
+## Guest story
+Property serves Brazilian guests; desk runs UI in `pt-BR`. Guest asks for a 2-night walk-in — copy must not look broken or untrustworthy.
+
+## Staff job
+`front_desk`: complete walk-in with locale `pt-BR` if switcher exists; nights pluralize cleanly; flow still completable.
+
+## Surfaces
+`/front-desk` (locale control wherever shipped)
+
+## Delight if
+Clear pt-BR nights copy; walk-in finishes.
+
+## Annoy if
+Nested English plural junk like `(2 NIGHT(S))` in pt-BR strings.
+
+## Block if
+Locale switch prevents completing the walk-in.
+
+## Severity if fail
+**FRICTION** for bad copy; **BLOCKER** only if task blocked; **ENV_BLOCKED** if locale unavailable.
diff --git a/ops/harden/vignettes/base-12-near-full-second-type.md b/ops/harden/vignettes/base-12-near-full-second-type.md
new file mode 100644
index 0000000..584bd59
--- /dev/null
+++ b/ops/harden/vignettes/base-12-near-full-second-type.md
@@ -0,0 +1,22 @@
+# base-12 — Peak night, second room type sold out mid-flow
+
+## Guest story
+Family needs two rooms at arrival peak. First type is fine; **second type shows unavailable** mid-flow. They’re already emotionally invested — don’t leave a half-booking mess.
+
+## Staff job
+`front_desk` on `/front-desk`: recover — alternate type, reduce rooms, or abort cleanly; keep first selection coherent.
+
+## Surfaces
+`/front-desk`
+
+## Delight if
+Clear unavailable message + clean recovery without orphan stays.
+
+## Annoy if
+Confusing error but recoverable.
+
+## Block if
+Silent fail, corrupt partial booking, or forced unlinked second walk-in.
+
+## Severity if fail
+**BLOCKER** on corrupt/duplicate-walk-in; **FRICTION** if recoverable but messy.
diff --git a/ops/harden/vignettes/base-13-hk-stayover-family-waiting.md b/ops/harden/vignettes/base-13-hk-stayover-family-waiting.md
new file mode 100644
index 0000000..fce27f6
--- /dev/null
+++ b/ops/harden/vignettes/base-13-hk-stayover-family-waiting.md
@@ -0,0 +1,22 @@
+# base-13 — Family waiting; stayover room still dirty
+
+## Guest story
+Arriving family is in the lobby. Their assigned room is still a **stayover dirty**. Kids are restless; GM is glancing at the desk. Someone must free a clean room without making the family wait through a folio tour.
+
+## Staff job
+`housekeeping_supervisor` on `/housekeeping` (+ `/rooms` if needed): see the blocked arrival, push clean/inspect or reassign path so Front Desk can put them up — **without** wandering into Folios/Billing.
+
+## Surfaces
+`/housekeeping`, `/rooms`
+
+## Delight if
+Clear dirty/stayover signal + action; family gets a room without ops chaos.
+
+## Annoy if
+Must leave HK into unrelated modules to understand the block.
+
+## Block if
+HK dashboard broken (e.g. 500) so supervisor cannot act.
+
+## Severity if fail
+**BLOCKER** if HK cannot run the board; **FRICTION** if path is slow/confusing.
diff --git a/ops/harden/vignettes/base-14-reservations-corporate-modify.md b/ops/harden/vignettes/base-14-reservations-corporate-modify.md
new file mode 100644
index 0000000..b3debbc
--- /dev/null
+++ b/ops/harden/vignettes/base-14-reservations-corporate-modify.md
@@ -0,0 +1,22 @@
+# base-14 — Corporate guest extends stay, direct bill
+
+## Guest story
+A corporate traveler on **direct bill** calls/emails: extend two nights. Company will pay; they don’t want a panic “we lost your rate” conversation.
+
+## Staff job
+`reservations` on `/reservations` (rate glance OK): find the stay, extend dates, keep corporate/direct-bill context without rebuilding the booking from scratch.
+
+## Surfaces
+`/reservations`
+
+## Delight if
+Modify dates in one coherent flow; confirmation still makes sense.
+
+## Annoy if
+Must cancel + recreate; direct bill context dropped.
+
+## Block if
+No modify path and no honest failure — silent corruption.
+
+## Severity if fail
+**FRICTION** if workaround exists; **BLOCKER** if stay cannot be extended safely; `NEEDS_DOMAIN` if product has no modify at all.
diff --git a/ops/harden/vignettes/base-15-night-audit-due-out-balance.md b/ops/harden/vignettes/base-15-night-audit-due-out-balance.md
new file mode 100644
index 0000000..6c5d70f
--- /dev/null
+++ b/ops/harden/vignettes/base-15-night-audit-due-out-balance.md
@@ -0,0 +1,22 @@
+# base-15 — Night audit blocked by due-out with balance
+
+## Guest story
+It’s roll time. One due-out still has an open balance. Night auditor must not blindly close the day and invent a ghost checkout — and must not spend 40 minutes hunting the folio.
+
+## Staff job
+`night_auditor` on `/night-audit` + `/folios` / Front Desk departures: see the due-out with balance, resolve or consciously defer before audit.
+
+## Surfaces
+`/night-audit`, `/folios`, `/front-desk`
+
+## Delight if
+Audit surface flags the problem; clear next action.
+
+## Annoy if
+Audit runs “fine” while due-out balance is invisible.
+
+## Block if
+Occupancy/audit tools 500 so NA cannot close the day.
+
+## Severity if fail
+**BLOCKER** if day cannot close safely; **FRICTION** if visible but painful.
diff --git a/ops/harden/vignettes/base-16-gm-peak-occupancy-flash.md b/ops/harden/vignettes/base-16-gm-peak-occupancy-flash.md
new file mode 100644
index 0000000..a8b7be2
--- /dev/null
+++ b/ops/harden/vignettes/base-16-gm-peak-occupancy-flash.md
@@ -0,0 +1,22 @@
+# base-16 — GM needs a peak occupancy flash
+
+## Guest story
+Saturday peak. Front desk is slammed. GM walks in: “Are we full? Any walk-in left?” They need a trustworthy occupancy flash — not a broken report and not a 20-click export.
+
+## Staff job
+`gm` on `/` and `/reports`: read occupancy / today’s flash quickly while FD works.
+
+## Surfaces
+`/`, `/reports`
+
+## Delight if
+Dashboard or occupancy report answers in one glance.
+
+## Annoy if
+Numbers disagree across screens with no explanation.
+
+## Block if
+Occupancy report 500 / unusable at peak.
+
+## Severity if fail
+**BLOCKER** if GM cannot get occupancy; **FRICTION** if slow/inconsistent.
diff --git a/ops/harden/vignettes/base-17-privacy-adjoining-request.md b/ops/harden/vignettes/base-17-privacy-adjoining-request.md
new file mode 100644
index 0000000..911fe6c
--- /dev/null
+++ b/ops/harden/vignettes/base-17-privacy-adjoining-request.md
@@ -0,0 +1,22 @@
+# base-17 — Two guests, privacy / adjoining ask
+
+## Guest story
+Two adults check in with separate names. They ask for **adjoining or privacy-sensitive** placement (e.g. not announcing names loudly; prefer rooms near each other). Desk must service the ask without inventing a loyalty program.
+
+## Staff job
+`front_desk` on `/front-desk`: capture the request (notes / room choice), assign appropriately, keep both guests serviceable.
+
+## Surfaces
+`/front-desk`
+
+## Delight if
+Request noted and rooms chosen in one pass; no lobby theater.
+
+## Annoy if
+No place for the request; staff uses sticky notes IRL.
+
+## Block if
+Cannot assign two related stays without unlinked chaos (ties to multi-room party).
+
+## Severity if fail
+**FRICTION** if notes missing but assign works; **BLOCKER** if party/assign breaks; `NEEDS_DOMAIN` only if asking for a product concept that truly isn’t shipped.
diff --git a/ops/harden/vignettes/base-18-cashier-post-and-settle.md b/ops/harden/vignettes/base-18-cashier-post-and-settle.md
new file mode 100644
index 0000000..59dc9f8
--- /dev/null
+++ b/ops/harden/vignettes/base-18-cashier-post-and-settle.md
@@ -0,0 +1,22 @@
+# base-18 — Restaurant charge then settle at desk
+
+## Guest story
+In-house guest signed a dinner charge to the room. At checkout they want to **see the charge and pay**. Cashier/desk must post/settle without a treasure hunt.
+
+## Staff job
+`cashier` / `front_desk` on `/folios` (+ Front Desk): find folio, confirm charge, take payment / settle, then checkout path.
+
+## Surfaces
+`/folios`, `/front-desk`
+
+## Delight if
+Charge visible; settle → checkout coherent.
+
+## Annoy if
+Charge exists in ops lore but not on the folio UI.
+
+## Block if
+Cannot post/settle; checkout silently ignores balance (ties to base-07).
+
+## Severity if fail
+**BLOCKER** on financial integrity; **FRICTION** if path is slow.
diff --git a/ops/harden/vignettes/base-19-no-show-morning-release.md b/ops/harden/vignettes/base-19-no-show-morning-release.md
new file mode 100644
index 0000000..2f4e0ab
--- /dev/null
+++ b/ops/harden/vignettes/base-19-no-show-morning-release.md
@@ -0,0 +1,22 @@
+# base-19 — Morning no-show, free the room
+
+## Guest story
+It’s morning. A guaranteed/no-show reservation didn’t arrive. House needs the room for a walk-in; desk/NA must release inventory without ghosting revenue policy blindly.
+
+## Staff job
+`front_desk` or `night_auditor`: mark/handle no-show and free the room for sale; don’t leave a phantom arrival blocking Walk-In.
+
+## Surfaces
+`/front-desk`, `/reservations`
+
+## Delight if
+Clear no-show / release action; room available for next walk-in.
+
+## Annoy if
+Status confusing; room stuck “arriving” all day.
+
+## Block if
+Cannot free inventory; walk-ins blocked by ghosts.
+
+## Severity if fail
+**FRICTION** if awkward; **BLOCKER** if inventory stuck.
diff --git a/ops/harden/vignettes/base-20-reservations-walk-in-handoff.md b/ops/harden/vignettes/base-20-reservations-walk-in-handoff.md
new file mode 100644
index 0000000..f31e744
--- /dev/null
+++ b/ops/harden/vignettes/base-20-reservations-walk-in-handoff.md
@@ -0,0 +1,22 @@
+# base-20 — Reservations books walk-in that FD must finish
+
+## Guest story
+Phone rings at reservations during peak: walk-in-ish same-day stay. Reservations starts the booking; guest will be at the desk in 20 minutes for keys.
+
+## Staff job
+`reservations` creates/holds the stay on `/reservations`; `front_desk` completes assign/check-in on `/front-desk` without re-entering the whole guest as a new walk-in.
+
+## Surfaces
+`/reservations`, `/front-desk`
+
+## Delight if
+Handoff is one stay; FD finishes assign/check-in fast.
+
+## Annoy if
+FD cannot find the booking; duplicates created.
+
+## Block if
+Forced second independent create at the desk.
+
+## Severity if fail
+**BLOCKER** if duplicate create required; **FRICTION** if findable but slow.
diff --git a/package.json b/package.json
index 4c8af16..472f444 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,9 @@
"db:studio": "pnpm --filter @telivityhaip/database run db:studio",
"docker:up": "docker compose up -d",
"docker:down": "docker compose down",
+ "harden:local": "node ops/harden/cli/harden.mjs local",
+ "harden:live": "node ops/harden/cli/harden.mjs live",
+ "harden": "node ops/harden/cli/harden.mjs all",
"clean": "pnpm -r run clean && rm -rf node_modules",
"integrations:demo": "bash integrations/demos/run.sh"
},