Guide

Federated services

How the core federates with QR Find, TimeLens, Make-It-Right, and the Itinerary Planner, what crosses each boundary, and what the core keeps afterward.

Four federated services sit next to the Guest Intelli core. QR Find answers guest questions and pushes suggestions back. TimeLens shows the property's history from a photo. Make-It-Right handles service recovery. The Itinerary Planner builds a day-by-day plan across the resort. Each one has a different relationship with the core, and the differences are the point of this guide.

The shared identifier is porter_ref, a UUID stamped once on a stay at recognition and never regenerated. It carries no name, no room, and no booking code. The services see the ref. They do not see the stay. Three of the four use it. The Itinerary Planner is handed no ref at all.

QR Find

QR Find is the question-answering service. Federation runs in both directions, and consent gates every leg of it.

The core telling QR Find a stay exists

porter-feed.ts posts a stay to QR Find's ingest endpoint at two moments: on recognition when the stay already carries consent, and the moment a guest records analytics_consent: 1. The body is deliberately thin.

JSON

{
  "stay_ref": "<porter_ref>",
  "property_slug": "solara-cove",
  "checkin_at": "2026-09-04T16:00:00Z",
  "checkout_at": "2026-09-08T11:00:00Z"
}

It carries x-gi-service-token, times out at three seconds, and is fire-and-forget. Failures log one line and vanish. Recognition and consent must never block on a federated service being slow. A stay with no porter_ref, no configured URL, or no configured token is skipped outright.

Forwarding a Concierge question

POST /v1/stays/:stay_id/concierge takes one message, between 1 and 500 characters. concierge.ts matches it against the property config's intent keywords first, using plain case-insensitive phrase containment.

On a matched intent, the reply is the intent's own configured text plus a typed card, and no round trip to QR Find is needed to answer. If the guest consented and a chat function is configured, the message is still forwarded, fire-and-forget, with the reply discarded, so QR Find's question store keeps the words for the Openings read. An unconsented guest's message is never forwarded.

On no matched intent, the core awaits QR Find and returns its answer as the reply text with no cards. concierge-porter.ts builds that call:

JSON

{
  "property_slug": "solara-cove",
  "thread_key": "<porter_ref, or a fresh random UUID>",
  "channel": "web",
  "message": "...",
  "stay_ref": "<porter_ref, present only when consented>"
}

A consented stay's porter_ref rides as both thread_key and stay_ref, which keeps one conversation continuous across the guest web app and the QR Find web channel. An unconsented call gets a random thread key and no ref at all, so the words never link to an identity. The call times out at eight seconds and expects { "reply": "<string>" }. Anything else, including an unreachable host, falls back to a fixed line offering a person and a concierge request form.

Consent gating

Consent gates analytics and marketing, never service. The group, offers, and timelens intents return a consent_gate card to an unconsented guest. Dining, spa, housekeeping, late checkout, and luggage all work unconsented, exactly as a direct request always did.

The same rule governs deep links. GET /v1/stays/:stay_id/home returns concierge_url and timelens_url as null unless the stay has analytics_consent === 1, has a porter_ref, and the deployment configured that service's URL. An unconsented guest's tap must never arrive at a service carrying a ref that resolves to their stay.

QR Find calling back

Two routes accept inbound calls, both requiring x-gi-service-token to match the configured federation token. With no token configured they return 401 for everybody.

POST /v1/federation/porter/escalations takes stay_ref, summary, and an optional conversation_url. The core resolves the ref to a stay, creates a concierge request on it, and stores provenance in the request details: note (the summary, trimmed to 200 characters), source: "porter", and the conversation URL when given (trimmed to 300). It emits porter_escalation. A concierge request completes at zero value and records no action, because getting a person on something is service, not revenue. Unknown ref returns 404, empty summary returns 400.

POST /v1/federation/porter/upsells takes stay_ref, delivery_id, title, body, emoji, moment, and link. The link must be https. The row is keyed on delivery_id, so a repeat delivery updates in place rather than duplicating, and only the first emits porter_upsell_sent. Stored upsells surface on the guest home as concierge_suggestions, capped at five, unopened first. When the guest taps one, POST /v1/porter-upsells/:id/open (session-guarded) stamps opened_at once and emits porter_upsell_opened.

openings-sources.ts adds one more outbound call. The Openings read batches every active stay's refs into a single POST to QR Find's questions endpoint with x-gi-service-token, a three-second timeout, and a since timestamp. It returns { stay_ref, text, at } rows that become topic evidence. A failure marks the porter source unavailable in the response while local evidence still returns.

What the core keeps

The porter_ref on the stay. Upsell rows. Concierge requests created from escalations, with their note and conversation link. Events: porter_escalation, porter_upsell_sent, porter_upsell_opened, and concierge_asked. Conversation text is not among them. concierge_asked stores a character_count and nothing else. The platform remembers what it did, and QR Find keeps the words under its own retention.

TimeLens

TimeLens is a photo deep link. The core knows two things about it, both from configuration: a web URL and an API base with a service token.

The web URL becomes a link the guest taps. It is assembled in two places, the home response and the Concierge's timelens intent card, in the same shape:

Code

<TIMELENS_URL>?property=solara-cove&ref=<porter_ref>&src=stay-app

Both are consent-gated and both return null (home) or a consent gate (Concierge) without it. The core stores no photo, no session, and no result. It hands over a ref and a link.

The API is read-only and feeds Openings. createTimelensFeaturesSource issues one GET <TIMELENS_API_URL>/api/engagement/<ref>/features per ref with Authorization: Bearer <TIMELENS_PARTNER_TOKEN> and a three-second timeout. Of each returned row it keeps feature_key and evidence_count and drops every other field. Feature keys map to plain prose through a fixed table in openings.ts, so a topic reads "explored the beach in TimeLens" rather than a score or a system name. Results are cached for five minutes and never written to the database. A failure marks the timelens source unavailable in the openings response.

The relevant environment variable names are TIMELENS_URL, TIMELENS_API_URL, and TIMELENS_PARTNER_TOKEN.

Make-It-Right

Make-It-Right is service recovery, and its integration is the thinnest of the three. It is an event emitter. It has no route of its own beyond the shared sink.

POST /v1/events/sink accepts event_type, stay_id, and occurred_at. Everything else in the body is stored as the event's params. The route is unauthenticated in this build. Every event it writes is stamped source: 'make_it_right', hardcoded, so the sink is a Make-It-Right channel by construction rather than by convention. A body missing any of the three required fields returns 400 invalid_event.

Three event types are read back, and only these three:

Event typeWhere it surfaces
friction_detectedProperty tab recovery.incidents_today, report recovery.incidents, an activity line reading "Friction flagged for {First}"
recovery_sentProperty tab recovery.gestures_sent_today, report recovery.gestures_sent, and recovery_spend_usd summed from each event's cost_usd param
recovery_claimedReport recovery.gestures_claimed, an activity line reading "{First} claimed the gesture"

Every make_it_right event also carries a "Make-It-Right" chip in the activity stream, and the operator context view for a stay returns its full recovery_events list with parsed params.

That is the whole surface. The core holds no recovery rules, no gesture catalog, no cost model, and no route to create or claim a gesture. It counts what Make-It-Right tells it happened, attributes each item to the right stay, and shows it next to everything else on that stay.

Itinerary Planner

The Itinerary Planner is a separate service that builds a guest a day-by-day plan across the resort. It is a Java and Spring Boot API with a React Native web front end, running on its own host with its own database. None of its code lives in this repo.

It keeps its own venue catalog, 51 venues for Solara Cove, loaded into the planner's own store. The core's recommendation rules and the planner's catalog are separate lists that happen to describe the same property.

The core's whole share of this integration is one link. GET /v1/stays/:stay_id/home returns itinerary_url, built from the deployment's ITINERARY_PLANNER_URL with any trailing slash normalized away:

Code

<ITINERARY_PLANNER_URL>/?property=solara-cove

It is null when no URL is configured. The Concierge renders it under Plan your days in the Your stay sheet, and the guest taps through in a new tab.

The link carries the property slug and nothing else. No porter_ref, no stay id, no room, no booking code. With no ref on it, it is the one deep link in this guide that is not consent-gated. An unconsented guest gets the same URL a consented one does, and core/src/__tests__/federation.test.ts pins that.

Nothing flows back into the core from the planner in this build. The planner has no inbound route here, emits no events, and writes nothing to the database. The core does not know whether the guest opened it, what they picked, or what plan came out. One outbound link, no return path. Whatever the guest builds in the planner stays in the planner.