Loyalty β integration specification
Status: POC Β· Date: 2026-09-22 Β· Service: mock-loyalty-api (stands in for GalaxyJoy / SkyJoy)
Loyalty enriches; it never gates. When the loyalty system is down passengers still get online at the baseline β they lose the tier benefit, not the connection. This is the core difference from HR.
1. Why this is not HR
Two systems, two entirely different failure semantics. Confusing them is a serious defect.
| HR | Loyalty | |
|---|---|---|
| Role | Gate | Enrichment |
| Cannot answer | No access β fails closed | Baseline still granted |
| Who is affected by an outage | Every employee is locked out | Nobody loses a connection, only a benefit |
| Timeout | 3000 ms Γ 2 attempts | 2000 ms, no retry |
| Called by | Keycloak, during authentication | Entitlement Service, after authentication |
Put plainly: a marketing system having a bad afternoon must not take a cabin offline.
For that reason loyalty.js and HrVerificationClient are separate classes with no shared helper β so nobody copy-pastes one's failure semantics onto the other by accident.
2. Where it sits
Portal β Keycloak authenticate, obtain identity
β Entitlement
β Loyalty what tier is this member? β customers only
β DMN what do the rules say?
β Session open the network
Loyalty feeds Entitlement, not Galaxy ID. Galaxy ID is the identity layer (ADR-002); benefits belong to Entitlement (ADR-005). The Flow 1 diagram currently draws this arrow the other way β see docs/03-architecture/FLOW1-AUTHEN-REVIEW.md Β§2.4.
Employees are not sent to loyalty. Their entitlement comes from HR verification; asking loyalty as well adds a dependency for nothing.
3. API
POST /api/v1/members/lookup
POST http://localhost:3006/api/v1/members/lookup
Content-Type: application/json
X-API-Key: {LOYALTY_API_KEY}
X-Correlation-ID: corr-...
{ "email": "an.nguyen@example.test" }
A member:
{
"found": true,
"memberRef": "GJ-000101",
"tier": "GOLD",
"status": "ACTIVE",
"wifiBenefit": true
}
Not a member β a perfectly ordinary answer, not an error. Most passengers on any flight land here:
{ "found": false }
| Code | HTTP | When |
|---|---|---|
UNAUTHORIZED |
401 | Missing or wrong X-API-Key |
INVALID_REQUEST |
400 | Malformed email, or the body is not JSON |
RATE_LIMITED |
429 | Global ceiling exceeded |
LOYALTY_SERVICE_TIMEOUT |
504 | (fault injection only) |
LOYALTY_SERVICE_UNAVAILABLE |
503 | (fault injection only) |
Minimal disclosure
Five fields, and no more. No name, phone, address, point balance, transaction history or date of birth.
A Wi-Fi portal needs a verdict, not a customer profile. A leak then costs little. An automated test rejects any additional field.
Tier is flattened when the account is not active
A SUSPENDED or CLOSED account keeps its historical tier inside the loyalty system, but this API returns tier: "NONE".
The reason: a consumer cannot then accidentally honour a tier the account no longer earns. That decision belongs at the source, not inferred independently by every caller.
4. The lookup key β an open decision
The POC looks up by email.
| For | Simple, needs no prior linking, matches the real case of "find an existing member" |
| Against | Email is personal data, and it is now flowing to another system |
| Against | A user who changes email loses the link |
Production candidate: store member_ref as a Galaxy ID attribute β the way employee_ref already works β and look up by that reference. Email then never leaves Galaxy ID.
Tracked as T-LOYALTY-KEY.
5. Effect on entitlement
Member tier is an input to the DMN decision table. See DMN-DECISION-SPEC.md.
| Tier | Entitlement | Tier | Devices | Validity |
|---|---|---|---|---|
GOLD |
WIFI_CUSTOMER_PLUS |
BOOST | 2 | 2h |
SILVER |
WIFI_CUSTOMER_PLUS |
BASIC | 2 | 1h |
NONE, or unreachable |
WIFI_CUSTOMER_BASIC |
BASIC | 1 | 1h |
β οΈ Every value above is a POC placeholder. The tier names and what each one earns are not confirmed business. See ADR-011 and open question T1 β whether the lever is duration or speed is still undecided.
All of it is editable in the DMN table without touching code.
The entitlement's sourceType records where it came from:
| Origin | sourceType |
sourceRef |
|---|---|---|
| Loyalty tier | LOYALTY_TIER |
GJ-000101 |
| Employee verification | EMPLOYEE_VERIFICATION |
EMP-12345 |
| Galaxy ID login alone | GALAXY_ID_LOGIN |
null |
One look at an entitlement record tells you why that person has that access.
6. Degraded behaviour
loyalty.js never throws. Every failure path returns "no tier" with a source field recording why β so the lower tier can be explained afterwards.
source |
Meaning |
|---|---|
loyalty |
Looked up, is a member |
not-a-member |
Looked up, is not a member β normal |
skipped |
An employee; loyalty was not asked |
timeout |
Exceeded 2000 ms |
unreachable |
Could not connect |
http-503, http-401β¦ |
Loyalty returned an error |
not-configured |
LOYALTY_URL is unset |
bad-response |
Unparseable reply |
The first three are normal. Every other value is logged at warn β otherwise nobody could explain why a GOLD customer received BASIC.
7. Mock data
All synthetic. .test is reserved by RFC 6761 and can never resolve.
Deliberately not a mirror of the Galaxy ID user list. Some members have no Galaxy ID; some Galaxy ID holders are not members. That mismatch is the normal state of two systems owned by different teams, and the platform has to survive it.
| memberRef | Tier | Status | Wi-Fi benefit | Result | |
|---|---|---|---|---|---|
an.nguyen@example.test |
GJ-000101 | GOLD | ACTIVE | β | WIFI_CUSTOMER_PLUS Β· BOOST Β· 2 dev |
dung.pham@example.test |
GJ-000104 | GOLD | ACTIVE | β | as above |
linh.dao@example.test |
GJ-000109 | GOLD | ACTIVE | β | (no Galaxy ID) |
phuc.ngo@example.test |
GJ-000112 | GOLD | ACTIVE | β | (no Galaxy ID) |
binh.tran@example.test |
GJ-000102 | SILVER | ACTIVE | β | WIFI_CUSTOMER_PLUS Β· BASIC Β· 2 dev |
em.hoang@example.test |
GJ-000105 | SILVER | ACTIVE | β | as above |
minh.ly@example.test |
GJ-000110 | SILVER | ACTIVE | β | (no Galaxy ID) |
chi.le@example.test |
GJ-000103 | NONE | ACTIVE | β | WIFI_CUSTOMER_BASIC Β· 1 dev |
nga.trinh@example.test |
GJ-000111 | NONE | ACTIVE | β | (no Galaxy ID) |
giang.vo@example.test |
GJ-000106 | GOLD | SUSPENDED | β | β οΈ flattened to NONE β BASIC |
hoa.dang@example.test |
GJ-000107 | GOLD | ACTIVE | β | β οΈ GOLD but no Wi-Fi benefit β BASIC |
khanh.bui@example.test |
GJ-000108 | SILVER | CLOSED | β | β οΈ flattened to NONE β BASIC |
The last three are the interesting ones. High tier, no benefit β each for a different reason.
A Galaxy ID user absent from loyalty gets found: false and the baseline. That is the most common case in practice.
8. Fault injection
# Loyalty down β the customer must still get online
curl -X POST localhost:3006/admin/fault -H 'Content-Type: application/json' \
-d '{"mode":"unavailable"}'
# Loyalty hanging β the login must not hang with it
curl -X POST localhost:3006/admin/fault -H 'Content-Type: application/json' \
-d '{"mode":"timeout","delayMs":5000}'
# Back to normal
curl -X POST localhost:3006/admin/fault -H 'Content-Type: application/json' \
-d '{"mode":"none"}'
POST /admin/faultis a test harness with no authentication. Delete it before any deployment.
Measured: with loyalty hanging for 5 s, the login still completes in ~2.1 s and returns the baseline.
9. Open questions
| Ref | Question |
|---|---|
| T-LOYALTY-KEY | Look up by email, or by a member_ref stored on Galaxy ID? |
| T-TIER | Tier names and what each earns β no business confirmation. ADR-011, T1 |
| T-LOYALTY-SOR | What is the real loyalty system β GalaxyJoy, SkyJoy, or both? What is its contract? |
| T-LOYALTY-SYNC | Synchronous lookup on the login path, or pre-synced and cached? |
| T-LOYALTY-PII | Does sending an email to the loyalty system need its own legal basis? |
API-OVERVIEW.md Β· KEYCLOAK-INTEGRATION-SPEC.md Β· DMN-DECISION-SPEC.md Β· DATA-CLASSIFICATION.md