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.

Email 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/fault is 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

GIW POC Identity Platform Β· local demo Β· not production Β· generated from the repository