search-aggregation-service — Event Schemas
Companion: DOMAIN_MODEL · APPLICATION_LOGIC · 04 Event-Driven Architecture · Naming
All events follow melmastoon.<service>.<aggregate>.<verb_past>.v<n> and ship over GCP Pub/Sub with the transactional outbox pattern. For events produced by this service, ordering key is propertyId (cross-tenant by design — tenantId is included in the envelope for cascade-purge audit, never used for routing). Schemas live in services/search-aggregation-service/contracts/events/*.json (JSON Schema draft 2020-12) and are CI-validated against the example payloads in this document.
1. Envelope (shared)
{
"eventId": "evt_01H...",
"eventType": "melmastoon.search.projection.updated.v1",
"schemaVersion": 1,
"occurredAt": "2026-04-22T10:00:00.123Z",
"tenantId": "tnt_01H...",
"aggregate": { "type": "hotel_index_entry", "id": "srh_01H..." },
"version": 7,
"producer": { "service": "search-aggregation-service", "version": "1.0.0" },
"trace": {
"traceId": "00f067aa0ba902b7",
"spanId": "00f067aa0ba902b8",
"causationId": "evt_01H...",
"correlationId":"req_01H..."
},
"data": { /* payload, schema-version-specific */ }
}
Pub/Sub attributes:
| Attribute | Value |
|---|---|
eventType | same as JSON eventType |
tenantId | same as JSON tenantId (audit only) |
propertyId | aggregate's propertyId when applicable (used as ordering key) |
schemaVersion | string '1' |
traceparent | W3C-formatted trace context |
idempotencyKey | same as eventId |
retentionClass | operational | regulated | audit |
2. Versioning rules
- Additive changes (new optional field) → bump
schemaVersionminor in JSON Schema, same topic version (.v1). - Breaking changes → publish a new version (
.v2) on a new topic for ≥ 90 days alongside.v1. Mark.v1deprecated incontracts/events/DEPRECATIONS.md. - All consumers must tolerate unknown additional fields.
3. Topics & subscriptions
| Topic | Producer | Subscribers (initial) | Retention class |
|---|---|---|---|
melmastoon.search.projection.v1 | search-aggregation | analytics, bff-consumer (cache-warm), bff-tenant-booking (deep-link cache invalidation) | operational |
melmastoon.search.click.v1 | search-aggregation | analytics, ai-orchestrator (Phase 2+ ranking signal), search-aggregation (popularity recompute self-loop) | operational |
melmastoon.search.query.v1 | search-aggregation | analytics | regulated (sampled; PII-redacted by domain) |
melmastoon.search.boost_rule.v1 | search-aggregation | bff-consumer (cache invalidation), analytics | audit |
melmastoon.search.index.v1 | search-aggregation | platform SRE (alerting), analytics | audit |
DLQ per subscription with 5 delivery attempts, exponential backoff (1s..60s).
4. Events published
4.1 melmastoon.search.projection.updated.v1
Emitted on every successful upsert/suppress/delete of a HotelIndexEntry, RateSnapshot, AvailabilityHint. Payload identifies what changed but does not duplicate the full document (consumers re-read via the API or the /internal/projection/changes stream if they need full state).
{
"data": {
"kind": "hotel_index_entry",
"propertyId": "ppt_01H...",
"tenantId": "tnt_01H...",
"operation": "upserted",
"changedSlices": ["amenities", "hero_photo", "popularity"],
"vectorClock": { "propertyService": 12, "pricingService": 47, "inventoryService": 91 },
"occurredAt": "2026-04-22T10:00:00.123Z"
}
}
kind ∈ hotel_index_entry | rate_snapshot | availability_hint. operation ∈ upserted | suppressed | reinstated | deleted.
4.2 melmastoon.search.projection.failed.v1
Emitted when a projection write failed permanently (after retries; row went to DLQ).
{
"data": {
"kind": "hotel_index_entry",
"propertyId": "ppt_01H...",
"tenantId": "tnt_01H...",
"sourceEventId": "evt_01H...",
"sourceTopic": "melmastoon.property.published.v1",
"errorCode": "MELMASTOON.SEARCH.FORBIDDEN_FIELD_IN_PROJECTION",
"errorDetail": "field 'guest_email' is not cross_tenant_searchable",
"attempts": 5,
"movedToDlqAt": "2026-04-22T10:00:00.123Z"
}
}
4.3 melmastoon.search.click.recorded.v1
{
"data": {
"clickEventId": "clk_01H...",
"queryId": "q_01H...",
"propertyId": "ppt_01H...",
"tenantId": "tnt_01H...",
"rank": 3,
"userBucket": "ub_anon_3f9a...",
"occurredAt": "2026-04-22T10:00:00.123Z"
}
}
4.4 melmastoon.search.query.executed.v1 (sampled)
Sampled at 1 % for anonymous traffic and 100 % for authenticated traffic. PII-redacted by the domain SearchQuery aggregate.
{
"data": {
"queryId": "q_01H...",
"canonicalQueryHash": "sha256:abc123…",
"locale": "ps",
"currency": "AFN",
"region": "AF",
"text": "guesthouse near garden",
"filter": {
"destination": { "city": "kabul", "near": { "center": { "lat": 34.5328, "lng": 69.1727 }, "radiusKm": 10 } },
"dates": { "from": "2026-05-01", "to": "2026-05-04" },
"occupancy": { "adults": 2, "children": 1, "rooms": 1 },
"amenities": ["wifi", "halal_kitchen"],
"starRatingMin": 3
},
"sortKey": { "kind": "price", "direction": "asc" },
"resultCount": 47,
"tookMs": 87,
"userBucket": "ub_anon_3f9a...",
"degradationLevel": "none",
"occurredAt": "2026-04-22T10:00:00.123Z"
}
}
If text matched the PII regex (email/phone), it is replaced by '[REDACTED]' before persistence and emission.
4.5 melmastoon.search.boost_rule.created.v1
{
"data": {
"boostRuleId": "brt_01H...",
"tenantId": "tnt_01H...",
"propertyId": "ppt_01H...",
"scope": {
"region": "AF",
"amenities": ["conference_hall"],
"locales": ["en"],
"dateRange": { "from": "2026-06-01", "to": "2026-09-30" }
},
"multiplier": 1.5,
"createdBy": "usr_01H...",
"createdAt": "2026-04-22T10:00:00.123Z",
"expiresAt": "2026-10-01T00:00:00Z"
}
}
4.6 melmastoon.search.boost_rule.activated.v1
{
"data": {
"boostRuleId": "brt_01H...",
"tenantId": "tnt_01H...",
"propertyId": "ppt_01H...",
"activatedBy": "usr_01H...",
"activatedAt": "2026-04-22T10:00:00.123Z",
"appliedMultiplier": 1.5
}
}
4.7 melmastoon.search.index.rebuilt.v1
{
"data": {
"buildId": "ibd_01H...",
"regions": ["AF", "TJ", "IR"],
"templateVersion": "v3",
"newIndexNames": {
"AF": "melmastoon-search-af-2026-04-22T08-00",
"TJ": "melmastoon-search-tj-2026-04-22T08-00",
"IR": "melmastoon-search-ir-2026-04-22T08-00"
},
"previousIndexNames": {
"AF": "melmastoon-search-af-2026-04-15T08-00"
},
"docsIndexed": 38712,
"durationSeconds": 612,
"swappedAt": "2026-04-22T08:14:11Z"
}
}
4.8 melmastoon.search.index.health_alert.v1
{
"data": {
"severity": "P1",
"kind": "circuit_open" ,
"details": "OpenSearch returned 5xx on 7/10 last requests; Postgres fallback engaged.",
"since": "2026-04-22T10:01:33Z",
"indexAlias": "melmastoon-search-current"
}
}
kind ∈ circuit_open | circuit_close | freshness_breach | rebuild_failed | mapping_drift | query_budget_breach.
5. Events consumed
The service is a conformist consumer — it does not negotiate contracts; it accepts the upstream service's published schema. The list below documents every consumed event, its inbox handling, and the consumer's effect.
5.1 From property-service
| Event | Effect | Vector-clock field |
|---|---|---|
melmastoon.property.created.v1 | ignored — only published triggers indexing | n/a |
melmastoon.property.updated.v1 | UpsertHotelIndexEntry (delta) | event.version |
melmastoon.property.published.v1 | UpsertHotelIndexEntry (full upsert) | event.version |
melmastoon.property.unpublished.v1 | SuppressHotelIndexEntry | event.version |
melmastoon.property.deleted.v1 | DeleteHotelIndexEntry | event.version |
melmastoon.property.room_type.updated.v1 | UpsertHotelIndexEntry (capacity slice) | event.version |
melmastoon.property.amenity_set.updated.v1 | UpsertHotelIndexEntry (amenities slice) | event.version |
melmastoon.property.photo.added.v1 | UpsertHotelIndexEntry (hero refresh; only fires when is_hero=true) | event.version |
melmastoon.property.photo.removed.v1 | UpsertHotelIndexEntry (hero refresh) | event.version |
5.2 From pricing-service
| Event | Effect | Vector-clock field |
|---|---|---|
melmastoon.pricing.rate_plan.published.v1 | UpsertRateSnapshot per affected (property, date) | event.version |
melmastoon.pricing.rate_plan.updated.v1 | UpsertRateSnapshot per affected (property, date) | event.version |
melmastoon.pricing.fx_snapshot.updated.v1 | UpsertFxSnapshot (cache + invalidate srh:q:*) | event.snapshotVersion |
5.3 From inventory-service
| Event | Effect | Vector-clock field |
|---|---|---|
melmastoon.inventory.allocation.confirmed.v1 | UpsertAvailabilityHint (decrement) | event.version |
melmastoon.inventory.allocation.released.v1 | UpsertAvailabilityHint (increment) | event.version |
melmastoon.inventory.block.created.v1 | UpsertAvailabilityHint (decrement) | event.version |
melmastoon.inventory.block.released.v1 | UpsertAvailabilityHint (increment) | event.version |
5.4 From tenant-service
| Event | Effect |
|---|---|
melmastoon.tenant.deleted.v1 | PurgeTenantFromIndex cascade |
melmastoon.tenant.region_changed.v1 | per-property re-evaluation of RegionPinningPolicy |
6. Sample inbound: melmastoon.property.published.v1
{
"eventId": "evt_01H...",
"eventType": "melmastoon.property.published.v1",
"schemaVersion": 1,
"occurredAt": "2026-04-22T08:14:11Z",
"tenantId": "tnt_01H...",
"aggregate": { "type": "property", "id": "ppt_01H..." },
"version": 12,
"producer": { "service": "property-service", "version": "1.7.3" },
"trace": { /* … */ },
"data": {
"id": "ppt_01H...",
"slug": "ghasi-house-kabul",
"name": { "default": "ps", "values": { "ps": "...", "en": "Ghasi House Kabul" } },
"description": { "default": "ps", "values": { "ps": "...", "en": "..." } },
"address": { "city": "Kabul", "countryIso2": "AF" },
"geo": { "lat": 34.5310, "lng": 69.1750, "source": "manual" },
"starRating": 3,
"amenities": ["wifi", "halal_kitchen", "prayer_room"],
"enabledLocales": ["en", "ps", "fa"],
"heroPhotoMediaId": "med_01H...",
"publishedAt": "2026-04-22T08:14:11Z"
}
}
The consumer reads only the allow-listed fields — anything else (e.g., owner email, phone) is rejected by the projection allow-list policy at the projector, never reaching the index.
7. Schema registry
- AsyncAPI document at
services/search-aggregation-service/contracts/asyncapi.yamllists topics + schemas + retention classes. - CI runs
asyncapi:checkand the platform schema-registry compatibility check on every PR. - Breaking changes require a new
.v(n+1)topic; old topic stays for ≥ 90 days.