Guide

Events and the ledger

How an action becomes an event, which surfaces read events and which read tables, and exactly what provenance the core stores alongside every recorded action.

Everything the platform does leaves a row. This guide covers where those rows go and, more usefully, which reading surfaces actually depend on them.

The event row

One table, one insert function, six columns.

SQL

CREATE TABLE events (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  event_type TEXT NOT NULL,
  property_slug TEXT NOT NULL,
  stay_id TEXT NOT NULL,
  occurred_at TEXT NOT NULL,
  params TEXT NOT NULL DEFAULT '{}',
  source TEXT NOT NULL DEFAULT 'core'
);

emitEvent takes a type, a stay id, a timestamp, and a params object it serializes to JSON. source defaults to core. property_slug is written as solara-cove in both insert paths, so multi-property is not yet a live concern here. Params are free-form JSON, unvalidated, and every reader parses defensively.

The core emits 32 event types across recognition, consent, requests, offers, groups, the Concierge, federation, and connector plumbing. Six of those are property-level rather than guest-level and are written with the literal stay id none: the offer lifecycle events (offer_created, offer_pending_approval, offer_approved, offer_rejected, offer_closed) and source_unreachable. Every reader that renders a guest name skips those, or skips any row whose stay join comes back empty.

Nothing deletes or updates an event. id is the only ordering that matters, and the activity stream's pagination cursor rides on it.

The sink for external emitters

POST /v1/events/sink

POST /v1/events/sink

JSON

{ "event_type": "recovery_claimed", "stay_id": "stay-871",
  "occurred_at": "2026-08-30T18:00:00Z", "cost_usd": 40 }

event_type, stay_id, and occurred_at are required. Everything else in the body becomes the event's params. The three required fields are the only validation, so a missing one returns 400 invalid_event and an unknown stay id does not. The route writes source: 'make_it_right', hardcoded, and is unauthenticated in this build. See Federated services for what Make-It-Right sends and how it reads back.

Two worked examples

The consent event. POST /v1/stays/:stay_id/consent updates the stay's analytics_consent column and emits:

Code

consent_recorded / stay-655 / <now> / { "analytics_consent": 1 } / core

The activity stream reads that param and picks one of two lines, "Aretha opted in to stay updates" or "Aretha declined stay updates". It drops the row if the value is anything other than 0 or 1. Nothing else reads the event. Every other consent-dependent behavior in the platform, including the federated service deep links and the twin's consented count, reads the column on the stay row directly. The event is the record that it happened and when, not the state.

The concierge_asked event. When a guest asks the Concierge something and that message is going to reach QR Find, the core emits:

Code

concierge_asked / stay-412 / <now> / { "character_count": 27 } / core

Only the length of the trimmed message. Not the message. An unconsented guest's question emits nothing at all, because it is never forwarded. The event has no entry in the activity stream's line map, so it never renders anywhere. It exists so an operator can later show that a forwarding path ran, at what volume, without holding a word of what was said.

What reads events

The activity stream (GET /v1/activity) is the only surface built entirely on events. activity.ts holds a pinned map from event type to a one-line template. Any type absent from that map is never rendered, which is why concierge_asked, offer_sent, offer_viewed, recommendation_shown, group_activity_proposed, group_attendance_set, and the connector-failure types stay invisible. Params rarely carry display text, so the reader batch-loads request types, offer labels, group labels, activity titles, and staff first names before rendering, and drops any row whose lookup fails rather than printing a blank. Adjacent rows from the same actor and type merge, so three requests from one guest collapse to "Aretha made 3 requests". A chip names the system behind a line when there is one: SynXis, Stayntouch, Hapi, OPERA, Simphony, QR Find, or Make-It-Right. Pagination pulls a 300-row window and returns next_before_id.

The Property tab (GET /v1/property/summary) is the digital twin: counts and a live stream, no floor plans and no predictions. The counts are computed on read from tables, not events. Guests on property, arrivals, departures, open and overdue requests per queue, open offers, offers accepted today, group size, and today's booking revenue all come from stays, requests, offers, offer_instances, groups, and bookings. One block reads events: recovery, counting today's friction_detected and recovery_sent rows where source is make_it_right. The stream half of the tab is the activity feed above.

The GM feed (GET /v1/gm/feed) reads events in one place. Its vendors block groups source_unreachable events inside a configured window by the source param, minus any source acknowledged since its most recent failure. Approvals, overdue cards, the Last Day card, and the groups rollup are all computed from tables. POST /v1/gm/acks writes to gm_acks, not to events.

The report (GET /v1/report) is mostly not event-derived. The live funnel is recomputed per stay from stays, recommendations, offer_instances, and actions. Seed rows come from the funnel table. Offer rows, rebook holds, and group bookings come from their own tables. The one event-derived block is recovery, counting friction_detected, recovery_sent, and recovery_claimed from the sink and summing cost_usd out of each recovery_sent's params. measured_lift is the literal string finance test pending, which is the honest answer until a finance test runs.

Two things follow from this. Deleting the event table would not change the report, the twin's counts, or most of the GM feed. And an event is the audit trail, not the source of truth for any number the platform shows.

What an action carries

The actions table is the platform's revenue ledger, and it is small on purpose:

SQL

CREATE TABLE actions (
  id TEXT PRIMARY KEY, stay_id TEXT NOT NULL,
  kind TEXT NOT NULL CHECK (kind IN ('booking','order','itinerary_change','rebooking')),
  source TEXT NOT NULL, value_usd REAL NOT NULL, acted_at TEXT NOT NULL,
  origin TEXT NOT NULL DEFAULT 'live'
);

Two code paths write to it, and each stamps source with the id of the thing that produced the action. Completing a request writes the request id. Accepting an offer instance writes the offer instance id. There is no third writer, and there is no action with an empty source.

That pointer is the reason, and it keeps going from there. A request id resolves to a row carrying the guest's submitted details, the queue it was routed to, the staff member who closed it, the confirmation code, and a nullable recommendation_id naming the recommendation that prompted it. A request created from a QR Find escalation carries source: "porter" and the conversation URL in its details. An offer instance id resolves to a row carrying its offer, its audience definition, and the operator's intent_note when one was written. In parallel, request_completed and offer_accepted events record the same moment with the staff id, the confirmation code, and the value.

Two honest limits on the claim. First, source is a bare id column with no type discriminator, so a reader has to try requests and then offer_instances to learn which kind of pointer it holds. Second, not every completed request produces an action at all: luggage_hold and concierge completions record none, because a held bag and a fetched staff member are service rather than a trackable action. A rebook_hold records rebooking, and everything else records booking.

Within those limits the claim holds. Every row in actions names what caused it, and that chain runs back to a recommendation, an operator's stated intent, or a QR Find escalation with its conversation attached.