# Analysis — the client's reading of `https://api.transmekong.com/v1`

**OPINION.** Nothing below is a conformance finding; it is what a careful outside reader would say next, with the evidence each remark rests on. Tags: `contract` = declare it · `wire` = fix it · `model` = a modelling decision.

## Invariants the contract states, measured

- ✔ **availability-le-inventory** — availability ≤ inventory ≤ estate — each step only shrinking. 210 (day, service, sale type) cells compared against the catalogue's inventory.
- ✔ **closed-vs-full** — closed (not operating) and full (sold out) are different facts. states seen over the window: {'on-sale': 43, 'full': 17} — the distinction can only be judged when a closed or full day occurs.
- ? **quote-lines-sum** — a quote's lines account for its total. 0 priced quote(s) summed.
- ✗ **age-groups-tile** — age groups tile 0–120: every age belongs to exactly one group. 2 service(s) with age groups.
    - ✗ accommodation/nambo: child 5–4 is inverted
    - ✗ accommodation/lighthouse: child 5–4 is inverted
- ✔ **unenforced-rules-named** — a rule with enforced:false is our stated position, not what the system charges. 2 stated-not-enforced rule(s): accommodation/nambo: booking-grace, accommodation/lighthouse: booking-grace.

## Suggestions, most unblocking first

### `contract` · structure.no-range-availability — GET /availability

30 requests for 30 days.  
Why it matters: A month view is the first thing a booking widget paints; the contract gives it no single call.  
Suggestion: `GET /availability?from&days` (or `to`), bounded, answering `{days: [{date, entries}]}` — additive, so v1 keeps its shape.  
Seen: calendar 2026-09-07 + 30: 30 exchanges

### `wire` · structure.no-pictures — contentRefs

No service references a picture.  
Why it matters: The contract names a static /media/ path and a `contentRefs` map; neither is populated, so a page has text only.  
Suggestion: Land `contentRefs` with at least a cover and a gallery per service, as paths under /media/ the front door can list.  
Seen: No service payload carries a picture reference — no contentRefs, no URL-shaped value, nothing under /media/. The page shows none rather than a placeholder.

### `contract` · structure.availability-undeclared-useful — AvailabilityEntry

Every availability entry carries `nights`, `asOf`, which the contract does not declare.  
Why it matters: `asOf` is exactly the freshness signal a cache needs and `nights` says what the vacancies were computed for; undeclared, a reader dares not depend on either.  
Suggestion: Declare them: `asOf` (date-time, when the feed was read) and `nights` (the stay length the entry answers for).  
Seen: seen on 60 entries: {'nights': 60, 'asOf': 60}

### `model` · structure.cruise-words-on-a-stay — Service

A hotel night carries `itinerary`, `embarkTimeLocal`, `embarkPlaceId`, `disembarkPlaceId`, `durationMinutes` (itinerary.kind = 'route', embarkTimeLocal = '14:00').  
Why it matters: A reader building a hotel page meets a boat's vocabulary: is 14:00 an embarkation or a check-in? Does a route apply to a room? The words are the model's, and the model is right to have one Service; the reader still pays.  
Suggestion: Either neutral names on the shared Service (`startTimeLocal`, `startPlaceId`, `movement`) with the family docs mapping them, or family-specific optional blocks (`stay: {checkIn, checkOut}`, `cruise: {embark…}`) beside the shared core.  
Seen: accommodation/nambo: itinerary={'kind': 'route'}, embarkTimeLocal='14:00', embarkPlaceId=None, disembarkPlaceId=None, durationMinutes=None

### `model` · structure.variant-without-axes — variants[].axes

`accommodation/nambo` has variant `standard` with no axes at all.  
Why it matters: The contract says variantId IS the axis value and nothing is minted; a variant with empty axes is a minted token by construction, and a reader cannot recover what `standard` means.  
Suggestion: For an axis-less family, either declare a family axis (`rate-plan`, `room-only`…) whose value this is, or state in the contract that a family with no axes carries exactly one variant named by convention.  
Seen: accommodation/nambo: variants = [{'axes': [], 'variantId': 'standard', 'title': 'Nam Bộ Boutique Hotel', 'onSale': True}] (+1 more: accommodation/lighthouse: variants = [{'axes': [], 'variantI)

### `wire` · structure.empty-array-for-object — content

`content` arrives as `[]`.  
Why it matters: The contract declares an object; an empty PHP array serialises as a list, so a reader's `content.title` raises on a list. An absent object and an empty one are different facts and neither is a list.  
Suggestion: Emit `{}` (or omit the key) — `(object)[]` at the serialiser, and a test that every declared object is a dict on the wire.  
Seen: accommodation/nambo: content = [] (+1 more: accommodation/lighthouse: content = [])

### `wire` · structure.list-carries-detail — GET /services

The list and the detail are the same object (18 keys); the list is ~11 KB for 2 services.  
Why it matters: A catalogue page downloads compositions and refund ladders for services it will never open; at ten services it is a quarter-megabyte first paint.  
Suggestion: A light list (identity, title, nights, exclusivity, variants) and the deep answer on the detail — which is what the contract already draws.  
Seen: list keys == detail keys: ['ageGroups', 'compositions', 'content', 'counterpart', 'disembarkPlaceId', 'durationMinutes']…
