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.