05 — API Design
Companion: 02 Enterprise Architecture · 03 Microservices Catalog · 04 Event-Driven Architecture · 06 Data Models · 07 Security & Tenancy · 12 Desktop Spec · Standards · NAMING · Standards · ERROR_CODES
This is the authoritative API contract document for Ghasi Melmastoon. It defines the surfaces (three BFFs + internal service-to-service REST + async via Pub/Sub), conventions, request and response envelopes, error model, pagination, filtering, versioning, authentication and authorization, the BFF contracts, the offline sync API consumed by the Electron desktop, outbound webhooks, server-sent events, file uploads, rate limiting, internationalization, the OpenAPI publishing pipeline, the Postman collection layout, and concrete worked examples. Every per-service API_CONTRACTS.md defers to this document for cross-cutting concerns.
The cloud is GCP. The desktop is Electron. All naming follows MELMASTOON.* / melmastoon.* per NAMING.md. Error codes resolve through ERROR_CODES.md.
1. API Surfaces
Five surface classes; each has different audience, auth, latency budget, and data shape. They are not interchangeable.
| # | Surface | Style | Audience | Auth | Owned by |
|---|---|---|---|---|---|
| 1 | bff-consumer-service | REST + JSON | Anonymous + low-trust guests on web/mobile (consumer meta) | Anonymous + soft session | bff-consumer-service |
| 2 | bff-tenant-booking-service | REST + JSON + SSE (booking-flow heartbeats) | Guest scoped to one tenant; in-funnel | Anonymous + booking-session token; payment-scoped JWT at checkout | bff-tenant-booking-service |
| 3 | bff-backoffice-service | REST + JSON + SSE + sync HTTP | Authenticated staff on Electron desktop; long-lived; offline-first | Tenant JWT + device binding + RBAC + ABAC | bff-backoffice-service |
| 4 | Internal service-to-service REST | REST + JSON | NestJS microservices over the VPC | OIDC service-account JWTs (Cloud Run) | each service publishes its own /api/v1 |
| 5 | Internal service-to-service async | GCP Pub/Sub events (envelope per 04) | NestJS microservices | Pub/Sub IAM | each service per 04 §3 |
A sixth surface — outbound webhooks to tenant-configured URLs — exists for tenants that want push notifications about their own events (§11). Inbound webhooks from third parties (PayPal, MFS, lock vendors) terminate at payment-gateway-service or lock-integration-service directly and are out of scope of this document beyond the signature verification rules.
Boundary rules:
- Frontends never call internal services directly. They call a BFF or a service's
/api/v1exposed through Cloud Load Balancer + Cloud Armor. - BFFs may read from multiple internal services in parallel; they may write through orchestration only when the saga is explicit and tracked.
- Internal services never call a BFF.
- Cross-service writes go through Pub/Sub events (04), not synchronous REST chains > 2 hops.
2. REST Conventions
2.1 Base URL
Production https://api.melmastoon.ghasi.io
Staging https://api.staging.melmastoon.ghasi.io
Tenant booking site https://{tenant-slug}.melmastoon.ghasi.io
2.2 Path Pattern
/api/v<N>/<resource-plural-kebab> # internal service surface
/bff/consumer/v<N>/<feature> # consumer meta BFF
/bff/tenant-booking/v<N>/<feature> # tenant booking BFF
/bff/backoffice/v<N>/<feature> # backoffice BFF
/sync/v<N>/<pull|push> # offline sync
/webhooks/v<N>/<provider> # inbound 3rd-party
- Lowercase, kebab-case, no trailing slash.
- Plural-noun collections:
/reservations,/properties,/key-credentials. - Sub-resources:
/reservations/{id}/folio,/properties/{id}/rooms. - Action endpoints when REST shape doesn't fit:
POST /reservations/{id}/check-in,POST /key-credentials/{id}/revoke.
2.3 HTTP Verbs
| Verb | Semantics | Idempotent | Cacheable |
|---|---|---|---|
GET | Read; no side effects | yes | yes (per Cache-Control) |
POST | Create or invoke action; non-idempotent unless Idempotency-Key provided | only with Idempotency-Key | no |
PUT | Replace a resource (rare; we prefer PATCH) | yes | no |
PATCH | Partial update; requires If-Match for concurrency | yes (with If-Match) | no |
DELETE | Remove or revoke | yes | no |
2.4 Status Codes
| Code | Use |
|---|---|
200 OK | Successful read/write returning a body |
201 Created | Resource created (POST); Location header points to the resource |
202 Accepted | Async action accepted; operation continues server-side (sagas, AI) |
204 No Content | Successful DELETE or no-body action |
400 Bad Request | Malformed request (missing required header, invalid JSON) |
401 Unauthorized | Missing or invalid auth |
402 Payment Required | Tenant plan limit hit (MELMASTOON.TENANT.PLAN_LIMIT_EXCEEDED); also payment decline (MELMASTOON.PAYMENT.DECLINED) |
403 Forbidden | Authenticated but disallowed (RBAC/ABAC, tenant-mismatch, surface-mismatch) |
404 Not Found | Resource missing or hidden by RLS — never differentiated cross-tenant |
409 Conflict | State transition rejected, room state conflict, idempotency reuse with different body |
410 Gone | Hold expired, quote expired, sync cursor too old |
412 Precondition Failed | If-Match mismatch on optimistic concurrency |
413 Payload Too Large | Sync push batch over budget; theme asset over budget |
422 Unprocessable Entity | Domain validation failed; cross-tenant reference; invalid enum |
429 Too Many Requests | Rate limit; carries Retry-After and RateLimit-* headers |
500 Internal Server Error | Uncaught error; alert paged |
502 Bad Gateway | Downstream vendor failure (lock, payment, AI provider) |
503 Service Unavailable | Service degraded; carries Retry-After |
504 Gateway Timeout | Downstream timeout (BFF aggregate timeout, payment gateway timeout) |
2.5 Idempotency-Key
Mandatory on every POST/PATCH that mutates money, inventory, or key credentials. Strongly recommended on all other writes. Format: ULID. Stored server-side per (tenantId, route, principal, idempotencyKey) for 24 hours; replay returns the original response. Replay with a different body returns 409 MELMASTOON.SYNC.IDEMPOTENCY_KEY_REUSED.
Idempotency-Key: 01H8YN7Q2P7GZ4F8Y5CK4MV3DT
Idempotency is implemented in a NestJS interceptor (IdempotencyInterceptor) backed by Memorystore.
3. Request Envelope
3.1 Required Headers (most writes)
| Header | Purpose | Required on |
|---|---|---|
Authorization: Bearer <jwt> | Access token | All authenticated routes |
X-Tenant-Id: tnt_<ulid> | Tenant context; must match JWT tid claim | All tenant-scoped routes |
X-Request-Id: req_<ulid> | Request correlation; generated by client or assigned by gateway | All requests |
X-Idempotency-Key: <ulid> | Idempotency token | All money / inventory / key writes |
Accept-Language: <bcp47> | Locale negotiation; falls back per §15 | All routes returning user-visible text |
X-Device-Id: dev_<ulid> | Device binding | All bff-backoffice and /sync/v1 routes |
traceparent: 00-<traceid>-<spanid>-01 | W3C trace context | All routes (auto-injected by gateway if absent) |
If-Match: "<etag>" | Optimistic concurrency on PATCH | All optimistic-concurrency routes |
Content-Type: application/json; charset=utf-8 | JSON only | All requests with a body |
3.2 Optional Headers
| Header | Purpose |
|---|---|
If-None-Match: "<etag>" | Cache validation on GET |
Prefer: return=minimal | Skip body in 200/201; return Location only |
X-Client-Mutation-Id: <ulid> | Required on POST /sync/v1/push mutations (§10) |
X-Tenant-Locale-Override: <bcp47> | Backoffice operator overriding the guest's preferred locale |
X-Replay: true | Marks a desktop replay request; server may relax some rate limits |
3.3 Body
All bodies are application/json; charset=utf-8. UTF-8 only; no BOM. Numbers fit IEEE 754 doubles except money which is always bigint-string in micro-units (e.g. "totalAmountMicro": "12500000" for AFN 12.50). Currencies are ISO 4217 codes (AFN, USD, IRR, TJS, EUR).
4. Response Envelope
Every response is wrapped. The wrapper is non-negotiable; it is generated by a NestJS interceptor and asserted by every contract test.
4.1 Success — Single Resource
{
"data": {
"id": "rsv_01H8YN7Q2P7GZ4F8Y5CK4MV3DT",
"status": "Confirmed",
"tenantId": "tnt_01H7...",
"...": "..."
},
"meta": {
"requestId": "req_01H8YN7Q2P...",
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
"apiVersion": "v1.42",
"etag": "\"v9\""
}
}
4.2 Success — Collection
{
"data": [ { "id": "rsv_01H...", "...": "..." } ],
"meta": {
"requestId": "req_01H...",
"traceId": "00-...",
"apiVersion": "v1.42",
"page": {
"limit": 50,
"nextCursor": "eyJsIjoiMjAyNi0wNC0yMlQxMDoxNTowMFoifQ==",
"hasMore": true
},
"filters": { "status": "Confirmed" },
"sort": [ { "field": "createdAt", "dir": "desc" } ]
}
}
4.3 Error — Problem+JSON
We extend RFC 9457 (application/problem+json). Every error carries a stable code from ERROR_CODES.md.
{
"error": {
"type": "https://errors.melmastoon.ghasi.io/reservation/overbooking-blocked",
"title": "Overbooking blocked",
"status": 409,
"detail": "Confirming this booking would exceed allocation; another reservation took the last unit.",
"instance": "/api/v1/reservations/rsv_01H.../confirm",
"code": "MELMASTOON.RESERVATION.OVERBOOKING_BLOCKED",
"traceId": "00-...",
"requestId": "req_01H...",
"tenantId": "tnt_01H...",
"retriable": false,
"retryAfter": null,
"userMessageKey": "errors.reservation.overbooking_blocked",
"docUrl": "https://docs.melmastoon.ghasi.io/errors/reservation/overbooking-blocked",
"runbook": "https://runbooks.melmastoon.ghasi.io/reservation/overbooking",
"errors": [
{ "field": "rooms[0].roomTypeId", "code": "no_availability" }
]
}
}
The Content-Type for error responses is application/problem+json. Clients dispatch on error.code, not on title or detail.
5. Pagination
5.1 Cursor-Based by Default
GET /api/v1/reservations?cursor=eyJsIjoiMjAyNi0wNC0yMlQxMDoxNTowMFoifQ==&limit=20
| Parameter | Type | Default | Max |
|---|---|---|---|
cursor | opaque base64url string | (start of stream) | — |
limit | int | 50 | 100 |
Response carries meta.page.nextCursor (null if at end) and meta.page.hasMore (boolean). Cursors are opaque — never decoded by clients. Cursors are valid for 24 hours; an expired cursor returns 410 MELMASTOON.SYNC.CURSOR_OUT_OF_RANGE (sync surface) or 422 MELMASTOON.SEARCH.PAGE_OUT_OF_RANGE (search surface).
5.2 Offset Pagination — Admin Search Only
Cursor pagination cannot do "page 47 of 312". The platform-admin and tenant-admin search consoles need that. Only those routes accept offset:
GET /bff/backoffice/v1/admin/audit-search?offset=920&limit=40
offset is capped at 10,000 — beyond that we return 422 MELMASTOON.SEARCH.PAGE_OUT_OF_RANGE and the operator must add filters.
5.3 Why Cursor First
Cursor pagination tolerates writes during paging (no skipped or duplicated rows), is cheaper at scale (no OFFSET scan), and works trivially in BigQuery / pgvector backed search. Offset pagination is reserved for human "deep-link" UX; cursor handles every other case.
6. Filtering & Sorting
6.1 Filtering
Bracket-notation filters per resource:
GET /api/v1/reservations?filter[status]=Confirmed&filter[checkInAt][gte]=2026-04-01&filter[propertyId]=ppt_01H...
Supported operators per field type:
| Type | Operators |
|---|---|
string | eq (default), ne, in, contains |
enum | eq, ne, in |
number, int, money | eq, ne, gt, gte, lt, lte, between |
date, datetime | eq, gt, gte, lt, lte, between |
bool | eq |
geo | near (with radiusKm), within (bounding box) |
Each endpoint declares its filterable fields in OpenAPI. Unknown filter fields return 422 MELMASTOON.GENERAL.VALIDATION_FAILED with the unknown field listed.
6.2 Sorting
GET /api/v1/reservations?sort=checkInAt,-createdAt
Comma-separated, leading - for descending. Each endpoint declares sortable fields in OpenAPI; unknown fields return 422.
6.3 Field Selection (Sparse Fieldsets)
For payload-sensitive low-bandwidth surfaces:
GET /bff/consumer/v1/listings?fields=id,name,minPrice,maxPrice,thumbnail
Field selection applies after pagination, filtering, and sorting; it never reduces the work the server does. Used by the consumer mobile app on first paint.
7. Versioning
7.1 URI Versioning
The major version is in the URI: /api/v1/..., /api/v2/.... Minor versions are reflected in the X-API-Version response header (e.g. v1.42). Patch versions are server-internal only.
7.2 Deprecation Headers
When a version is on a sunset path, every response in that version carries:
Deprecation: true
Sunset: Wed, 22 Oct 2026 00:00:00 GMT
Link: <https://docs.melmastoon.ghasi.io/migrations/v1-to-v2>; rel="deprecation"
The minimum overlap window between v<n> and v<n+1> is 6 months. The window may be extended per consumer; it cannot be shortened without an ADR.
7.3 What Counts as Breaking
| Change | Bumps version? |
|---|---|
| Add an optional response field | No |
| Add an optional request field with a default | No |
| Add an enum value the client may not know | No (clients must ignore unknown enum values) |
| Tighten a numeric range or regex | Yes if any prior request would now be rejected |
| Make an optional request field required | Yes |
| Remove a response field | Yes |
| Change a field's type | Yes |
| Rename a path | Yes |
| Change the meaning of an existing enum value | Yes |
| Change the auth requirement of an endpoint | Yes |
CI runs openapi-diff between the PR branch's emitted openapi.json and the current production version per service; breaking changes without a /v<n+1>/ path fail the build.
7.4 Event Versioning
Event subjects carry their own version (*.v1, *.v2) per 04 §5. API versioning and event versioning are independent — bumping an API version does not bump every event the API touches.
8. Authentication & Authorization
8.1 Tokens
| Token | Lifetime | Use |
|---|---|---|
| Access JWT | 15 minutes | Sent in Authorization: Bearer …; carries sub, tid (tenant), roles, device (optional), aud, iat, exp, jti |
| Refresh token | 30 days, rotating (one-time use) | Exchanged for a new access + refresh pair; rotation invalidates the prior refresh; reuse triggers session revocation |
| Device-bound refresh (Electron desktop) | 30 days, rotating, device-bound | Refresh is sealed to the device's hardware-bound key (OS keychain via keytar); cannot be lifted to another machine |
| Booking session token (consumer) | 30 minutes | Soft session for in-funnel booking on bff-tenant-booking-service (no PII rights, only funnel state) |
| Service account JWT (Cloud Run) | 1 hour | OIDC-signed by Google for service-to-service internal calls |
| Webhook signing secret | rotated per tenant | HMAC-SHA256 (§11) |
8.2 SSO
Chain operators (multi-property) and platform staff use OIDC or SAML SSO via iam-service. Identity providers tested:
- Google Workspace (OIDC)
- Microsoft Entra ID (OIDC + SAML)
- Generic OIDC (Auth0, Okta, Keycloak)
- Generic SAML 2.0
SSO is opt-in per tenant; small independents use the password + WebAuthn flow.
8.3 ABAC + RBAC
Every authenticated request carries a tenant claim (tid) and a role set. Authorization is the intersection of:
| Layer | Mechanism |
|---|---|
| RBAC (coarse) | Roles: Owner, GeneralManager, FrontDesk, Housekeeping, Maintenance, Finance, ChainOperator, MarketingReviewer, PlatformAdmin. Each route declares the role set it accepts. |
| ABAC (fine) | Attributes: tenantId (mandatory), propertyId (when relevant), dataResidency, deviceBound, surface (which BFF), aiHITLAuthority (boolean per role for irreversible AI actions). Policies expressed as Cedar-style rules in iam-service. |
The tenantId claim is mandatory on every tenant-scoped route. A request whose Authorization JWT tid does not equal X-Tenant-Id is rejected at the gateway with 403 MELMASTOON.TENANT.NOT_A_MEMBER. Cross-tenant references inside the request body are rejected at the application layer with 422 MELMASTOON.GENERAL.CROSS_TENANT_REFERENCE.
8.4 Device Binding
The Electron desktop pairs with iam-service once per install. Pairing produces a DeviceId and a device-bound key (stored in OS keychain via keytar). Refresh tokens issued to that device are sealed to the key; using the refresh on another machine returns 403 MELMASTOON.IDENTITY.DEVICE_NOT_BOUND and silently revokes the original session. Re-pairing requires either operator MFA or platform-staff override.
9. BFF Contracts
The platform ships three BFFs (02 §5). Each one's scope, audience, and composition responsibilities are summarized below; per-route OpenAPI is in each BFF's repo.
9.1 bff-consumer-service
Audience. Anonymous + low-trust guests on the consumer meta web (Next.js) and the React Native consumer mobile app.
Scope. Cross-tenant meta-search (read-only). Never sees a tenant JWT; never touches lock/payment internals; never composes mutations.
Hot endpoints:
| Path | Verb | Purpose |
|---|---|---|
/bff/consumer/v1/search | POST | Geo + dates + filters → cross-tenant ranked listings (list view) |
/bff/consumer/v1/search/map | POST | Same query → bounding-box + map-pin payload |
/bff/consumer/v1/listings/{propertyId} | GET | Hotel detail page (rooms, amenities, photos, policies) for the meta surface |
/bff/consumer/v1/listings/{propertyId}/availability | GET | Light availability snapshot (from search-aggregation-service projection) |
/bff/consumer/v1/handoff | POST | Mints a soft booking-session token + redirect URL into the tenant booking flow |
Composition. Reads exclusively from search-aggregation-service (cross-tenant projection). Never reads from reservation-service, inventory-service, or pricing-service directly; the projection is the only legitimate cross-tenant read path (02 §6.3).
Cache posture. Aggressive — Memorystore + Cloud CDN for static facets (amenity catalog, geo cells); 60s TTL for availability snapshots (with stampede protection).
9.2 bff-tenant-booking-service
Audience. Guest scoped to one tenant; in-funnel; soft session.
Scope. Tenant-themed booking funnel from selection through confirmation. Composes theme + room/rate/availability + price quote + payment redirect.
Hot endpoints:
| Path | Verb | Purpose |
|---|---|---|
/bff/tenant-booking/v1/bootstrap | GET | Tenant theme tokens + locale + content blocks for a property |
/bff/tenant-booking/v1/properties/{id}/room-types | GET | Room types + photos for the booking page |
/bff/tenant-booking/v1/properties/{id}/rates | GET | Available rate plans for a stay window |
/bff/tenant-booking/v1/availability | POST | Per-room-type availability for a stay window (composes inventory + pricing) |
/bff/tenant-booking/v1/quote | POST | Final quote (price + taxes + cancellation policy + currency snapshot) |
/bff/tenant-booking/v1/booking-intents | POST | Captures intent; mints booking-session token; returns funnel state |
/bff/tenant-booking/v1/booking-intents/{id}/payment-method | POST | Selects payment rail (PayPal / card / cash-on-arrival) |
/bff/tenant-booking/v1/booking-intents/{id}/confirm | POST | Confirms (with Idempotency-Key); kicks the booking saga; returns 202 + status URL |
/bff/tenant-booking/v1/booking-intents/{id}/status | GET / SSE | Polls or streams saga status until confirmed / cancelled |
Composition. Reads from theme-config-service, property-service, pricing-service, inventory-service in parallel for bootstrap; writes through reservation-service (which orchestrates the booking saga per 04 §8.1) and payment-gateway-service.
One tenant per request. The BFF asserts the tenantId in the request matches the property; mismatches return 403 MELMASTOON.BFF.SURFACE_MISMATCH.
9.3 bff-backoffice-service
Audience. Authenticated staff on the Electron desktop; long-lived sessions; offline-first.
Scope. Dashboard composition, sync hand-off (§10), AI suggestion fan-out, lock action proxying, multi-property switcher for chain operators.
Hot endpoints:
| Path | Verb | Purpose |
|---|---|---|
/bff/backoffice/v1/session/bootstrap | GET | Operator profile + tenant + property switcher + feature flags + sync cursors |
/bff/backoffice/v1/dashboard | GET / SSE | Live KPIs + alerts + AI insights (SSE for the live tail) |
/bff/backoffice/v1/check-in-board | GET / SSE | Today's arrivals + departures + room status; updates as events flow |
/bff/backoffice/v1/reservations/search | POST | Multi-facet reservation search (composes reservation-service + inventory-service + billing-service) |
/bff/backoffice/v1/key-credentials/issue | POST | Proxies to lock-integration-service with audit + retry semantics |
/bff/backoffice/v1/ai/suggestions | GET / SSE | Live AI suggestions (pricing, housekeeping, anomaly) with provenance |
/bff/backoffice/v1/ai/decisions | POST | Records HITL decisions on AI suggestions |
/sync/v1/pull | POST | Sync pull (delegated to sync-service); see §10 |
/sync/v1/push | POST | Sync push (delegated to sync-service); see §10 |
Composition. Reads from every operational service; writes are delegated downstream (reservations through reservation-service, keys through lock-integration-service, charges through billing-service, etc). The BFF never owns mutation state; it owns the shape the desktop expects.
Strong auth. Tenant JWT + device binding + per-route role check + ABAC; no anonymous traffic; all routes require X-Device-Id.
10. Sync API
The Electron desktop is offline-first (ADR-0003). The platform exposes one sync protocol — /sync/v1/pull and /sync/v1/push — owned by sync-service and surfaced through bff-backoffice-service. This is the only path by which the desktop reads or writes during catch-up; live operations also go through this protocol when the desktop chooses to defer (poor link, batched typing, etc).
10.1 POST /sync/v1/pull
POST /sync/v1/pull HTTP/1.1
Authorization: Bearer <jwt>
X-Tenant-Id: tnt_01H...
X-Device-Id: dev_01H...
X-Request-Id: req_01H...
Content-Type: application/json; charset=utf-8
{
"since": "eyJsIjoiMjAyNi0wNC0yMlQxMDowMDowMFoifQ==",
"aggregates": ["reservation","room","folio_draft","housekeeping_task","key_credential","staff_schedule","guest_profile"],
"maxBatch": 500
}
Response:
{
"data": {
"deltas": [
{
"aggregateType": "reservation",
"aggregateId": "rsv_01H...",
"version": 14,
"op": "upsert",
"payload": { "...": "..." },
"occurredAt": "2026-04-22T10:14:32.118Z",
"causationEventId": "evt_01H..."
}
],
"nextCursor": "eyJsIjoiMjAyNi0wNC0yMlQxMjowNjozMVoifQ==",
"hasMore": true,
"heartbeatAt": "2026-04-22T12:06:31.001Z"
},
"meta": { "requestId": "req_01H...", "traceId": "00-..." }
}
| Field | Notes |
|---|---|
since | Opaque cursor from the prior pull; null for first pull (server returns initial snapshot up to maxBatch) |
aggregates | Whitelist of aggregate types the device wants; server intersects with the device's permission scope |
maxBatch | Capped server-side at 500; larger requests return 400 |
deltas | Ordered by occurredAt then version; server guarantees per-aggregate causal order (per 04 §11) |
op | upsert or tombstone (soft delete) |
nextCursor | Opaque; pass back as since |
hasMore | True if more deltas exist; client should pull again immediately |
Cursor expiry. A cursor older than 14 days returns 410 MELMASTOON.SYNC.CURSOR_OUT_OF_RANGE. The desktop must perform a full rebase pull (drop local state for the aggregates concerned and pull from null).
10.2 POST /sync/v1/push
POST /sync/v1/push HTTP/1.1
Authorization: Bearer <jwt>
X-Tenant-Id: tnt_01H...
X-Device-Id: dev_01H...
X-Idempotency-Key: 01H8YN7Q2P7GZ4F8Y5CK4MV3DT
Content-Type: application/json; charset=utf-8
{
"mutations": [
{
"clientMutationId": "01H8YN7Q2P...",
"aggregateType": "housekeeping_task",
"aggregateId": "hkt_01H...",
"op": "complete",
"payload": { "completedAt": "2026-04-22T11:48:00Z", "actorId": "stf_01H..." },
"baseVersion": 3,
"vectorClock": { "dev_01H...": 12, "server": 8 },
"conflictPolicyHint": "append_only"
}
]
}
Response:
{
"data": {
"results": [
{
"clientMutationId": "01H8YN7Q2P...",
"status": "applied",
"serverState": { "version": 4, "...": "..." }
}
]
},
"meta": { "requestId": "req_01H...", "traceId": "00-..." }
}
Per-mutation status:
| Status | Meaning | Client action |
|---|---|---|
applied | Mutation applied; serverState returned | Replace local state with serverState |
noop | Idempotent reapply; server already had it | Replace local state with serverState |
conflict | Conflict per the aggregate's policy; serverState + conflict returned | Surface to operator (notes/profile) or auto-resolve per policy (02 §8.2) |
rejected | Server-authoritative invariant violation | Drop local state; pull fresh; surface error to operator |
Per-aggregate conflict policy hints. The client sends conflictPolicyHint; the server validates against the canonical policy (02 §8.2) and rejects mismatches with 409 MELMASTOON.SYNC.MUTATION_REJECTED.
413 Payload Too Large. A push batch must stay under 256 KiB compressed and 100 mutations. Larger batches return 413 MELMASTOON.SYNC.PAYLOAD_TOO_LARGE; the client splits and retries. The Electron sync engine pre-splits but the server enforces the limit defensively.
Idempotency-Key on the batch + clientMutationId per mutation. The header guards the batch; the per-mutation id guards the individual mutation. Replay of the batch is safe; partial replay (a sub-set of mutations) is also safe because each mutation is keyed independently.
11. Webhooks (Outbound to Tenants)
Tenants may configure webhook endpoints to receive selected events about their own data. Configured per tenant in tenant-service; delivered by notification-service.
11.1 Signing
Every webhook POST is signed with HMAC-SHA256 using the tenant's webhook secret:
POST https://tenant-host.example/webhooks/melmastoon
X-Melmastoon-Signature: t=1745318400,v1=9c8d... (HMAC-SHA256 of `${t}.${rawBody}`)
X-Melmastoon-Event: melmastoon.reservation.booking.confirmed.v1
X-Melmastoon-Delivery: dlv_01H...
X-Melmastoon-Idempotency-Key: rsv_01H...:confirmed
Content-Type: application/json
The receiver must verify the signature (sample code in the docs), enforce a 5-minute time window on t, and treat X-Melmastoon-Idempotency-Key as the dedup token.
11.2 Retry
| Attempt | Backoff |
|---|---|
| 1 | immediate |
| 2 | 30 s |
| 3 | 2 min |
| 4 | 10 min |
| 5 | 1 hour |
| 6 | 6 hours |
| 7 | 24 hours (final) |
Failed deliveries are queued; the dashboard exposes a replay button that re-sends a delivery with the original payload + a new dlv_… id (and the same Idempotency-Key).
11.3 Event Subset
Tenants subscribe to a subset of platform events (mostly reservation, payment, lock, notification). The full list is in notification-service's API_CONTRACTS.md. The webhook payload is the platform event envelope (04 §4) with metadata.aiProvenance redacted.
11.4 Schema
The webhook envelope schema is published at https://schemas.melmastoon.ghasi.io/webhooks/v1.json and is versioned per 04 §5.
12. Server-Sent Events (SSE)
For backoffice live UX (KPIs, check-in feed, AI suggestions), we use SSE. SSE wins over WebSockets here because (a) it's HTTP-native (works through every corporate proxy), (b) it's one-way (server → client) which matches the use case, (c) it has built-in reconnect with Last-Event-Id, and (d) it costs less than WebSocket on Cloud Run.
GET /bff/backoffice/v1/dashboard HTTP/1.1
Accept: text/event-stream
Authorization: Bearer <jwt>
X-Tenant-Id: tnt_01H...
X-Device-Id: dev_01H...
Last-Event-Id: 01H8YN7Q2P7GZ4F8Y5CK4MV3DT
Stream:
event: dashboard.kpis
id: 01H8YN7Q2P7GZ4F8Y5CK4MV3DT
data: {"occupancyRate":0.78,"adrMicro":"4500000","arrivalsToday":12,"...":"..."}
event: checkin.feed
id: 01H8YN7Q2Q...
data: {"reservationId":"rsv_01H...","status":"checked_in","at":"2026-04-22T12:01:11Z"}
: heartbeat
| Aspect | Detail |
|---|---|
| Transport | text/event-stream; cache-control: no-cache; connection: keep-alive |
| Reconnect | Client passes Last-Event-Id; server resumes from that point (kept in Memorystore for 60s) |
| Heartbeat | Comment line every 15s to defeat idle proxies |
| Close | Server closes after 30 min idle; client reconnects |
12.1 SSE Channels
| Channel | Source |
|---|---|
dashboard.kpis | analytics-service snapshots projected per tenant |
checkin.feed | reservation-service events filtered to today's window |
housekeeping.board | housekeeping-service events |
ai.suggestions | ai-orchestrator-service decisions filtered by operator role |
lock.events | lock-integration-service events for the operator's properties |
sync.status | sync-service — sync cursor moves + conflict notifications |
13. File Upload
Direct-to-Cloud-Storage uploads via short-lived signed URLs minted by file-storage-service. Clients never POST file bytes to our APIs directly.
13.1 Flow
1. Client → POST /api/v1/file-uploads
{ kind: 'property_photo', filename, mimeType, sizeBytes, sha256 }
2. file-storage-service validates kind + size budget + mime allow-list
3. file-storage-service mints a V4 signed URL (PUT, 15-min TTL, conditioned on Content-Length + sha256)
4. Returns { uploadUrl, mediaId: 'med_01H...', maxSizeBytes }
5. Client → PUT <uploadUrl> (uploads bytes directly to Cloud Storage)
6. Client → POST /api/v1/file-uploads/{mediaId}/notify-complete
7. file-storage-service runs virus scan; emits melmastoon.file.uploaded.v1 + .scanned.v1
13.2 Chunked Uploads
For media > 32 MiB (rare — mostly videos in tenant theme content), the signed URL is a resumable upload session URL. The client uses standard Cloud Storage resumable-upload semantics; the server only sees the notify-complete callback.
13.3 Size & Type Budgets
| Kind | Max size | MIME allow-list |
|---|---|---|
property_photo | 8 MiB | image/jpeg, image/png, image/webp, image/avif |
theme_logo | 1 MiB | image/svg+xml, image/png, image/webp |
theme_hero | 8 MiB | image/jpeg, image/webp, image/avif |
theme_video | 32 MiB | video/mp4 (h.264 baseline) |
guest_id_doc | 4 MiB | image/jpeg, image/png, application/pdf |
invoice_attachment | 4 MiB | application/pdf |
Over-budget uploads return 413 MELMASTOON.THEME.ASSET_TOO_LARGE (theme) or 413 with a sized error code on the relevant domain.
14. Rate Limiting
Memorystore-backed token bucket per (tenantId, route, principal) tuple. Standard RateLimit-* response headers (RFC 9239 draft).
RateLimit-Limit: 60
RateLimit-Remaining: 47
RateLimit-Reset: 23
RateLimit-Policy: 60;w=60
When exhausted: 429 MELMASTOON.GENERAL.RATE_LIMITED with Retry-After: <seconds>.
14.1 Default Buckets
| Surface / route class | Per-principal | Per-tenant | Per-IP |
|---|---|---|---|
bff-consumer-service GET (search/listings) | — | 600 / min | 120 / min |
bff-consumer-service POST (handoff) | — | 60 / min | 30 / min |
bff-tenant-booking-service GET | 120 / min | 1200 / min | 240 / min |
bff-tenant-booking-service POST (booking-intents/confirm) | 5 / min | 60 / min | 30 / min |
bff-backoffice-service standard | 600 / min | 6000 / min | — |
bff-backoffice-service AI endpoints | 60 / min | 600 / min | — |
/sync/v1/pull | 30 / min | 600 / min | — |
/sync/v1/push | 30 / min (with X-Replay: true → 60) | 600 / min | — |
notification-service send | — | per-tenant plan limit | — |
| Webhook delivery (outbound) | — | 100 / s per endpoint | — |
payment-gateway-service create-intent | 5 / min per booking-session | 600 / min | 60 / min |
14.2 Per-Tenant Plan Overrides
Plans (Free, Starter, Pro, Plus) carry rate-limit multipliers stored in tenant-service. tenant-service writes the effective limits to Memorystore on plan change; the rate-limit interceptor reads from Memorystore (with a 60s in-process cache).
14.3 Burst Allowance
Each bucket allows a 2x burst over a 5s window. The bucket refills linearly to baseline. This prevents legitimate bursts (a front-desk operator confirming a queue of 8 walk-in check-ins in 30s) from being blocked.
15. i18n
15.1 Accept-Language Honored
Every BFF and most internal services honor Accept-Language for user-visible strings (error messages, validation errors, AI-drafted text). The negotiation order:
Accept-Languageheader (BCP 47)- Tenant preference (
tenant-service→defaultLocale) - Property preference (
property-service→localeOverride) - Platform default (
en)
The chosen locale is returned in the Content-Language response header.
15.2 Per-Tenant Fallback Chain
A tenant declares an ordered locale list, e.g. [ps, fa-AF, en]. If a string is missing in ps, fall through to fa-AF, then en. This is enforced at the i18n bundle layer (@ghasi/ui-melmastoon/i18n) and at API responses with userMessageKey.
15.3 Locale-Aware Formatting
Money, dates, and numerals are returned machine-readable in API bodies (ISO currency + ISO date + IEEE numbers) and rendered by clients per locale. We never return preformatted human strings except in error.detail (which is locale-aware via userMessageKey).
15.4 RTL/LTR
Direction is a property of the locale, not of any individual API call. Clients derive direction from the locale (ps, fa, ar → RTL; everything else → LTR) and apply logical CSS properties; the API never carries direction hints.
16. Error Model & Retry Guidance
The full registry is in ERROR_CODES.md. The table below summarizes the platform-wide retry guidance the desktop and the BFF clients use.
| HTTP | Code | Retriable | Client retry strategy |
|---|---|---|---|
| 401 | MELMASTOON.IDENTITY.TOKEN_EXPIRED | n/a | Refresh + retry once |
| 401 | MELMASTOON.IDENTITY.SESSION_REVOKED | no | Re-login; do not retry |
| 401 | MELMASTOON.PAYMENT.WEBHOOK_SIGNATURE_INVALID | no | Drop |
| 402 | MELMASTOON.PAYMENT.DECLINED | no | Surface to user |
| 402 | MELMASTOON.TENANT.PLAN_LIMIT_EXCEEDED | no | Surface to admin |
| 403 | MELMASTOON.IDENTITY.DEVICE_NOT_BOUND | no | Re-pair desktop |
| 403 | MELMASTOON.TENANT.NOT_A_MEMBER | no | Drop |
| 404 | MELMASTOON.GENERAL.RESOURCE_NOT_FOUND | no | Drop |
| 409 | MELMASTOON.RESERVATION.OVERBOOKING_BLOCKED | no | Surface alternatives to user |
| 409 | MELMASTOON.SYNC.CONFLICT_DETECTED | per policy | Resolve per aggregate policy |
| 409 | MELMASTOON.SYNC.IDEMPOTENCY_KEY_REUSED | no | Bug — log + drop |
| 410 | MELMASTOON.RESERVATION.HOLD_EXPIRED | no | Re-quote + retry funnel |
| 410 | MELMASTOON.SYNC.CURSOR_OUT_OF_RANGE | no | Full rebase pull |
| 412 | MELMASTOON.GENERAL.PRECONDITION_FAILED | no | Re-fetch + retry with new ETag |
| 413 | MELMASTOON.SYNC.PAYLOAD_TOO_LARGE | no | Split batch + retry |
| 422 | MELMASTOON.GENERAL.VALIDATION_FAILED | no | Surface field errors |
| 422 | MELMASTOON.GENERAL.CROSS_TENANT_REFERENCE | no | Bug — log + drop |
| 429 | MELMASTOON.GENERAL.RATE_LIMITED | yes | Honor Retry-After; exponential backoff with jitter |
| 500 | MELMASTOON.GENERAL.INTERNAL | yes | Exp backoff, max 3 attempts; report |
| 502 | MELMASTOON.LOCK.VENDOR_UNREACHABLE | yes | Exp backoff (10s, 30s, 2min); fall back to mechanical key flow if persistent |
| 502 | MELMASTOON.AI.PROVIDER_UNAVAILABLE | yes | Exp backoff; degrade UI to non-AI |
| 502 | MELMASTOON.BFF.UPSTREAM_UNAVAILABLE | yes | Exp backoff; partial render if BFF returned partial data |
| 503 | MELMASTOON.LOCK.CARD_ENCODER_OFFLINE | yes | Queue locally; retry on reconnect |
| 504 | MELMASTOON.PAYMENT.GATEWAY_TIMEOUT | yes | Exp backoff; do not double-charge — Idempotency-Key protects |
| 504 | MELMASTOON.BFF.UPSTREAM_TIMEOUT | yes | Exp backoff |
Universal client rules:
- Always retry with the same
Idempotency-Keyafter a 5xx; the server collapses replays. - Never retry without backoff; minimum 1s + jitter.
- Always honor
Retry-Afterwhen present. - Never retry on 4xx unless the code is
429.
17. OpenAPI
Every service emits its openapi.json to its repo root via a build-time script (scripts/emit-openapi.ts). The CI pipeline:
- Builds the service.
- Runs the emit script; fails the build if
openapi.jsonis stale or invalid. - Runs
openapi-diffagainst the production version published athttps://schemas.melmastoon.ghasi.io/openapi/<service>/v1.json. - Fails the build on breaking changes unless the version path bumps (per §7.3).
A portal aggregator job runs nightly:
- Pulls every service's
openapi.jsonfrom Artifact Registry. - Tags each with its capability cluster (02 §4).
- Publishes a unified portal at
https://docs.melmastoon.ghasi.io/api/with per-cluster navigation, code samples per language (TypeScript / cURL / Postman), and try-it-out for every endpoint markedsafeForPortal: true.
The aggregator also emits a machine-readable index (openapi-index.json) consumed by the SDK generator (TypeScript SDK) and the contract-test harness.
18. Postman Collections
We generate Postman collections per BFF and per service; the generator runs on the same CI step that emits OpenAPI. Each collection ships with:
| Folder | Contents |
|---|---|
| Environments | local, staging, prod. Variables: baseUrl, tenantId, accessToken, refreshToken, deviceId, idempotencyKey (dynamic via Postman script). |
| Auth | Pre-request scripts that exchange refresh → access if accessToken is missing or expired. |
| Smoke flows | End-to-end happy paths for booking, check-in, checkout, key issuance, sync pull, sync push. |
| Negative flows | Overbooking, payment decline, idempotency reuse, cursor expiry. |
| Per-resource | One folder per resource with full CRUD + actions. |
Generated collections are versioned per service major version; consumers fetch from https://docs.melmastoon.ghasi.io/postman/<service>-v1.postman_collection.json. We follow Postman best practices for collection structure: descriptive folder names, request descriptions populated from OpenAPI, examples for every response code we document.
19. Examples
Concrete request/response pairs for the highest-value flows. Headers truncated where boilerplate (auth, trace, content-type).
19.1 Search Hotels (Consumer Meta)
POST /bff/consumer/v1/search HTTP/1.1
Content-Type: application/json
Accept-Language: ps,fa-AF;q=0.9,en;q=0.7
{
"geo": { "lat": 34.5553, "lng": 69.2075, "radiusKm": 5 },
"checkIn": "2026-05-12",
"checkOut": "2026-05-15",
"guests": { "adults": 2, "children": 1 },
"rooms": 1,
"filters": {
"priceMicro": { "lte": "8000000" },
"amenities": ["wifi","breakfast","parking"],
"minRating": 4.0
},
"sort": "price_asc",
"limit": 20
}
{
"data": [
{
"propertyId": "ppt_01H8YN7Q2P...",
"tenantId": "tnt_01H7...",
"name": "Park Star Guesthouse",
"thumbnail": "https://cdn.melmastoon.ghasi.io/m/ppt_01H.../hero.webp",
"geo": { "lat": 34.5561, "lng": 69.2082 },
"rating": 4.4,
"amenities": ["wifi","breakfast","parking","laundry"],
"minPriceMicro": "5500000",
"maxPriceMicro": "7200000",
"currency": "AFN",
"availabilitySummary": { "anyAvailable": true, "lowStock": false }
}
],
"meta": {
"requestId": "req_01H...",
"page": { "limit": 20, "nextCursor": "eyJzIjoiMjAifQ==", "hasMore": true },
"filters": { "priceMicro": {"lte": "8000000"} },
"sort": [{"field": "price", "dir": "asc"}]
}
}
19.2 Check Availability (Tenant Booking)
POST /bff/tenant-booking/v1/availability HTTP/1.1
X-Tenant-Id: tnt_01H7...
Content-Type: application/json
{
"propertyId": "ppt_01H...",
"checkIn": "2026-05-12",
"checkOut": "2026-05-15",
"guests": { "adults": 2, "children": 1 },
"ratePlanFilter": ["BAR","WEEKLY"]
}
{
"data": {
"stay": { "checkIn": "2026-05-12", "checkOut": "2026-05-15", "nights": 3 },
"roomTypes": [
{
"roomTypeId": "rmt_01H...",
"name": "Deluxe King",
"capacity": { "adults": 2, "children": 2 },
"available": 4,
"rates": [
{
"ratePlanId": "rate_01H...",
"code": "BAR",
"totalMicro": "16500000",
"perNightMicro": "5500000",
"currency": "AFN",
"cancellation": { "freeUntil": "2026-05-10T00:00:00Z" }
}
]
}
]
},
"meta": { "requestId": "req_01H...", "traceId": "00-..." }
}
19.3 Create Booking (Tenant Booking)
POST /bff/tenant-booking/v1/booking-intents/bki_01H.../confirm HTTP/1.1
Authorization: Bearer <booking-session-token>
X-Tenant-Id: tnt_01H7...
X-Idempotency-Key: 01H8YN7Q2P7GZ4F8Y5CK4MV3DT
Content-Type: application/json
{
"guest": {
"fullName": "Layla Karimi",
"email": "layla@example.com",
"phone": "+93700000000",
"preferredLocale": "ps"
},
"paymentMethod": { "rail": "card", "tokenId": "pmt_01H..." }
}
HTTP/1.1 202 Accepted
Location: /bff/tenant-booking/v1/booking-intents/bki_01H.../status
Content-Type: application/json
{
"data": {
"bookingIntentId": "bki_01H...",
"status": "processing",
"sagaId": "sga_01H...",
"statusUrl": "/bff/tenant-booking/v1/booking-intents/bki_01H.../status"
},
"meta": { "requestId": "req_01H...", "traceId": "00-..." }
}
19.4 Check-In (Backoffice)
POST /api/v1/reservations/rsv_01H.../check-in HTTP/1.1
Authorization: Bearer <jwt>
X-Tenant-Id: tnt_01H...
X-Device-Id: dev_01H...
X-Idempotency-Key: 01H8YN7Q2P...
Content-Type: application/json
If-Match: "v9"
{
"actorId": "stf_01H...",
"guestIdDocument": { "mediaId": "med_01H...", "type": "national_id" },
"issueKey": true,
"lockTarget": { "roomId": "rmu_01H...", "vendorHint": "ttlock" }
}
{
"data": {
"reservationId": "rsv_01H...",
"status": "CheckedIn",
"version": 10,
"folio": { "id": "fol_01H...", "status": "open", "balanceMicro": "0" },
"keyCredentials": [
{ "id": "key_01H...", "validFrom": "2026-05-12T14:00:00Z", "validUntil": "2026-05-15T11:00:00Z", "vendor": "ttlock" }
],
"saga": { "id": "sga_01H...", "status": "completed" }
},
"meta": { "requestId": "req_01H...", "etag": "\"v10\"" }
}
19.5 Issue Key (Backoffice — Standalone)
POST /api/v1/key-credentials HTTP/1.1
Authorization: Bearer <jwt>
X-Tenant-Id: tnt_01H...
X-Device-Id: dev_01H...
X-Idempotency-Key: 01H8YN7Q2P...
Content-Type: application/json
{
"reservationId": "rsv_01H...",
"roomId": "rmu_01H...",
"validFrom": "2026-05-12T14:00:00Z",
"validUntil": "2026-05-15T11:00:00Z",
"vendorHint": "salto",
"delivery": { "channel": "card_encoder", "encoderId": "enc_01"}
}
{
"data": {
"id": "key_01H...",
"vendor": "salto",
"vendorReference": "salto-cred-9f8a...",
"validFrom": "2026-05-12T14:00:00Z",
"validUntil": "2026-05-15T11:00:00Z",
"delivery": { "channel": "card_encoder", "encoderId": "enc_01", "encodedAt": "2026-05-12T14:01:11Z" },
"status": "issued"
},
"meta": { "requestId": "req_01H..." }
}
If the lock vendor times out: 502 MELMASTOON.LOCK.KEY_ISSUE_FAILED with retriable: true. The Electron desktop queues the issue request and retries with the same Idempotency-Key.
19.6 Pull Sync (Desktop)
POST /sync/v1/pull HTTP/1.1
Authorization: Bearer <jwt>
X-Tenant-Id: tnt_01H...
X-Device-Id: dev_01H...
Content-Type: application/json
{
"since": null,
"aggregates": ["reservation","room","folio_draft","housekeeping_task","key_credential"],
"maxBatch": 500
}
{
"data": {
"deltas": [
{
"aggregateType": "reservation",
"aggregateId": "rsv_01H...",
"version": 9,
"op": "upsert",
"payload": {
"id": "rsv_01H...",
"status": "Confirmed",
"checkIn": "2026-05-12",
"checkOut": "2026-05-15",
"guest": { "fullName": "Layla Karimi", "preferredLocale": "ps" }
},
"occurredAt": "2026-04-22T08:14:32Z"
}
],
"nextCursor": "eyJsIjoiMjAyNi0wNC0yMlQxMDowMDowMFoifQ==",
"hasMore": true,
"heartbeatAt": "2026-04-22T10:00:00Z"
},
"meta": { "requestId": "req_01H..." }
}
19.7 Push Sync (Desktop)
POST /sync/v1/push HTTP/1.1
Authorization: Bearer <jwt>
X-Tenant-Id: tnt_01H...
X-Device-Id: dev_01H...
X-Idempotency-Key: 01H8YN7Q2P...
Content-Type: application/json
{
"mutations": [
{
"clientMutationId": "01H8YN7Q2P...",
"aggregateType": "housekeeping_task",
"aggregateId": "hkt_01H...",
"op": "complete",
"payload": { "completedAt": "2026-04-22T11:48:00Z", "actorId": "stf_01H..." },
"baseVersion": 3,
"vectorClock": { "dev_01H...": 12, "server": 8 },
"conflictPolicyHint": "append_only"
},
{
"clientMutationId": "01H8YN7Q2Q...",
"aggregateType": "guest_profile",
"aggregateId": "gst_01H...",
"op": "patch",
"payload": { "preferences": { "pillowType": "soft" } },
"baseVersion": 1,
"vectorClock": { "dev_01H...": 13, "server": 1 },
"conflictPolicyHint": "lww"
}
]
}
{
"data": {
"results": [
{
"clientMutationId": "01H8YN7Q2P...",
"status": "applied",
"serverState": { "id": "hkt_01H...", "version": 4, "status": "Completed" }
},
{
"clientMutationId": "01H8YN7Q2Q...",
"status": "conflict",
"serverState": { "id": "gst_01H...", "version": 3, "preferences": { "pillowType": "firm" } },
"conflict": { "policy": "lww", "winner": "server", "reason": "server_timestamp_later" }
}
]
},
"meta": { "requestId": "req_01H..." }
}
20. Anti-Patterns
| Anti-pattern | Why it's banned | What we do instead |
|---|---|---|
| Synchronous waterfalls > 2 hops | Couples availability; defeats offline-first | Async via Pub/Sub + saga (04 §8); BFFs do parallel reads, not chained writes |
RPC verbs in paths (/getReservation, /createBooking) | Defeats REST; defeats caching; defeats OpenAPI tooling | Resource paths + standard verbs; action endpoints (/check-in, /revoke) only when REST shape doesn't fit |
| Leaking internal IDs (raw UUIDs, internal numeric ids, internal email addresses) | Couples client to internal schema; breaks ID rotation; risks PII leak | ULIDs with service-chosen prefixes (NAMING.md); never expose internal counters or vendor reference IDs except in the field that domain requires |
| Untyped error responses | Clients hand-parse English detail; breaks i18n; breaks alerting | Problem+JSON with code per ERROR_CODES.md; clients dispatch on code |
| Idempotency by client backoff alone | Double-charge on retry-after-success | Idempotency-Key mandatory on money/inventory/key writes; server collapses replays |
| Cross-tenant joins in BFFs | Defeats RLS; defeats per-tenant cache; defeats audit | Cross-tenant only via search-aggregation-service's projection; everything else tenant-scoped |
| Bodies with secrets (API keys, refresh tokens, lock vendor credentials) | Logged accidentally; cached in proxies | Secret material in headers (auth) or in Secret Manager; never in JSON bodies |
| Direct frontend → microservice calls | Couples client to internal topology; defeats BFF; bypasses rate limits | All client traffic goes through a BFF or /sync/v1; internal services not exposed to the public LB |
| Client-side decoded cursors | Couples client to server's pagination implementation; breaks evolution | Cursors are opaque; clients pass back exactly what they received |
| Last-write-wins on money or inventory | Silent data loss | Append-only or server-authoritative (02 §8.2) |
| Polling where SSE applies | Wastes the desktop's link budget; blows rate limits | SSE for live channels (§12) |
| Versioning by header instead of URI | Hides version from logs, traces, and CDN cache keys | URI versioning (/api/v1); header carries minor version only |
Unbounded responses (no limit, no pagination) | Eats memory, breaks gateways, slows the desktop on a flaky link | Every collection paginated; limit capped at 100 |
AI calls outside ai-orchestrator-service | Bypasses moderation, provenance, budget | All AI through ports/AIClient per 02 §9 |
Cross-references: per-service API contracts live in
services/<service-name>/API_CONTRACTS.md. The event surface that REST writes feed (via the outbox) is in 04 Event-Driven Architecture. The Electron sync client implementation that consumes/sync/v1is in 12 Desktop Spec. The next document in the strategic set is 06 Data Models.