Context
The backend currently creates orders in SEARCHING status, and PATCH /orders/:id/status allows transitions. But the Cascade Engine that bridges these two points — assigning a venture, offering the order, and handling the response — does not exist.
This issue covers the ENTIRE flow defined in openspec/specs/01-master-system.md §3.1.
Full Flow Architecture
POST /orders (tourist)
→ status = SEARCHING, no venture
↓
╔══════════════════════════════════╗
║ CASCADE ENGINE (new) ║
╠══════════════════════════════════╣
║ 1. Iterate ventures by cascade ║
║ 2. Filter: active, capacity, ║
║ hours, pauses ║
║ 3. Assign first valid venture ║
║ 4. status = OFFER_PENDING ║
║ 5. Cascade_Assignment + deadline ║
╚══════════════════════════════════╝
↓
GET /orders/pending (entrepreneur)
→ sees offer with deadline
↓
┌─ POST /orders/:id/accept ─┐
│ → CONFIRMED │
└────────────────────────────┘
┌─ POST /orders/:id/reject ──┐
│ → SEARCHING → cascade │
│ continues to next │
└─────────────────────────────┘
┌─ Timeout 30s ──────────────┐
│ → auto-assigns to next │
│ available venture │
└─────────────────────────────┘
Implementation Tasks
T-001: Cascade_Assignment — DB Schema
Create cascade_assignments table in Drizzle:
// apps/backend/src/db/schema/cascade-assignments.ts
export const cascadeAssignments = pgTable("cascade_assignments", {
zzz_id: uuid("zzz_id").defaultRandom().primaryKey(),
zzz_order_id: uuid("zzz_order_id").notNull().references(() => orders.zzz_id),
zzz_venture_id: integer("zzz_venture_id").notNull().references(() => ventures.id),
zzz_offer_status: pgEnum("offer_status", [
"WAITING_FOR_RESPONSE", "ACCEPTED", "REJECTED", "TIMEOUT", "SKIPPED",
])().notNull().default("WAITING_FOR_RESPONSE"),
zzz_skip_reason: pgEnum("skip_reason", [
"VENTURE_INACTIVE", "GENERAL_PAUSE", "INDIVIDUAL_PAUSE",
"CAPACITY_EXCEEDED", "CLOSED_THAT_DAY", "OUTSIDE_OPENING_HOURS",
"OUTSIDE_MOMENT_HOURS", "NOT_OFFERED",
])(),
zzz_response_deadline: timestamp("response_deadline", { withTimezone: true }),
zzz_cascade_step: integer("cascade_step").notNull(),
zzz_created_at: timestamp("zzz_created_at", { withTimezone: true }).defaultNow().notNull(),
zzz_resolved_at: timestamp("zzz_resolved_at", { withTimezone: true }),
});
Relations: cascade_assignments → orders (N:1), cascade_assignments → ventures (N:1).
Include Drizzle migration.
T-002: CascadeEngine — Core Service
Create apps/backend/src/services/cascade-engine.service.ts with these methods:
2a. startCascade(db, order, catalogTypeId)
Executed after creating the order (POST /orders):
- Get ventures for the project that match the
catalog_type_id
- Sort by
cascade_order ascending
- Iterate running
filterVenture() for each
- First venture that passes: call
offerToVenture()
- If none passes: mark order as
EXPIRED with cancel_reason = NO_VENTURE_AVAILABLE
2b. filterVenture(db, venture, order)
Validations in order:
venture.is_active === true → skip VENTURE_INACTIVE
venture.is_paused === false → skip GENERAL_PAUSE
- Item not in
venture_paused_items → skip INDIVIDUAL_PAUSE
(current_occupation + guest_count) <= venture.max_capacity → skip CAPACITY_EXCEEDED
- Venture open that day per
opening_hours → skip CLOSED_THAT_DAY
- Time within moment range (BREAKFAST/LUNCH/etc.) → skip
OUTSIDE_MOMENT_HOURS
Current occupation: sum guest_count of all CONFIRMED orders with confirmed_venture_id = venture and same service_at.
2c. offerToVenture(db, order, venture, step)
- Create
Cascade_Assignment with offer_status = WAITING_FOR_RESPONSE
- Set
response_deadline = now + project.cascade_timeout_minutes (from projects table)
- Update order:
status = OFFER_PENDING, current_offer_venture_id = venture.id
2d. continueCascade(db, order)
When a venture rejects or times out:
- Find the next venture in cascade_order (skip current one)
- Call
filterVenture() + offerToVenture() for each
- If max cascade steps reached →
EXPIRED
2e. processTimeouts(db)
Executed by the Timeout Processor:
- Find Cascade_Assignment where
offer_status = WAITING_FOR_RESPONSE AND response_deadline < now()
- Mark as
TIMEOUT
- Call
continueCascade()
T-003: New Endpoints (routes/orders.ts)
3a. GET /orders/pending
- Role: ENTREPRENEUR
- Returns Cascade_Assignments with
offer_status = WAITING_FOR_RESPONSE for the entrepreneur's ventures
- Include: order data, items, customer alias, deadline, remaining_minutes
- Scope: only assignments where the entrepreneur is a venture member
3b. POST /orders/:id/accept
- Role: ENTREPRENEUR
- Validate: order.status === "OFFER_PENDING"
- Validate: current_offer_venture_id is in entrepreneur's ventures
- Validate: order hasn't expired (check deadline)
- Atomic transaction: UPDATE order SET status = "CONFIRMED", confirmed_venture_id = current_offer_venture_id
- Cascade_Assignment → ACCEPTED
- Handle race conditions: if another entrepreneur accepted first → HTTP 409
3c. POST /orders/:id/reject
- Role: ENTREPRENEUR
- Validate: order.status === "OFFER_PENDING"
- Cascade_Assignment → REJECTED
- Call
continueCascade() for next venture
- HTTP 200 with
next_venture_triggered: true/false
T-004: Timeout Processor
Create apps/backend/src/services/timeout-processor.service.ts:
// MVP: setInterval every 30 seconds within the Hono process
export function startTimeoutProcessor(db: Db) {
setInterval(async () => {
await CascadeEngine.processTimeouts(db);
}, 30_000);
}
Call from apps/backend/src/index.ts on server start.
Include cleanup: clearInterval on graceful shutdown (SIGTERM/SIGINT).
T-005: Modify POST /orders (existing)
When creating an order, instead of just returning { status: "SEARCHING" }, also trigger the cascade engine:
// In OrderService.create(), after insert:
const order = await tx.insert(orders).values({ ... }).returning();
// Don't await — cascade engine runs in background
CascadeEngine.startCascade(db, order, catalogTypeId).catch(logger.error);
return order;
The order is returned to the client as SEARCHING. The cascade engine runs in the background.
T-006: Seed Data
Update seed scripts to include:
- Ventures with
cascade_order, is_active, is_paused, max_capacity, opening_hours
- Venture memberships (entrepreneur → venture relation)
- Project config with
cascade_timeout_minutes, max_cascade_steps
T-007: Tests
- Unit: CascadeEngine — filterVenture with each skip reason, startCascade with 1/2/N ventures, continueCascade, processTimeouts
- Integration: POST /orders → cascade starts → GET /orders/pending → accept/reject → correct final state
- Race condition: simulate simultaneous accept → only one wins
Affected Files
| File |
Action |
apps/backend/src/db/schema/cascade-assignments.ts |
New — Drizzle table |
apps/backend/src/db/schema/orders.ts |
Modify — add zzz_cascade_snapshot? |
apps/backend/src/db/schema/index.ts |
Modify — export cascade-assignments |
apps/backend/src/services/cascade-engine.service.ts |
New — core algorithm |
apps/backend/src/services/timeout-processor.service.ts |
New — background job |
apps/backend/src/services/order.service.ts |
Modify — create triggers cascade |
apps/backend/src/routes/orders.ts |
Modify — add /pending, /accept, /reject |
apps/backend/src/index.ts |
Modify — start timeout processor |
apps/backend/seeds/ |
Modify — venture config, cascade config |
packages/shared/src/types/ |
Modify — CascadeAssignment, OfferStatus, SkipReason |
packages/shared/src/validators/ |
New — AcceptInput, RejectInput schemas |
Notes
- MVP vs POST-MVP: This covers the full MVP per spec §3.1. POST-MVP would be: BullMQ + Redis instead of setInterval, full offer/wait/timeout per venture cycle, frozen cascade_snapshot, idempotency keys
- Order items constraint: Spec §3.3.1 says
items: max 1 — cascade engine iterates ventures filtered by catalog_type_id of the item
- Capacity unit: Always
guest_count (people), not item quantity
- Offline: MVP does not include offline handling. If the entrepreneur is offline when the timeout expires, cascade continues regardless
Context
The backend currently creates orders in
SEARCHINGstatus, andPATCH /orders/:id/statusallows transitions. But the Cascade Engine that bridges these two points — assigning a venture, offering the order, and handling the response — does not exist.This issue covers the ENTIRE flow defined in
openspec/specs/01-master-system.md §3.1.Full Flow Architecture
Implementation Tasks
T-001: Cascade_Assignment — DB Schema
Create
cascade_assignmentstable in Drizzle:Relations: cascade_assignments → orders (N:1), cascade_assignments → ventures (N:1).
Include Drizzle migration.
T-002: CascadeEngine — Core Service
Create
apps/backend/src/services/cascade-engine.service.tswith these methods:2a.
startCascade(db, order, catalogTypeId)Executed after creating the order (POST /orders):
catalog_type_idcascade_orderascendingfilterVenture()for eachofferToVenture()EXPIREDwithcancel_reason = NO_VENTURE_AVAILABLE2b.
filterVenture(db, venture, order)Validations in order:
venture.is_active === true→ skipVENTURE_INACTIVEventure.is_paused === false→ skipGENERAL_PAUSEventure_paused_items→ skipINDIVIDUAL_PAUSE(current_occupation + guest_count) <= venture.max_capacity→ skipCAPACITY_EXCEEDEDopening_hours→ skipCLOSED_THAT_DAYOUTSIDE_MOMENT_HOURSCurrent occupation: sum
guest_countof allCONFIRMEDorders withconfirmed_venture_id= venture and sameservice_at.2c.
offerToVenture(db, order, venture, step)Cascade_Assignmentwithoffer_status = WAITING_FOR_RESPONSEresponse_deadline = now + project.cascade_timeout_minutes(fromprojectstable)status = OFFER_PENDING,current_offer_venture_id = venture.id2d.
continueCascade(db, order)When a venture rejects or times out:
filterVenture()+offerToVenture()for eachEXPIRED2e.
processTimeouts(db)Executed by the Timeout Processor:
offer_status = WAITING_FOR_RESPONSEANDresponse_deadline < now()TIMEOUTcontinueCascade()T-003: New Endpoints (routes/orders.ts)
3a.
GET /orders/pendingoffer_status = WAITING_FOR_RESPONSEfor the entrepreneur's ventures3b.
POST /orders/:id/accept3c.
POST /orders/:id/rejectcontinueCascade()for next venturenext_venture_triggered: true/falseT-004: Timeout Processor
Create
apps/backend/src/services/timeout-processor.service.ts:Call from
apps/backend/src/index.tson server start.Include cleanup:
clearIntervalon graceful shutdown (SIGTERM/SIGINT).T-005: Modify POST /orders (existing)
When creating an order, instead of just returning
{ status: "SEARCHING" }, also trigger the cascade engine:The order is returned to the client as
SEARCHING. The cascade engine runs in the background.T-006: Seed Data
Update seed scripts to include:
cascade_order,is_active,is_paused,max_capacity,opening_hourscascade_timeout_minutes,max_cascade_stepsT-007: Tests
Affected Files
apps/backend/src/db/schema/cascade-assignments.tsapps/backend/src/db/schema/orders.tszzz_cascade_snapshot?apps/backend/src/db/schema/index.tsapps/backend/src/services/cascade-engine.service.tsapps/backend/src/services/timeout-processor.service.tsapps/backend/src/services/order.service.tsapps/backend/src/routes/orders.tsapps/backend/src/index.tsapps/backend/seeds/packages/shared/src/types/packages/shared/src/validators/Notes
items: max 1— cascade engine iterates ventures filtered bycatalog_type_idof the itemguest_count(people), not item quantity