Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Tiered Seat Discount MVP — Technical Design and Implementation Plan

Status: Implementation reference; consumer rollout hardening remains Last reviewed: 27 August 2026 Repositories: pricing-engine, hh-server, hh-pegasus-monorepo Scope: Tiered demand-capacity evaluation and its existing public API contract

1. Executive decision

For Phase 1, a Demand & Capacity PricingRule is a parent campaign containing multiple mutually exclusive seat tiers. Each tier is represented by one DemandCapacityCondition and one price adjustment.

The implementation has the following properties:

  1. Creates all approved tier PriceModifier definitions when the parent rule is created or updated. Kafka capacity changes will no longer create and remove modifier definitions.
  2. Accepts adult (mapped internally to requested_seats) for date/time pricing. The HTTP field is optional for backward compatibility, but a party size is required to select a demand-capacity tier.
  3. Calculates projected_booked_seats = current_booked_seats + requested_seats for quote and pre-hold validation.
  4. Selects at most one tier from each parent PricingRule.
  5. Returns no capacity modifier when no tier matches, which means normal price.
  6. Reuses exactly the same selector for public quotes, temporary-reservation validation, and final validation.
  7. Keeps Kafka as the capacity-snapshot synchronization mechanism, not as the modifier-lifecycle mechanism.

For the tiered-seat example:

Current bookedAdultProjected bookedSelected tierResult
91101–1020% decrease
921111–2010% decrease
91221no matchnormal price

The adjustment applies to the whole booking. It is not split seat-by-seat.

2. Scope and explicit Phase 1 constraints

Implemented behavior

  • Static modifier definitions: one modifier per tier and eligibility window, never one modifier per calendar day.
  • Exactly one threshold item per tier, homogeneous metric/schedule across tiers, and rejection of overlapping threshold ranges.
  • Selected-slot projection for all four existing capacity metrics.
  • All six DATE_TYPES, including exact single-time matching, weekly matching, half-open range end times, no-end date, and no-end time.
  • Quote mode (booked + adult) and held-seat verification mode (do not add the same adult again).
  • Snapshot-only Kafka/backfill lifecycle; capacity changes no longer create or deactivate modifier definitions.
  • Consumer request and capacity-event contracts for hh-server and Pegasus.
  • Booking/cancellation capacity consistency requirements for active acknowledged, temporary, and locking reservations.
  • Docker-based RSpec test service for pricing-engine.

Remaining rollout hardening

  • Snapshot freshness/read-through handling when a hold event has not reached pricing-engine yet.
  • Downstream consumer rollout and end-to-end validation across hh-server and Pegasus.

In scope

  • Tiers based on one of the four existing metrics: total_seats_booked, remaining_seats, pct_seats_booked, or pct_seats_remaining.
  • Multiple tiers under one parent rule.
  • One or more date/time eligibility windows.
  • Bounded and no-end-date rules.
  • Public date/time quote flow from Pegasus to pricing-engine.
  • Temporary and final reservation validation through hh-server.
  • Booking, cancellation, update, temporary hold, and lock capacity events.
  • Backward-compatible deployment across the three repositories.

Out of scope

  • Splitting one booking across multiple tiers.
  • Combining multiple capacity tiers cumulatively.
  • Unique-customer or unique-reservation aggregation across a whole day.
  • Multi-metric expressions such as booked seats AND remaining percentage.
  • A cryptographically signed quote token.
  • Changing the existing cross-parent priority policy for Date & Time rules.

Phase 1 authoring restrictions

All tiers in one parent PricingRule must have:

  • the same selected capacity metric;
  • equivalent date/time eligibility windows;
  • the same package scope inherited from the parent rule; and
  • exactly one CapacityConditionItem each; and
  • non-overlapping threshold ranges.

Different schedules or different capacity metrics must be authored as separate parent pricing rules. This restriction avoids inventing an implicit grouping algorithm from condition fields.

3. Pre-change implementation audit

The pre-change audit was performed against:

  • pricing-engine 73f2141e73613d7937277da716157a99dc83359a;
  • hh-server 00dcc14f22;
  • hh-pegasus-monorepo was refreshed to 83a4dc16 before implementation.

3.1 Current pricing-engine model

The persisted hierarchy is:

PricingRule (rule_type = demand_capacity)
└── DemandCapacityCondition [many]
    ├── CapacityConditionItem [many; OR semantics]
    └── DemandCapacityTimeWindow [many]

Today, CapacityRuleMatcher independently selects every matching DemandCapacityCondition. Within one condition, any matching CapacityConditionItem is sufficient. There is no tier group, exclusive winner, overlap validation, or range precedence.

CapacityEvaluator then builds one modifier for every matching condition and window. Consequently, if two conditions overlap, two modifiers can be active. The public modifier resolver later chooses based on generic priority, weight, creation order, and value. It does not choose based on the matched seat range.

3.2 Current capacity scope is incompatible with projected adult

CapacityEvaluator currently aggregates all snapshots from 00:00:00 to 23:59:59. A focused spec explicitly locks this full-day behavior.

This cannot be combined safely with + adult:

  • one reservation can occupy several 15-minute inventory rows;
  • summing slot-level seats_booked can count the same adults repeatedly; and
  • adding the requested adult only once mixes different units.

Phase 1 therefore uses the selected inventory slot as the capacity scope. For a request at 19:00, the selector reads the 19:00 CapacitySnapshot, then adds the requested adult once. If the product later needs unique booked covers across a window/day, hh-server must publish a distinct-reservation metric; it must not be derived by summing the current slot snapshots.

3.3 Current modifier lifecycle creates per-date state

Creating an active capacity rule enqueues CapacityRuleInitialEvaluationJob. The job pulls capacity from hh-server, evaluates each date, and caps no-end-date evaluation at the snapshot horizon, currently 30 days.

CapacityModifierBuilder writes start_date = end_date = evaluated date and does not carry is_no_end_date into the capacity modifier. A no-end-date rule therefore produces many daily modifiers rather than one reusable schedule definition.

3.4 Public APIs do not have party-size context

The following endpoints do not currently accept adult:

  • GET /api/v1/packages_pricing_rules/:restaurant_id;
  • POST /api/v1/get_available_rules_by_date;
  • POST /api/v1/get_available_rules_by_time; and
  • POST /api/v1/verify_active_rules.

The response only exposes modifier id, action_type, value_type, and value. It omits rule_type, parent pricing_rule_id, and capacity tier identity.

This omission is significant: ReservationService::Create in hh-server only runs its final capacity-specific guard when the locked rule contains rule_type == "demand_capacity". A rule originating from the current public response does not carry that field.

3.5 Current verification is not a safe shared selector

The current capacity revalidation path:

  • has no adult/projected-seat input;
  • searches pricing_rule.date_time_conditions instead of the demand-capacity condition windows;
  • returns valid when a capacity window cannot be found;
  • returns valid when aggregation or matching fails; and
  • rescues every exception as valid.

It is therefore fail-open and can disagree with the quote path.

3.6 hh-server capacity is mostly full-state, with two consistency gaps

The normal reservation callback calculates booked seats as:

adult when reservation is active AND
  (acknowledged OR temporary OR used by locking system)
otherwise 0

It recalculates inventory.total_booked_seat, and InventoryTrait emits a full-state event when that total changes. Setting a reservation inactive on cancellation reduces the total and emits a cancellation event.

However:

  • InventoryReservationWorker only counts active && ack, so a bulk rebuild can remove active temporary/locking holds from the total; and
  • its dine-in bulk-import path manually emits capacity events, while the delivery bulk-import path currently does not.

The Phase 1 implementation must make all writers use the same booked-seat predicate and must emit full-state events after every bulk total change.

3.7 Pegasus has adult state but does not send it to pricing-engine

Pegasus already has bookingStore.adult and uses it to calculate base package prices. Its pricing-engine adapters omit adult from all three requests.

Additional client risks are:

  • package-level pricing is fetched during SSR or initial package load before date, time, and adult are available;
  • pricingRulesAttempted prevents a package-level refetch after adult changes;
  • the date API has a shared abort controller but no request-context key;
  • the time API has no cancellation or stale-response guard; and
  • selected pricing rules can survive an adult change until another fetch completes.

4. Target domain semantics for many tiers

4.1 Vocabulary

TermMeaning
Parent ruleOne PricingRule, including schedule, packages, priority, active state, and approval policy.
TierOne DemandCapacityCondition with one adjustment and one or more normalized seat intervals.
Tier intervalAn inclusive integer range of projected booked seats.
Modifier definitionA persistent, approved price adjustment linked to one tier and one schedule window. It is not evidence that the threshold currently matches.
Selected modifierThe one modifier definition chosen at request time after projected-seat evaluation.

4.2 Normalize relations into inclusive intervals

The selector and authoring validator will normalize current relations as:

RelationNormalized interval
gt N[N + 1, infinity]
gte N[N, infinity]
lt N[0, N - 1]
lte N[0, N]
eq N[N, N]
between N M[N, M]

is_no_end_value is not currently honored by CapacityConditionItem#matches?. Phase 1 should represent open-ended tiers with gte and reject ambiguous between plus no-end combinations.

Phase 1 permits exactly one item inside each tier. Multiple items previously had OR semantics, but that shape is now rejected because it makes tier identity, overlap validation, and modifier cardinality ambiguous.

4.3 Validation rules

On create and update, reject the parent rule when:

  • any two normalized intervals overlap, including intervals in the same tier;
  • tiers use different metrics;
  • tier schedules are not equivalent;
  • two eligibility windows overlap within the same parent rule;
  • a range is negative, inverted, or empty;
  • is_anytime is combined with another tier; or
  • no condition/tier exists.

Gaps are valid. A projected value in a gap means normal price.

Example: tiers 1–10 and 11–20 are valid. Tiers 1–10 and 10–20 are rejected because seat 10 has two possible prices.

4.4 Selection algorithm

For one package, date, time, and adult count:

1. Load active and approved parent rules valid for package/date/time.
2. Load the selected slot capacity snapshot.
3. Calculate projected metrics from snapshot + requested adult.
4. For each parent demand-capacity rule:
   a. filter its tiers by the matching schedule window;
   b. match normalized tier intervals against projected seats;
   c. return zero or one tier; more than one is a configuration error.
5. Replace all capacity candidates from that parent with its selected tier.
6. Run the existing cross-parent resolver over:
   - the selected capacity candidate from each parent; and
   - eligible modifiers from other rule types.
7. Return the same final rule set shape used by DPv2.

Priority resolves conflicts between parent rules. Priority must never be used to choose two tiers inside the same parent rule.

If an invalid legacy configuration produces more than one matching tier, pricing-engine must return no capacity discount for that parent, log a tier_overlap_runtime error, and alert. It must not choose an arbitrary tier.

4.5 Modifier count and no-end-date behavior

The number of modifier definitions is:

number of tiers × number of normalized eligibility windows

For two tiers and one no-end-date window, exactly two modifier definitions are stored. Both can have status = active, but only one can be selected for a request. The records inherit:

  • parent/window start_date;
  • end_date = nil and is_no_end_date = true for ongoing rules;
  • the configured start/end time flags; and
  • parent package scope and priority.

No modifier is generated once per calendar date. A rule edit synchronizes and supersedes definitions in one transaction. Kafka events do not alter modifier status.

4.6 Schedule and capacity-observation matrix

An eligibility window answers when a tier may be used. A capacity observation answers which booked-seat value is projected. They are related, but they are not the same range:

  • schedule matching uses the parent rule plus the condition/window fields;
  • capacity lookup uses the one 15-minute inventory slot selected by the user;
  • requested_seats is added once to that selected-slot snapshot; and
  • snapshots from other times in the same eligibility window are never summed into total_seats_booked.

Phase 1 must support the following schedule matrix through one normalized schedule matcher:

Date typeEligibility semanticsCapacity observation
everydayEvery calendar date allowed by the parent date range; use parent time bounds when present.Selected 15-minute slot.
specific_dateOnly the configured date or date range; use parent time bounds when present.Selected slot on the matching date.
specific_time rangeEvery parent-valid date, only when selected time is inside the configured half-open time range.Selected slot, not the sum of the range.
specific_time singleEvery parent-valid date at exactly one configured inventory start time.The one selected 15-minute slot.
specific_date_timeConfigured date/date range and configured time range must both match.Selected slot on the matching date.
day_of_weekSelected weekdays inside the parent date range; use parent time bounds when present.Selected 15-minute slot.
day_time_of_week rangeSelected weekdays and configured half-open time range must both match.Selected slot, not the sum of the range.
day_time_of_week singleSelected weekdays at exactly one configured inventory start time.The one selected 15-minute slot.

Single time is represented by period_mode = single with equal start and end times. The selector treats that representation as one exact inventory start time. Equal times remain invalid in range mode, so a single point cannot be mistaken for an empty range.

For multiple windows under one tier, the selected date/time must match exactly one normalized window. Overlapping windows in the same parent rule are rejected. Different windows can observe different booked-seat values and therefore can select different tiers in the same request’s time-slot matrix.

is_no_end_date means the reusable modifier definition remains ongoing; it does not create one modifier per future day. is_no_end_time means from the configured start time through the end of the service day. Both still observe only the selected inventory slot.

The current schema has everyday, but it has no recurrence interval such as “every 2 days” or “every N days”. Phase 1 does not infer that behavior from everyday. If product requires interval-day recurrence, add an explicit interval_days plus anchor-date contract and extend the matcher/test matrix in a separate scope.

4.7 Pre-change verdict and current resolution for all date types

Before Phase 1, the six values in DemandCapacityCondition::DATE_TYPES were accepted by the model, but enum acceptance did not mean complete schedule support. A characterization matrix run on 2026-08-19 produced 16 examples, 16 failures against the target semantics. In particular, a dinner snapshot above the threshold activated a lunch modifier because CapacityEvaluator first summed the full day and only then shaped modifiers.

Date typePre-change behaviorPhase 1 resolution
everydayDaily modifier and full-day threshold.One reusable definition; exact selected-slot projection.
specific_dateWindow-shaped daily modifier and full-day threshold.Required date window; exact matching date and selected slot.
specific_timeWindow-shaped daily modifier; ambiguous single time.Required time window; half-open range or exact single time.
specific_date_timeDate/time-shaped daily modifier and full-day threshold.Required date/time dimensions (or explicit anytime); exact selected slot.
day_of_weekWeekday filter with full-day threshold.Required weekdays; one reusable weekly definition.
day_time_of_weekWeekday/time-shaped daily modifiers.Required weekdays/windows; one reusable definition per child item.

The shared pre-change gaps below are retained as audit history. The Phase 1 implementation resolves them through normalized validation, DemandCapacityModifierSync, and DemandCapacitySelector:

  • DemandCapacityCondition does not require windows for slot date types and does not require days_of_week for day-based types.
  • period_mode is stored but does not drive time-window normalization.
  • Equal start/end times are rejected by DemandCapacityTimeWindow, while a start without an end behaves as open-ended even when is_no_end_time is false. There is therefore no unambiguous single-time representation.
  • Demand-capacity windows have no same-parent overlap validation.
  • Editing a bounded window by changing only is_no_end_date or is_no_end_time fails validation because the previous end value is not cleared automatically. Callers currently have to send the flag and explicit null together.
  • At parent-rule level, PricingRule#valid_on_date? ignores is_no_end_date when a stale end_date remains, although SQL scopes treat the flag as authoritative. Those two paths can disagree after a partial edit.
  • CapacityModifierBuilder creates per-date modifiers and does not copy is_no_end_date, is_no_end_time, is_anytime, or days_of_week into the modifier definition.
  • Initial evaluation is bounded by the snapshot horizon, currently 30 days by default, so no-end rules are not represented as reusable ongoing definitions.
  • The pricing dashboard has filters for demand-capacity records but no demand-capacity rule authoring payload or controls.

The Date & Time audit also found independent gaps. Phase 1 aligned runtime boundaries to half-open ranges, preserved exact single-time matching, normalized bounded-to-no-end edits, kept child modifier identities distinct, and changed bounded weekly rules from per-date rows to reusable definitions. The historical findings were:

  • DateTimeCondition requires weekdays for day-based types and has overlap validators, but its slot-required validation deliberately skips a new empty condition. A slot type can therefore be created with no item and no modifier.
  • specific_date_time does not actually require time fields when is_anytime is false.
  • Equal-time single points were accepted, but overlap checking used half-open comparisons while runtime matching used inclusive end times. Adjacent ranges could both match their shared boundary.
  • The dashboard clears parent no-end values correctly, but slot no-end-time UI sends end_time = 23:59 together with is_no_end_time = true; the backend rejects that combination.
  • Modifier supersession has callback paths that rescue and log errors, and four lifecycle examples are still pending because modifiers/callbacks were not reliably created.
  • The current request spec does not prove all six types end-to-end: its specific_date example creates a rule without a condition, and it has no create-and-resolve coverage for specific_date_time or day_time_of_week.

Demand Capacity therefore uses its own normalized matcher shared by quote and verification. Readiness is covered by public resolution tests for all six date types rather than inferred from the presence of enum strings.

5. Deep module and interface placement

The new seam belongs in pricing-engine because pricing-engine owns rule semantics, snapshots, modifier definitions, and priority resolution.

Proposed module:

Pricing::DemandCapacitySelector.call(
  restaurant_id:,
  date:,
  time:,
  package_ids:,
  requested_seats:,
  projection_mode:,
)

projection_mode is:

  • add_requested_seats for public quote and temporary validation before the reservation hold exists; or
  • requested_seats_already_held for final validation of a temporary reservation whose seats are already represented in capacity.

The result is data, not a side effect:

{
  selections: {
    "4300" => {
      modifiers: [price_modifier_or_nil],
      modifier: price_modifier_or_nil,
      projected_seats: 11,
      capacity_snapshot_id: 501,
    },
  },
  matching_condition_ids: [104],
  snapshot_timestamp: snapshot.last_event_timestamp,
  diagnostic: { status: "matched", reason: "tier_matched" },
}

Internal seams can split schedule matching, projection, tier matching, and cross-parent resolution, but callers and request specs should test through this single interface. Pegasus and hh-server must not reimplement interval matching.

6. API contract

6.1 Naming and seat definition

The wire field remains adult because that is the established booking-domain field in Pegasus and hh-server. The pricing-engine controller maps it to the internal requested_seats name.

Phase 1 excludes kids because hh-server currently writes reservation.adult to InventoryReservation#booked_seat. This must be stated in product copy and tests. Supporting child seats later requires one shared capacity_seats definition across inventory, events, quote, and reservation validation.

6.2 Time quote request

{
  "restaurant_id": "123",
  "restaurant_package_ids": ["4300"],
  "date": "2026-08-01",
  "adult": 2
}

adult is optional at the HTTP boundary so existing callers remain valid. When it is omitted, the endpoint returns eligible non-capacity rules but does not select a demand-capacity tier. When supplied, it must be a non-negative integer; customer booking flows should send the actual positive adult count.

Pricing Engine also accepts the internal alias requested_seats. If both fields are supplied, requested_seats takes precedence. The engine cannot derive the requested party size from the booked-seat snapshot alone.

projection_mode is optional and defaults to add_requested_seats. Send requested_seats_already_held explicitly only when the requested seats are already included in the capacity snapshot, such as final validation after a temporary hold.

6.3 Date quote request

{
  "restaurant_id": "123",
  "restaurant_package_ids": ["4300"],
  "start_date": "2026-08-01",
  "end_date": "2026-08-10",
  "adult": 2
}

The date endpoint evaluates each date’s available snapshot slots using the same selector, then retains its existing date-summary purpose. It is not a price lock; the selected time must be fetched again.

6.4 Package-level request

GET /packages_pricing_rules/:restaurant_id does not have enough context to select a capacity tier because it has no date or time. Phase 1 must exclude demand_capacity definitions from this endpoint.

Pegasus may continue using it for context-free Date & Time campaigns. When Pegasus already has date, time, and adult, it must call the time endpoint for a capacity-aware price.

6.5 Quote response

Keep the existing compact public response. The implementation does not add parent-rule, tier, capacity, or diagnostic fields to this response:

{
  "id": 501,
  "action_type": "decrease",
  "value_type": "percentage",
  "value": 10.0
}

id is the selected modifier ID. Consumers must preserve these four fields without changing their names or values.

6.6 Adjustment unit contract

The adjustment value is passed through unchanged:

  • percentage storage 10 -> public value: 10.0 (10%);
  • fixed storage 500 -> public value: 500.0 in the configured currency unit; and
  • hh-server/Pegasus applying the public value exactly once.

No basis-point conversion is performed by the demand-capacity API.

6.7 Missing-adult compatibility policy

Current compatibility behavior:

  1. adult/requested_seats is optional at the controller level.
  2. If it is missing, pricing-engine excludes demand-capacity definitions but still returns eligible non-capacity modifiers.
  3. No diagnostic field is added to the compact public response.
  4. If a supplied seat value is malformed or negative, the controller returns HTTP 400.

Silently evaluating capacity from stored booked seats alone is not allowed; it would reintroduce the original bug.

6.8 Verification request

Temporary reservation, before inventory is held:

{
  "restaurant_id": "123",
  "date": "2026-08-01",
  "time": "19:00",
  "adult": 2,
  "projection_mode": "add_requested_seats",
  "packages": [
    {
      "id": "4300",
      "pricing_rules": {
        "id": 501,
        "action_type": "decrease",
        "value_type": "percentage",
        "value": 10.0
      }
    }
  ]
}

Final reservation from an active temporary hold sends projection_mode = requested_seats_already_held. The selector does not add adult a second time.

The verification response returns HTTP 422 with a stable reason when the exact modifier is no longer selected:

{
  "success": false,
  "valid": false,
  "reason": "capacity_changed",
  "message": "Price changed. Refresh date and time pricing."
}

A missing exact-slot snapshot is represented by a default-zero state: seats_booked = 0 and no total_capacity. The selector can therefore evaluate total_seats_booked as requested_seats (for example, adult 1 projects to 1), while remaining_seats and percentage metrics do not match without total capacity. This behavior is distinct from a missing adult/requested_seats, which skips demand-capacity selection entirely. Consumers should still keep snapshots fresh before final verification.

7. End-to-end communication flows

7.1 Rule authoring and definition synchronization

sequenceDiagram
    participant Dashboard as Partner Dashboard
    participant PE as Pricing Engine
    participant DB as Pricing DB
    participant HH as HH Server

    Dashboard->>PE: Create parent rule with tier conditions
    PE->>PE: Normalize ranges and reject overlaps
    PE->>DB: Save rule, tiers, and windows
    PE->>DB: Sync T x W modifier definitions
    PE-->>Dashboard: Created
    PE->>HH: Pull capacity summary for snapshot backfill
    HH-->>PE: Slot capacity states
    PE->>DB: Upsert snapshots only

Snapshot backfill failure does not delete definitions. Quotes use the selector’s default-zero policy when no snapshot is available; metrics that require total_capacity remain ineligible until a snapshot arrives.

7.2 Pegasus quote flow

sequenceDiagram
    participant User
    participant PG as Pegasus
    participant PE as Pricing Engine
    participant DB as Pricing DB

    User->>PG: Select adult, package, and date
    PG->>PE: Date quote with adult
    PE->>DB: Read definitions and snapshots
    PE->>PE: Select tier per parent using projected seats
    PE-->>PG: Date summaries
    User->>PG: Select time
    PG->>PE: Time quote with same adult
    PE->>DB: Read selected-slot snapshot
    PE->>PE: Select exact tier and resolve parents
    PE-->>PG: Selected modifier per package/time
    PG->>PG: Store rule plus adult/date/time context

Pegasus request identity must include restaurant, sorted package IDs, date or range, and adult. Only the latest matching request may update the store.

7.3 Temporary reservation and authoritative hold

sequenceDiagram
    participant PG as Pegasus
    participant HH as HH Server
    participant PE as Pricing Engine
    participant DB as HH Inventory DB
    participant K as Kafka

    PG->>HH: Create temporary reservation with adult and selected rule
    HH->>PE: Verify rule, adult, mode=add_requested_seats
    PE-->>HH: Valid or capacity_changed
    HH->>DB: Lock inventory rows and hold adult seats
    HH->>DB: Commit temporary reservation
    HH-->>PG: Temporary reservation and locked rule
    HH->>K: Publish full slot state after commit
    K->>PE: Capacity event
    PE->>PE: Idempotent snapshot upsert

The pricing quote remains advisory until hh-server obtains the inventory row lock. The DB lock remains the authoritative overbooking guard.

7.4 Final reservation from a temporary hold

sequenceDiagram
    participant PG as Pegasus
    participant HH as HH Server
    participant PE as Pricing Engine

    PG->>HH: Confirm using temporary reservation
    HH->>HH: Read adult and locked rule from trusted reservation
    HH->>PE: Verify mode=requested_seats_already_held
    PE->>PE: Select tier without adding adult again
    alt exact modifier is still selected
        PE-->>HH: Valid
        HH-->>PG: Booking confirmed
    else tier changed, snapshot stale, or rule inactive
        PE-->>HH: 422 or retryable freshness error
        HH-->>PG: pricing_rule_invalid; refresh quote
    end

Because Kafka is asynchronous, final verification must compare the snapshot timestamp with the temporary hold timestamp. If the hold event is not yet visible, pricing-engine should perform a bounded read-through to hh-server’s capacity summary or return a retryable freshness error. It must not add adult again as a workaround.

7.5 Booking update and cancellation

sequenceDiagram
    participant HH as HH Server
    participant DB as HH Inventory DB
    participant K as Kafka
    participant PE as Pricing Engine

    HH->>DB: Change adult/date/time/active/ack/hold state
    DB->>DB: Recalculate slot total_booked_seat
    HH->>K: Publish full state for every changed old/new slot
    K->>PE: Ordered capacity event by restaurant
    PE->>PE: Ignore older timestamps; upsert latest snapshot

Events carry full current state, so duplicate delivery is safe. Date/time edits must publish both the released old slots and occupied new slots.

8. Pegasus state and race handling

8.1 Request context

Add adult to:

  • getPackagePricingRules only if that function is later given full date/time context; otherwise capacity rules remain excluded;
  • GetAvailableRulesByDatePayload;
  • GetAvailableRulesByTimePayload;
  • dateTimeDynamicPricingV2 call sites; and
  • all tests and recorded API fixtures.

8.2 Invalidation rules

When adult, selected packages, date, or restaurant changes:

  1. clear selectedPricingRules immediately;
  2. invalidate date and time pricing data for the old request key;
  3. cancel or supersede in-flight requests;
  4. fetch using the new context; and
  5. apply the response only if its key/generation still matches current state.

The loading state must be reference-counted or generation-based. An older request finishing must not clear loading while a newer request is active.

8.3 Package-level cache

pricingRulesAttempted can remain for context-free non-capacity rules but must not be treated as a cache hit for an adult-dependent quote. Capacity quote cache keys must include adult, package IDs, date, and time/range.

9. hh-server responsibilities

9.1 Trusted adult source

  • Public quote: adult originates from Pegasus.
  • Temporary validation: adult comes from permitted reservation params.
  • Final validation: adult comes from the persisted temporary/final reservation, not from a client-provided nested pricing rule.

9.2 Fetchers and validators

Update:

  • PricingEngine::PricingRuleFetcher to require/pass adult;
  • DateOnlyPricingRuleFetcher to require/pass adult;
  • PriceRuleValidatorService to pass adult and projection mode;
  • admin/owner rule injectors to pass reservation adult;
  • temporary validation to use add_requested_seats; and
  • final locked-rule validation to use requested_seats_already_held.

Every locked capacity rule must retain rule_type, parent rule ID, and tier ID.

9.3 Capacity predicate and emitters

Extract one shared predicate/calculator used by reservation callbacks, sync services, and InventoryReservationWorker:

capacity seats = adult when active AND (ack OR temporary OR locking)
capacity seats = 0 otherwise

Then:

  • emit full-state events for normal ActiveRecord updates;
  • emit events after dine-in and delivery bulk imports;
  • emit released and acquired slot states for date/time edits;
  • preserve event timestamp idempotency; and
  • test cancellation, temporary expiry, adult edits, and lock conversion.

Temporary and locking seats are intentionally counted because they consume inventory and prevent another diner from receiving a quote based on seats that are already held.

10. Implementation result by repository

10.1 pricing-engine

PE-1 — Characterization tests — implemented

  • Add the 9+1, 9+2, and 9+12 scenarios as failing selector/request specs.
  • Add multiple matching tiers, gaps, overlap, no-end-date, and multiple-window characterization specs.
  • Add request specs proving missing adult never returns capacity discounts.
  • Add tests exposing the current fail-open verification behavior.

PE-2 — Tier configuration validator — implemented for core constraints

  • Add normalized interval value objects.
  • Validate metric and schedule homogeneity.
  • Require at least one window for slot date types and a non-empty weekday list for day-based date types.
  • Validate the allowed/required date, time, and flag fields for each of the six date types instead of accepting unused or ambiguous fields.
  • Normalize bounded-to-no-end edits by clearing the corresponding end value in the same transaction.
  • Reject overlaps at partner create/update time.
  • Report existing invalid rules through an audit task before enforcement.

PE-3 — Static modifier definition synchronization — implemented

  • Replace capacity-dependent modifier creation with DemandCapacityModifierSync.
  • Create one definition per tier/window.
  • Preserve no-end flags and avoid per-date generation.
  • Supersede changed definitions and deactivate removed definitions.
  • Keep approval state independent from current seat count.

PE-4 — Deep selector module — implemented

  • Implement selected-slot snapshot lookup and projection.
  • Add one normalized schedule matcher covering all six date types, single-time, range-time, parent-bound fallbacks, and no-end flags.
  • Select zero/one tier per parent.
  • Integrate selected candidates with the current cross-parent resolver.
  • Return structured selection metadata for logs/tests.

PE-5 — Public endpoints — optional party-size selection implemented

  • Accept adult/requested_seats on date/time endpoints without changing the compact response shape.
  • Exclude capacity rules from package-only endpoint.
  • Preserve direct percentage and fixed-value units.
  • Keep snapshot freshness handling as downstream rollout work.

PE-6 — Verification — shared selector and projection mode implemented

  • Make verify call the same selector as quote.
  • Add projection mode.
  • Resolve demand-capacity windows from demand-capacity associations.
  • Remove fail-open rescue behavior.
  • Return stable 400, 409/503 retryable, and 422 business error semantics.

PE-7 — Snapshot-only Kafka lifecycle — implemented

  • Stop CapacityConsumer from creating/inactivating modifier definitions.
  • Keep timestamp-idempotent snapshot upsert.
  • Retain initial and manual capacity pull only for snapshot backfill.
  • Delete the legacy evaluator, aggregator, matcher, and capacity-driven modifier builder paths and their obsolete specs.

10.2 hh-server

HH-1 — Contract propagation — integration requirement

  • hh-server must forward adult and the appropriate projection mode to pricing-engine.
  • Every charge and validation path must use the same date, exact time, package, and party-size context.
  • The compact pricing response remains unchanged; consumers must preserve its modifier fields without inventing parent/tier response fields.

HH-2 — Reservation phase semantics — projection modes implemented; freshness retry pending

  • Use add_requested_seats before the temporary hold.
  • Use requested_seats_already_held during final confirmation.
  • Detect a stale snapshot relative to the temporary hold and retry/read-through within a bounded timeout.
  • Return pricing_rule_invalid for a business tier change and a different retryable error for infrastructure/freshness failures.

HH-3 — Capacity consistency — core booking/cancel/worker paths implemented

  • Extract the shared booked-seat predicate.
  • Align InventoryReservationWorker and sync services with temporary/locking behavior.
  • Add missing delivery bulk capacity emission.
  • Verify old and new slot events on booking edits.

10.3 hh-pegasus-monorepo

PG-1 — Types and adapters — integration requirement

  • Pegasus must include adult in date/time requests and retain the compact modifier response contract.
  • Update adapter tests and fixtures to cover omitted adult, adult 0, and the two projection modes.

PG-2 — Quote orchestration — adult refresh and generation guards implemented

  • Pass bookingStore.adult to date and time quote requests.
  • Stop treating initial package fetch as a capacity quote.
  • Key caches by restaurant/package/date/time/adult.
  • Clear selected rules immediately when context changes.
  • Add AbortController/generation protection to both date and time requests.
  • Refetch visible date rules and selected-time rules whenever adult changes.
  • Ignore stale date chunks and stale time responses even when the older request completes last.

PG-3 — Booking payload and recovery — pending rollout hardening

  • Preserve the exact selected modifier identity in temporary/final payloads.
  • On capacity_changed, clear the stale rule, refetch time pricing with current adult, recalculate charge, and ask the user to confirm the changed price.
  • Do not silently continue with a stale discount.

11. Deployment and compatibility sequence

Deploy in this order:

  1. Deploy pricing-engine with the backward-compatible selector. It skips demand-capacity selection when party size is absent and keeps the existing response shape.
  2. Update hh-server and Pegasus to send adult on every capacity-aware quote and the correct projection mode during verification.
  3. Validate capacity event freshness, booking/cancellation consistency, and final held-seat verification end to end.
  4. Compare expected tier decisions before enabling the feature for traffic.

Do not make adult mandatory at the HTTP boundary unless every caller has been updated and the compatibility policy is intentionally changed.

12. Test matrix and acceptance criteria

Verification guidance

Run the Pricing Engine RSpec suite through the repository’s Docker test service. The acceptance matrix below is the source of truth for demand, capacity, schedule, and projection behavior. Consumer-specific evidence must be recorded in the corresponding hh-server and Pegasus changes.

The remaining bullets in this section are the broader rollout acceptance matrix. Items called out as pending in sections 2 and 10 are not represented as completed merely because the core MVP suites pass.

Tier behavior

  • 9 booked + 1 adult selects 1–10 at 20% decrease.
  • 9 booked + 2 adults selects 11–20 at 10% decrease.
  • 9 booked + 12 adults selects no tier and returns normal price.
  • Boundary 10 matches only tier 1; boundary 11 matches only tier 2.
  • A configured gap returns normal price.
  • Overlapping tiers cannot be created or updated.
  • Mixed percentage/fixed tiers still return only the range-selected tier.

Many-rules behavior

  • Two tiers in one parent never compete by priority/value.
  • Two parent rules still resolve by parent-level priority.
  • Multiple windows select tiers only inside the matching window.
  • Two overlapping windows in one parent are rejected.
  • Runtime legacy overlap fails closed for that capacity parent and emits an alert.

Schedule and aggregation-window behavior

  • everyday matches every parent-valid date and observes only the requested 15-minute slot.
  • specific_date, specific_time, and specific_date_time each enforce their own date/time dimensions before tier selection.
  • day_of_week and day_time_of_week reject non-selected weekdays.
  • Slot date types cannot be created or updated without at least one eligibility window; day-based types cannot be saved with an empty weekday list.
  • Single-time specific_time and day_time_of_week normalize to one 15-minute slot and never persist a zero-length window.
  • A high dinner snapshot cannot activate a lunch window.
  • Two windows in one condition are evaluated independently; one may select a tier while the other returns normal price.
  • A reservation spanning multiple inventory rows is not counted repeatedly by summing those rows.
  • No-end-date and no-end-time definitions work without daily modifier copies.
  • Editing a bounded parent or child window to no-end clears the old end value atomically; the boolean flag and effective matcher cannot disagree.
  • “Every N days” is rejected as unsupported until an explicit recurrence interval and anchor date exist.

Lifecycle

  • Two tiers × one no-end window creates exactly two definitions.
  • Two tiers × three windows creates exactly six definitions, not daily copies.
  • Kafka booking/cancel events change snapshots but not modifier count/status.
  • Rule edits supersede definitions and retain history.

Reservation consistency

  • Temporary validation adds adult exactly once.
  • Final validation of a held reservation does not add adult again.
  • Booking cancellation reduces the snapshot.
  • Temporary expiry releases capacity.
  • Bulk worker results equal callback results.
  • Delivery and dine-in bulk paths both emit changed slot states.

Client race safety

  • Adult changes from 1 to 2 while the adult=1 request is in flight cannot apply the old response.
  • Selected pricing rules are empty during context transition.
  • Final payload contains the rule selected for the current adult/date/time.
  • A 422 capacity change triggers a visible refresh, not silent checkout.

Contract units

  • 20% is displayed and charged as 20%, not 2000%.
  • A fixed 100 currency-unit adjustment is displayed and charged as 100 units, not 100 subunits or 10,000 units.

13. Observability and rollout gates

Add structured fields to quote and verify logs:

  • restaurant/package/date/time;
  • requested adult and projection mode;
  • snapshot timestamp and age;
  • current and projected booked seats;
  • parent rule, tier, and modifier IDs;
  • decision: matched, gap, overlap, missing adult, stale snapshot, or error.

Required counters:

  • capacity_quote_missing_adult;
  • capacity_snapshot_missing;
  • capacity_snapshot_stale;
  • capacity_tier_overlap_runtime;
  • capacity_quote_tier_selected;
  • capacity_verify_changed; and
  • capacity_verify_infrastructure_error.

Rollout gates:

  • no existing active rule fails the overlap/homogeneity audit;
  • cross-repository unit conversion contract passes;
  • booking/cancel event lag is within the agreed freshness threshold;
  • no missing-adult traffic remains before strict mode; and
  • shadow decisions match the expected scenario matrix.

14. Source map

pricing-engine

  • app/models/pricing_rule.rb
  • app/models/demand_capacity_condition.rb
  • app/models/capacity_condition_item.rb
  • app/models/demand_capacity_time_window.rb
  • app/services/pricing/demand_capacity_modifier_sync.rb
  • app/services/pricing/demand_capacity_selector.rb
  • app/services/pricing/modifier_service.rb
  • app/jobs/capacity_rule_initial_evaluation_job.rb
  • app/consumers/capacity_consumer.rb
  • app/controllers/api/v1/pricing_rules_controller.rb

hh-server

  • app/services/pricing_engine/pricing_rule_fetcher.rb
  • app/services/pricing_engine/date_only_pricing_rule_fetcher.rb
  • app/services/pricing_engine/price_rule_validator_service.rb
  • app/services/reservation_service/init/hungry_hub.rb
  • app/services/reservation_service/create.rb
  • lib/model_ext/reservations/callbacks.rb
  • app/models/concerns/inventory_trait.rb
  • app/workers/inventory_reservation_worker.rb
  • app/services/event_driven_services/pricing/capacity_event_emitter.rb

hh-pegasus-monorepo

  • packages/shared-api/src/restaurant/getPackagePricingRules.ts
  • packages/shared-api/src/restaurant/getAvailableRulesByDate.ts
  • packages/shared-api/src/restaurant/getAvailableRulesByTime.ts
  • packages/shared-service/src/booking/dateTimeDynamicPricingV2.ts
  • packages/shared-service/src/booking/useAvailableDate.ts
  • packages/shared-service/src/package/packageDetail.ts
  • packages/pages-package/src/composables/usePackageData.ts
  • packages/shared-service/src/package/dynamicPricingV2.ts
  • packages/shared-type/src/Pack.ts