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: truedecision recorded in DATA_MODEL §3.
1. Ubiquitous Language
| Term | Definition | Sample sentence |
|---|---|---|
| HotelIndexEntry | Denormalized, cross-tenant-safe card for one published property. | "HotelIndexEntry for ppt_… carries name in 4 locales and a hero photo URL." |
| RateSnapshot | Cheapest displayable rate for (propertyId, date, currency). | "RateSnapshot for ppt_… on 2026-05-01 in AFN: 1 250 000 micro." |
| AvailabilityHint | Per-date count of available rooms. | "AvailabilityHint for ppt_… on 2026-05-01: 12 of 30 rooms remain." |
| AmenityIndex | Canonical amenity → property mapping for facet queries. | "Amenity halal_kitchen matches 137 properties." |
| LocationIndex | Geohash + PostGIS geometry for bbox/radius queries. | "City Kabul has 41 indexed properties within 10 km." |
| SearchQuery | Canonicalized, hashed user query (sampled, logged). | "SearchQuery q_… in en, dest=Kabul, dates=2026-05-01..03." |
| ClickEvent | Recorded deep-link click into a tenant booking surface. | "ClickEvent clk_… for ppt_… at rank 3 from query q_…." |
| BoostRule | Operator-configurable lift on a (propertyId, criteria) slice. | "BoostRule brt_… boosts ppt_… for region=AF, amenity=conference_hall by 1.5×." |
| SponsoredRanking | Auction-driven slot fill (Phase 3+). | "SponsoredRanking sets slot 1 to ppt_… for query bucket kabul-business." |
| IndexBuild | Control aggregate for a full reindex from event archive. | "IndexBuild ibd_… replayed 12 480 propery.published events; alias swapped at 10:42." |
| Region pinning | First-launch filter tenant.region ∈ {AF, TJ, IR}. | "Region pinning hides 4 EU tenants until tenant.region_changed.v1 opens EU." |
| Cross-tenant query | Read 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:
name.values[name.default]MUST be a non-empty string ≥ 2 chars.geoMUST be valid lat/lng;geohash5MUST be derived fromgeo.amenities[]MUST be a subset of the canonicalAmenityRegistry.languagesSpoken[]MUST be a non-empty subset ofLocale.boostMultiplier ∈ [0.1, 5.0](clamped at apply time).status='suppressed'⇒ aggregate cannot appear in any search result; OpenSearch document is deleted but the Postgres row is retained for cascade-purge audit.- 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:
cheapestBaseMicro > 0n.- Last-write-wins on
vectorClock.pricingService; out-of-order events with stale clock are dropped (silent, but counted insearch_projection_drop_total). convert(targetCurrency, fxSnapshot)returns{ amountMicro, currency, fxAgeSeconds, fxStale: bool }.fxStale=truewhen 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 class | Code | Trigger |
|---|---|---|
ForbiddenFieldInProjectionError | MELMASTOON.SEARCH.FORBIDDEN_FIELD_IN_PROJECTION | Allow-list policy rejected a field at projection time |
StaleProjectionEventError | MELMASTOON.SEARCH.STALE_PROJECTION_EVENT | Vector clock regressed; event dropped |
UnknownAmenityCodeError | MELMASTOON.SEARCH.UNKNOWN_AMENITY_CODE | Amenity code not in canonical registry |
BoostRuleScopeViolationError | MELMASTOON.SEARCH.BOOST_RULE_SCOPE_VIOLATION | Tenant tried to boost a property they don't own |
BoostMultiplierOutOfRangeError | MELMASTOON.SEARCH.BOOST_MULTIPLIER_OUT_OF_RANGE | Outside [0.1, 5.0] |
InvalidGeoBoundingBoxError | MELMASTOON.SEARCH.GEO_OUT_OF_BOUNDS | bbox area > limit or radius > 200 km |
QueryBudgetExceededError | MELMASTOON.SEARCH.QUERY_INVALID | Filter combination triggers an unsafe OpenSearch query |
IndexBuildAlreadyRunningError | MELMASTOON.SEARCH.INDEX_REBUILD_IN_PROGRESS | Second concurrent build attempt |
7. Cross-cutting domain rules
- No PII, ever. Enforced by
IndexFieldAllowListconstants and a unit test that walks theHotelIndexEntrytype and rejects any property path not in the allow-list. - Cross-tenant by design. Postgres connections in this service set
SET LOCAL app.tenant_id = '__cross_tenant__'; RLS policies onsearch.*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. - Last-write-wins on
occurredAtwith vector-clock tie-break. Out-of-order events do not raise; they are dropped and counted. - All money is bigint micro-units; FX conversion is the only place a multiplication happens, and it uses
bigint * bigint / 1_000_000narithmetic with explicit rounding. - Region pinning is a domain policy, not a query parameter. Default visibility is
tenant.region ∈ {AF, TJ, IR}; clients may override with explicitfilter.region, but this widens the visible set only as far astenant.regionallows.