Sequence flows
How the pieces of PropFlow interact — per flow, with the actors that trigger them and the internal components of each service. These complement the architecture diagram (static structure) and the ADRs (why it's built this way): here the question is what happens, in what order.
Actors
| Actor | Role (JWT) | Interacts via | Typical actions |
|---|---|---|---|
| Tenant | tenant |
Client app → API Gateway | opens maintenance requests |
| Property manager | manager |
Client app → API Gateway | manages properties, assigns/tracks work orders, reads the activity feed |
| Technician | technician |
Client app → API Gateway | moves work orders through the lifecycle |
| Anthropic API | — | called by the Work Orders service | classifies requests (category + urgency) |
All HTTP enters through the gateway under the /api prefix; no client talks to a service directly, and no service touches another service's database. Since phase 8 every business route requires a JWT (ADR-0008) — the flow below runs before every other diagram on this page.
0. Authentication (login and the guard chain)
Authentication lives at the edge, once: the gateway verifies the token, decides the role, and forwards identity (not the token) to services, which trust it because only the gateway can reach them (k8s NetworkPolicy).
sequenceDiagram
autonumber
actor User as Property manager
participant GW as API Gateway
participant AU as AuthService
participant JG as JwtAuthGuard
participant RG as RolesGuard
participant WO as Work Orders Service
Note over User,AU: login — the only public business route
User->>GW: POST /api/auth/login (email, password)
GW->>AU: validate (constant-time compare)
alt valid credentials
AU-->>User: accessToken (JWT: sub + role, expires 1h)
else invalid
AU-->>User: 401 — same error for unknown user or wrong password
end
Note over User,WO: every subsequent request carries the token
User->>GW: PATCH /api/work-orders/:id/assign<br/>Authorization: Bearer …
GW->>JG: verify signature + expiry
alt missing or invalid token
JG-->>User: 401 — "who are you?"
else verified
JG->>JG: user -> request + ALS context
JG->>RG: authorize (@Roles: manager)
alt role not in the route policy
RG-->>User: 403 — "you can't do this"
else allowed
GW->>WO: proxy with x-request-id + x-user-id
Note over WO: identity enters the event envelope (actorId)<br/>and ends up in the audit log — "who did it"
WO-->>User: response
end
end
1. Creating a work order (the write path)
The defining property of the write path after phase 7: it never talks to a broker. The state change and its event commit in one Postgres transaction (ADR-0007); everything asynchronous happens later, off the request.
sequenceDiagram
autonumber
actor Tenant
participant GW as API Gateway
participant CT as WorkOrdersController
participant SV as WorkOrdersService
participant OB as WorkOrderEventsOutbox
participant DB as Postgres (work_orders)
Tenant->>GW: POST /api/work-orders
Note over GW: mints x-request-id, forwards it downstream
GW->>CT: POST /work-orders
CT->>SV: create(dto) — after DTO validation
rect rgb(232, 244, 233)
Note over SV,DB: one transaction — state + event, atomically
SV->>DB: INSERT work_orders
SV->>OB: stage(manager, work-order.created)
OB->>DB: INSERT outbox_events (envelope with correlationId)
end
SV-->>CT: work order (triagedAt: null)
CT-->>GW: 201
GW-->>Tenant: 201
Note over Tenant: response returns before any broker is involved —<br/>brokers can be down and the write still succeeds
Assigning (PATCH :id/assign) and status transitions (PATCH :id/status) follow the same shape: state-machine guard → transaction → outbox row (assigned, started, completed, cancelled).
2. The outbox relay (staged rows → brokers)
Runs inside the Work Orders service on a poll interval. FOR UPDATE SKIP LOCKED lets multiple replicas relay concurrently without double-publishing.
sequenceDiagram
autonumber
participant RL as OutboxRelay
participant DB as Postgres (outbox_events)
participant MQ as RabbitMQ (propflow.events)
participant KF as Kafka (propflow.work-orders)
loop every OUTBOX_POLL_MS (default 500ms)
RL->>DB: BEGIN + SELECT unpublished tail<br/>FOR UPDATE SKIP LOCKED
alt batch published
RL->>MQ: publish (routing key = event type, messageId = eventId)
RL->>KF: produce (key = workOrderId → per-aggregate order)
RL->>DB: stamp published_at, COMMIT
else a broker fails
RL->>DB: ROLLBACK — rows stay staged
Note over RL: retried next tick — duplicates possible,<br/>every consumer dedupes (at-least-once)
end
end
3. Event fan-out (who reacts to work-order.created)
One event, three independent consumers — none knows the others exist. RabbitMQ fans out transient reactions; Kafka retains the replayable history (ADR-0002).
sequenceDiagram
participant MQ as RabbitMQ
participant KF as Kafka
participant NT as Notifications Service
participant TR as Work Orders (triage consumer)
participant AU as Audit Service
par notification
MQ->>NT: created (queue notifications.work-order-created)
NT->>NT: seen this eventId? skip : send + mark
and AI triage
MQ->>TR: created (queue work-orders.triage)
Note over TR: full loop in diagram 4
and audit projection
KF->>AU: same envelope, from the log
AU->>AU: INSERT ... ON CONFLICT (event_id) DO NOTHING
end
4. AI triage (async classification loop)
The Work Orders service reacting to its own event (ADR-0006) — the LLM sits behind the TriageClassifier seam and every failure degrades to "no classification".
sequenceDiagram
autonumber
participant MQ as RabbitMQ
participant TC as WorkOrderTriageConsumer
participant CL as AnthropicTriageClassifier
participant AN as Anthropic API (claude-opus-4-8)
participant TS as TriageService
participant DB as Postgres (work_orders)
MQ->>TC: work-order.created
TC->>CL: classify(title, description, priority)
alt no ANTHROPIC_API_KEY
CL-->>TC: null — triage disabled
else
CL->>AN: messages.create (JSON schema with closed enums)
alt classified
AN-->>CL: category, urgency, reasoning
CL-->>TC: WorkOrderTriage
TC->>TS: apply(workOrderId, triage)
TS->>DB: triagedAt already set? (duplicate delivery guard)
rect rgb(232, 244, 233)
Note over TS,DB: same atomic pair as the write path
TS->>DB: UPDATE work_orders (triage columns)
TS->>DB: INSERT outbox_events (work-order.triaged)
end
Note over DB: the relay fans the triaged event out —<br/>the AI decision lands in the audit log too
else refusal or API failure
AN-->>CL: refusal / error
CL-->>TC: null — logged and skipped, order stays untriaged
end
end
5. Notification delivery, retries and the dead letter
What happens when the side effect fails — TTL-based retry, then dead-lettering with the error attached (phase 2 machinery).
sequenceDiagram
autonumber
participant MQ as RabbitMQ (work queue)
participant CO as WorkOrderEventsConsumer
participant ST as ProcessedEventsStore
participant SN as NotificationSender
participant RQ as retry queue (TTL)
participant DL as dead-letter exchange
MQ->>CO: work-order.completed
CO->>ST: has(eventId)?
alt already processed
CO-->>MQ: ack (duplicate skipped)
else first sight
CO->>SN: send(recipient, subject, body)
alt sent
CO->>ST: mark(eventId) — only after success
else send failed, attempts left
CO->>RQ: republish with x-attempt + 1
Note over RQ: TTL expires → message returns<br/>to the work queue for another try
else attempts exhausted
CO->>DL: dead-letter with x-last-error
Note over DL: parked for a human — poison messages<br/>never block the queue
end
end
6. Property summary (composition with graceful degradation)
The gateway as composer: two downstream calls in parallel, and the secondary one is allowed to fail without failing the request (phase 3).
sequenceDiagram
autonumber
actor Manager as Property manager
participant GW as API Gateway (PropertySummaryService)
participant PR as Properties Service
participant WO as Work Orders Service
Manager->>GW: GET /api/properties/:id/summary
par primary resource
GW->>PR: GET /properties/:id
PR-->>GW: property
and secondary enrichment
GW->>WO: GET /work-orders?propertyId=:id
alt reachable
WO-->>GW: work orders page
else down / timeout
WO--xGW: error (caught)
end
end
alt property found
GW-->>Manager: 200 — property + workOrders<br/>(or workOrders: null, workOrdersAvailable: false)
else property missing / properties service down
GW-->>Manager: error passed through — the primary resource is required
end
7. Activity feed (audit read path — and how it fills)
Two halves that never meet synchronously: Kafka ingestion keeps the projection current; reads paginate it by keyset. Replay is the superpower — point a fresh consumer group at offset 0 and the table rebuilds.
sequenceDiagram
autonumber
participant KF as Kafka (propflow.work-orders)
participant IC as AuditStreamConsumer
participant DB as Postgres (audit_events)
actor Manager as Property manager
participant GW as API Gateway
participant FE as ActivityFeedService
Note over KF,DB: ingestion — continuous, idempotent
KF->>IC: next event (consumer group offset)
IC->>DB: INSERT ... ON CONFLICT (event_id) DO NOTHING
Note over Manager,DB: reading — keyset pagination
Manager->>GW: GET /api/activity?workOrderId=…&cursor=…
GW->>FE: GET /activity (pass-through)
FE->>DB: WHERE id < cursor ORDER BY id DESC LIMIT n+1
FE-->>GW: data + nextCursor (null at the end)
GW-->>Manager: page — stable even while new events append
8. Work order lifecycle (state machine)
Not a sequence but the state chart the transitions in diagrams 1 and 4 are guarded by (work-order-transitions.ts). assigned is only reachable through the assign endpoint — the one transition that guarantees an assignee exists — and cancellation is possible from any non-terminal state. Each transition emits the matching domain event through the outbox.
stateDiagram-v2
[*] --> open: POST /work-orders<br/>(work-order.created)
open --> assigned: assign<br/>(work-order.assigned)
assigned --> assigned: reassign<br/>(work-order.assigned)
assigned --> in_progress: status change<br/>(work-order.started)
in_progress --> completed: status change<br/>(work-order.completed)
open --> cancelled: (work-order.cancelled)
assigned --> cancelled: (work-order.cancelled)
in_progress --> cancelled: (work-order.cancelled)
completed --> [*]
cancelled --> [*]
note right of open
AI triage runs async after creation —
it annotates (category/urgency),
never drives a transition
end note