Skip to main content

search-aggregation-service — Domain Model

Companion: SERVICE_OVERVIEW · DATA_MODEL · EVENT_SCHEMAS · API_CONTRACTS · 06 Data Models · Naming

The domain layer is pure TypeScript — no I/O, no framework imports. All side effects flow through ports defined in application/ports/*.

The cardinal rule of this service. The domain model contains no PII, no payment data, no lock secrets, no per-room identifiers, and no unpublished property state. The aggregates here are projections of publicly safe slices of upstream events. Any new field requires an explicit cross_tenant_searchable: true decision recorded in DATA_MODEL §3.

1. Ubiquitous Language

TermDefinitionSample sentence
HotelIndexEntryDenormalized, cross-tenant-safe card for one published property."HotelIndexEntry for ppt_… carries name in 4 locales and a hero photo URL."
RateSnapshotCheapest displayable rate for (propertyId, date, currency)."RateSnapshot for ppt_… on 2026-05-01 in AFN: 1 250 000 micro."
AvailabilityHintPer-date count of available rooms."AvailabilityHint for ppt_… on 2026-05-01: 12 of 30 rooms remain."
AmenityIndexCanonical amenity → property mapping for facet queries."Amenity halal_kitchen matches 137 properties."
LocationIndexGeohash + PostGIS geometry for bbox/radius queries."City Kabul has 41 indexed properties within 10 km."
SearchQueryCanonicalized, hashed user query (sampled, logged)."SearchQuery q_… in en, dest=Kabul, dates=2026-05-01..03."
ClickEventRecorded deep-link click into a tenant booking surface."ClickEvent clk_… for ppt_… at rank 3 from query q_…."
BoostRuleOperator-configurable lift on a (propertyId, criteria) slice."BoostRule brt_… boosts ppt_… for region=AF, amenity=conference_hall by 1.5×."
SponsoredRankingAuction-driven slot fill (Phase 3+)."SponsoredRanking sets slot 1 to ppt_… for query bucket kabul-business."
IndexBuildControl aggregate for a full reindex from event archive."IndexBuild ibd_… replayed 12 480 propery.published events; alias swapped at 10:42."
Region pinningFirst-launch filter tenant.region ∈ {AF, TJ, IR}."Region pinning hides 4 EU tenants until tenant.region_changed.v1 opens EU."
Cross-tenant queryRead across all tenants — only allowed in this service over allow-listed fields."All searches issue a single cross-tenant query against the OpenSearch alias."

2. Branded IDs

import type { Brand } from '@ghasi/contracts-melmastoon';

// Mirrored from upstream services (the projector validates the prefix only).
export type TenantId = Brand<string, 'TenantId'>; // 'tnt_<ULID>'
export type PropertyId = Brand<string, 'PropertyId'>; // 'ppt_<ULID>'
export type RoomTypeId = Brand<string, 'RoomTypeId'>; // 'rmt_<ULID>'
export type RatePlanId = Brand<string, 'RatePlanId'>; // 'rate_<ULID>'

// Owned by this service.
export type SearchableDocumentId = Brand<string, 'SearchableDocumentId'>; // 'srh_<ULID>'
export type BoostRuleId = Brand<string, 'BoostRuleId'>; // 'brt_<ULID>'
export type SponsoredId = Brand<string, 'SponsoredId'>; // 'spr_<ULID>'
export type ClickEventId = Brand<string, 'ClickEventId'>; // 'clk_<ULID>'
export type SearchQueryId = Brand<string, 'SearchQueryId'>; // 'q_<ULID>'
export type IndexBuildId = Brand<string, 'IndexBuildId'>; // 'ibd_<ULID>'

export const SearchableDocumentId = {
parse(raw: string): SearchableDocumentId {
if (!/^srh_[0-7][0-9A-HJKMNP-TV-Z]{25}$/i.test(raw)) {
throw new InvalidIdError('SearchableDocumentId', raw);
}
return raw as SearchableDocumentId;
},
generate(clock: Clock): SearchableDocumentId {
return `srh_${ulid(clock.now())}` as SearchableDocumentId;
},
};

The other constructors (BoostRuleId, ClickEventId, …) follow the same pattern.

3. Value Objects

export type Locale = 'en' | 'ps' | 'fa' | 'tg' | 'ar' | 'ur' | 'ru';
export type ISODate = Brand<string, 'ISODate'>;
export type ISO4217 = Brand<string, 'ISO4217'>;

export interface I18nString {
values: Partial<Record<Locale, string>>;
default: Locale;
}

export interface GeoPoint {
lat: number; // -90..90
lng: number; // -180..180
}

export interface BoundingBox {
swLat: number; swLng: number;
neLat: number; neLng: number;
}

export interface Money {
amountMicro: bigint; // micro-units; no floats anywhere
currency: ISO4217;
}

export interface DateRange {
from: ISODate; // inclusive
to: ISODate; // exclusive (checkout date)
}

export interface Occupancy {
adults: number; // ≥ 1
children: number; // ≥ 0
childrenAges?: number[]; // length === children
rooms: number; // ≥ 1, ≤ 8
}

export type Region = 'AF' | 'TJ' | 'IR' | 'EU' | 'US' | 'GLOBAL';

export type SortKey =
| { kind: 'price'; direction: 'asc' | 'desc' }
| { kind: 'distance'; from: GeoPoint }
| { kind: 'popularity'; window: '7d' | '28d' }
| { kind: 'rating' };

export interface SearchFilter {
destination?: { city?: string; bbox?: BoundingBox; near?: { center: GeoPoint; radiusKm: number } };
dates?: DateRange;
occupancy?: Occupancy;
priceBand?: { minMicro?: bigint; maxMicro?: bigint; currency: ISO4217 };
amenities?: string[]; // canonical codes
starRatingMin?: 1 | 2 | 3 | 4 | 5;
languages?: Locale[];
region?: Region; // overrides default region pinning
freeCancellation?: boolean;
payAtProperty?: boolean;
}

export interface AIProvenance {
runId: string;
provider: 'vertex' | 'onnx' | 'none';
model: string;
policyVersion: string;
promptHash?: string;
occurredAt: string;
}

Money math is fixed-point bigint micro-units; FX conversion is performed by RateSnapshot.convert(targetCurrency, fxSnapshot) and never with floats.

4. Aggregates

4.1 HotelIndexEntry

The denormalized card for a single published property. One row per (tenantId, propertyId). Tenant ID is preserved (for purge cascades and tenant.region recompute) but is not used to scope reads — searches are explicitly cross-tenant.

export class HotelIndexEntry {
readonly id: SearchableDocumentId;
readonly tenantId: TenantId;
readonly propertyId: PropertyId;

// ── Cross-tenant safe fields (allow-list) ─────────────────────────────────
name: I18nString;
description: I18nString;
city: string; // canonical city slug
countryIso2: string;
region: Region;
geo: GeoPoint;
geohash5: string; // precision-5
starRating: 1 | 2 | 3 | 4 | 5 | null;
amenities: string[]; // canonical codes only
languagesSpoken: Locale[];
heroPhoto: { mediaId: string; signedUrlCacheKey: string; alt?: I18nString } | null;
ratingAvg: number | null; // 0..5, populated when reviews ship
ratingCount: number; // 0 if no reviews

// ── Composite ranking inputs (recomputed on event) ────────────────────────
popularityScore7d: number; // smoothed clicks / 7-day window
popularityScore28d: number;
freshnessBoost: number; // recently published / updated boost
qualityScore: number; // 0..1 composite (photos, completeness, etc.)
boostMultiplier: number; // sum of active BoostRule lifts (default 1.0)

// ── Status ────────────────────────────────────────────────────────────────
status: 'active' | 'suppressed'; // suppressed = unpublished or tenant.deleted
vectorClock: { propertyService: number; pricingService: number; inventoryService: number };
lastUpsertedAt: string;
schemaVersion: 1;
}

Invariants:

  1. name.values[name.default] MUST be a non-empty string ≥ 2 chars.
  2. geo MUST be valid lat/lng; geohash5 MUST be derived from geo.
  3. amenities[] MUST be a subset of the canonical AmenityRegistry.
  4. languagesSpoken[] MUST be a non-empty subset of Locale.
  5. boostMultiplier ∈ [0.1, 5.0] (clamped at apply time).
  6. status='suppressed' ⇒ aggregate cannot appear in any search result; OpenSearch document is deleted but the Postgres row is retained for cascade-purge audit.
  7. No PII fields ever. A schema test enumerates allowed property paths; any other path fails CI.

Domain events: IndexEntryUpserted, IndexEntrySuppressed, IndexEntryReinstated, IndexEntryDeleted.

4.2 RateSnapshot

Cheapest displayable rate for (propertyId, date, currency). Stored canonically in base currency (the property's tenant default currency); converted on read using the latest FxSnapshot consumed from pricing-service.

export class RateSnapshot {
readonly propertyId: PropertyId;
readonly date: ISODate;
readonly baseCurrency: ISO4217;
cheapestBaseMicro: bigint;
ratePlanId: RatePlanId; // the winning plan
refundable: boolean;
closedToArrival: boolean;
vectorClock: { pricingService: number };
occurredAt: string;
}

Invariants:

  1. cheapestBaseMicro > 0n.
  2. Last-write-wins on vectorClock.pricingService; out-of-order events with stale clock are dropped (silent, but counted in search_projection_drop_total).
  3. convert(targetCurrency, fxSnapshot) returns { amountMicro, currency, fxAgeSeconds, fxStale: bool }. fxStale=true when snapshot age > 1 h; the response carries the flag, never throws.

Domain events: RateSnapshotUpserted.

4.3 AvailabilityHint

Per-date hint for a property — counts only, never room identifiers.

export class AvailabilityHint {
readonly propertyId: PropertyId;
readonly date: ISODate;
roomsAvailable: number; // ≥ 0
roomsTotal: number; // ≥ 0
vectorClock: { inventoryService: number };
occurredAt: string;
}

Invariants: 0 ≤ roomsAvailable ≤ roomsTotal. A hint with roomsAvailable=0 does not exclude the property from the index — exclusion is decided at query time by dates filter against per-date hints.

4.4 AmenityIndex

Canonical mirror of property.amenity_set.updated.v1 — used to power facet counts efficiently. Materialized as a roaring-bitmap structure inside the OpenSearch document and a normalized table inside Postgres.

4.5 LocationIndex

Geohash precision-5 + PostGIS geography(POINT, 4326) for radius and bbox queries. Population centers (city, district) are pulled from the seeded province_centers catalog (ProvinceCenterPort).

4.6 SearchQuery (logged, sampled)

Canonicalized form of the request used for analytics + ranking iteration. PII-free by construction: free-text is hashed if it ends up looking like an email/phone (regex prefilter).

export class SearchQuery {
readonly id: SearchQueryId;
readonly canonicalQueryHash: string; // sha256 of the canonical form
readonly locale: Locale;
readonly currency: ISO4217;
readonly region: Region;
readonly text?: string; // dropped if matches PII regex
readonly filter: SearchFilter;
readonly sortKey: SortKey;
readonly resultCount: number;
readonly tookMs: number;
readonly userBucket: string; // anonymous hash for rate-limit + ranking
readonly occurredAt: string;
}

Domain rule: if text matches /(\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b|\+?\d[\d -]{6,}\d)/i, it is replaced by '[REDACTED]' before persistence.

4.7 ClickEvent

export class ClickEvent {
readonly id: ClickEventId;
readonly searchQueryId: SearchQueryId;
readonly propertyId: PropertyId;
readonly rank: number; // 1-based
readonly userBucket: string;
readonly occurredAt: string;
}

Used to feed popularityScore* recomputation; per-tenant-per-day click cap (1 000) prevents popularity gaming.

4.8 BoostRule (Phase 3+)

export class BoostRule {
readonly id: BoostRuleId;
readonly tenantId: TenantId; // owner — only their properties may be boosted
readonly propertyId: PropertyId;
readonly scope: { region?: Region; amenities?: string[]; locales?: Locale[]; dateRange?: DateRange };
readonly multiplier: number; // [0.1, 5.0]
readonly status: 'draft' | 'active' | 'expired';
readonly createdAt: string;
readonly activatedAt?: string;
readonly expiresAt?: string;
}

Invariants: multiplier ∈ [0.1, 5.0]; propertyId MUST belong to tenantId (verified via the consumer of property.published.v1). Activation requires an audit-logged operator decision.

4.9 SponsoredRanking (Phase 3+)

Auction-driven slot fill keyed by query bucket. Out of scope for Phase 1 details; reserved aggregate.

4.10 IndexBuild

Control aggregate for a full reindex from the event archive (BigQuery events_raw.melmastoon_*). State machine:

draft → planning → replaying → catching_up → swapping → completed
↘ failed

Only one active build per region. Build creates an OpenSearch index named melmastoon-search-<region>-<isoTs>, replays the archive into it, catches up the live deltas, then atomically swaps the alias melmastoon-search-<region> to point at the new index. Old index is retained for 7 days then ILM-deleted.

5. State Machines

5.1 HotelIndexEntry

property.published.v1
draft ──────────────► active
▲ ▲
│ │ tenant.region opens
│ │
│ property.updated.v1

suppressed ◄── property.unpublished.v1 / .deleted.v1 / tenant.deleted.v1

└─► (cascade purge from OpenSearch; Postgres row retained 30d for audit)

5.2 IndexBuild

See §4.10.

5.3 BoostRule

draft → active → expired
↘ cancelled

active → expired triggered by clock or operator; expired rules are excluded from boostMultiplier recomputation.

6. Domain Errors

All errors in src/domain/**/errors/* extend DomainError and map 1:1 to a code in ERROR_CODES.md.

Error classCodeTrigger
ForbiddenFieldInProjectionErrorMELMASTOON.SEARCH.FORBIDDEN_FIELD_IN_PROJECTIONAllow-list policy rejected a field at projection time
StaleProjectionEventErrorMELMASTOON.SEARCH.STALE_PROJECTION_EVENTVector clock regressed; event dropped
UnknownAmenityCodeErrorMELMASTOON.SEARCH.UNKNOWN_AMENITY_CODEAmenity code not in canonical registry
BoostRuleScopeViolationErrorMELMASTOON.SEARCH.BOOST_RULE_SCOPE_VIOLATIONTenant tried to boost a property they don't own
BoostMultiplierOutOfRangeErrorMELMASTOON.SEARCH.BOOST_MULTIPLIER_OUT_OF_RANGEOutside [0.1, 5.0]
InvalidGeoBoundingBoxErrorMELMASTOON.SEARCH.GEO_OUT_OF_BOUNDSbbox area > limit or radius > 200 km
QueryBudgetExceededErrorMELMASTOON.SEARCH.QUERY_INVALIDFilter combination triggers an unsafe OpenSearch query
IndexBuildAlreadyRunningErrorMELMASTOON.SEARCH.INDEX_REBUILD_IN_PROGRESSSecond concurrent build attempt

7. Cross-cutting domain rules

  1. No PII, ever. Enforced by IndexFieldAllowList constants and a unit test that walks the HotelIndexEntry type and rejects any property path not in the allow-list.
  2. Cross-tenant by design. Postgres connections in this service set SET LOCAL app.tenant_id = '__cross_tenant__'; RLS policies on search.* tables explicitly recognize this sentinel and grant unconditional access. Application of the sentinel is restricted to this service's runtime SA via Cloud SQL IAM auth.
  3. Last-write-wins on occurredAt with vector-clock tie-break. Out-of-order events do not raise; they are dropped and counted.
  4. All money is bigint micro-units; FX conversion is the only place a multiplication happens, and it uses bigint * bigint / 1_000_000n arithmetic with explicit rounding.
  5. Region pinning is a domain policy, not a query parameter. Default visibility is tenant.region ∈ {AF, TJ, IR}; clients may override with explicit filter.region, but this widens the visible set only as far as tenant.region allows.