diff --git a/services/nginx/app/modules/selfserve/selfserve.md b/services/nginx/app/modules/selfserve/selfserve.md
new file mode 100644
index 00000000..69bb1fa2
--- /dev/null
+++ b/services/nginx/app/modules/selfserve/selfserve.md
@@ -0,0 +1,1035 @@
+# Self-Serve Module
+
+This document explains the current self-serve implementation in `services/nginx/app/modules/selfserve`.
+
+It is written for two audiences:
+
+- API consumers who need to configure or call the self-serve endpoints.
+- Engineers who need to extend the module without reverse-engineering the whole flow.
+
+`openapi.yaml` in the repository root remains the contract source of truth. This guide explains how the module behaves at runtime and how the pieces fit together.
+
+## What The Module Does
+
+The self-serve module lets a customer answer eligibility questions for a vehicle, derives the tasks that apply to the lane, enables the machine relay when the answers allow it, records the physical machine start through a webhook, bills the wash when the lane is stopped, and keeps a session summary with questions, answers, tasks, and lifecycle events.
+
+At a high level:
+
+1. Shared self-serve questions are loaded.
+2. Machine-type-specific conditions, rules, and tasks are resolved for the lane.
+3. Vehicle answers are evaluated into condition results.
+4. If all visible questions are answered and an active task exposes the `MACHINE` service, the lane relay is enabled.
+5. When the physical machine button is pressed, `/relay/button/press/post` records the machine start.
+6. When the lane is stopped, the wash is invoiced and the latest open self-serve session is completed with the generated order id.
+
+## Main Flow
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant Customer
+ participant API as "Department Self-Serve API"
+ participant Flow as "selfserve_wash_flow"
+ participant Lane as "selfserve_lane"
+ participant Shelly as "Shelly relay"
+ participant Scanner as "Plate scanner webhook"
+
+ Customer->>API: GET /department/selfserve/vehicle/allowed?lane_id=12®=AB12345
+ API->>Flow: previewVehicleEligibility()
+ Flow-->>API: Questions, tasks, allowed=false/true
+ API-->>Customer: Eligibility preview
+
+ Customer->>API: POST /department/selfserve/vehicle/conditions
+ API->>Flow: synchronizeSession()
+ Flow->>Lane: setLaneCache(allowed_services)
+ Flow->>Lane: turnOnRelay(MACHINE)
+ Lane->>Shelly: switch(true)
+ Flow-->>API: Session summary with MACHINE_RELAY_ENABLED event
+ API-->>Customer: Updated summary
+
+ Scanner->>API: POST /relay/button/press/post
+ API->>Flow: recordMachineStartWebhook()
+ Flow->>Lane: set status/state, reg, customer, wash timer
+ Flow-->>API: Session summary with MACHINE_START_TRIGGERED event
+
+ Customer->>API: POST /modules/self-serve/lane/command (STOP)
+ API->>Lane: execute(STOP)
+ Lane->>Lane: invoice() and open exit port
+ Lane->>Flow: completeLatestSessionForLane(order_id)
+ Flow-->>API: Session summary with SESSION_COMPLETED event
+ API-->>Customer: Lane reset response
+```
+
+## Architecture
+
+```mermaid
+flowchart LR
+ Q["department_selfserve_questions
shared questions preferred"] --> F["selfserve_wash_flow"]
+ VC["department_selfserve_vehicle_conditions
vehicle answers"] --> F
+ C["department_selfserve_conditions
machine-type or legacy"] --> E["selfserve_condition_evaluator"]
+ R["department_selfserve_condition_rules"] --> E
+ T["department_selfserve_tasks
machine-type or legacy"] --> F
+ MT["selfserve_machine_types"] --> L["department_lanes.machine_type_id"]
+ E --> F
+ L --> F
+ F --> SL["selfserve_lane"]
+ SL --> SH["Shelly machine relay"]
+ F --> S["selfserve_wash_sessions"]
+ F --> SA["selfserve_wash_session_answers"]
+ F --> ST["selfserve_wash_session_tasks"]
+ F --> SE["selfserve_wash_session_events"]
+```
+
+## Domain Model
+
+### Entity Roles
+
+| Concept | Main object/class | Scope | Purpose |
+| --- | --- | --- | --- |
+| Shared questions | `department_selfserve_questions_o` | Shared rows are preferred over legacy lane/product rows | Defines the yes/no questions the customer must answer. |
+| Conditions | `department_selfserve_conditions_o` | Prefer `machine_type_id`, else fall back to legacy department/lane/product rows | Groups rules into named boolean gates. |
+| Condition rules | `department_selfserve_condition_rules_o` | Attached to a condition | Evaluates question answers or nested conditions. |
+| Tasks | `department_selfserve_tasks_o` | Prefer `machine_type_id`, else fall back to legacy department/lane/product rows | Drives the visible self-serve tasks and exposed services such as `MACHINE`. |
+| Machine types | `selfserve_machine_types_o` | Reusable across lanes | Lets multiple lanes share the same conditions and tasks. |
+| Vehicle answers | `department_selfserve_vehicle_conditions_o` | Per department, lane, registration, and question | Stores the customer's answers. |
+| Wash session | `selfserve_wash_sessions_o` | Per lane and vehicle | Tracks current wash lifecycle, timestamps, machine flags, and billing order. |
+| Session answers | `selfserve_wash_session_answers_o` | Per session | Snapshot of the answered visible questions. |
+| Session tasks | `selfserve_wash_session_tasks_o` | Per session | Snapshot of the active tasks and attached services/buttons. |
+| Session events | `selfserve_wash_session_events_o` | Per session | Audit trail for sync, relay enable, machine start, and completion. |
+| Lane runtime | `selfserve_lane` | Per lane | Applies lane status/state changes, relay control, billing, and STOP/RESET behavior. |
+
+### Scoping Rules
+
+- Questions should now be defined as shared questions. `selfserve_wash_flow::loadQuestions()` first calls `department_selfserve_questions_o::getSharedQuestions()`. If shared questions exist, they are used for every department and lane.
+- Conditions and tasks are machine-type-first. If a lane has `department_lanes.machine_type_id` and matching rows exist, `selfserve_wash_flow` uses those rows and ignores the legacy lane/product rows.
+- Legacy fallback still exists. If a lane has no machine type or there are no machine-type-specific rows, the flow falls back to the old department/lane/product plus vehicle-type lookup.
+- Vehicle answers are still stored per department, lane, registration number, and question. Those answers feed both direct task gates and nested condition evaluation.
+
+### Runtime Status Tables
+
+#### Wash Session Status
+
+| Status | Meaning |
+| --- | --- |
+| `PENDING_QUESTIONS` | At least one visible question has no answer yet. |
+| `READY_FOR_MACHINE_START` | The snapshot is eligible and ready to enable or start the machine. |
+| `MACHINE_NOT_ALLOWED` | All visible questions are answered, but the lane cannot start the machine. |
+| `MACHINE_RELAY_ENABLED` | The relay has already been enabled for the session. |
+| `MACHINE_STARTED` | The physical machine start webhook has been recorded. |
+| `COMPLETED` | The STOP flow completed the session, optionally with an `order_id`. |
+
+#### Wash Event Types
+
+| Event | Meaning |
+| --- | --- |
+| `SESSION_SYNCED` | A snapshot was written to the current session. |
+| `MACHINE_RELAY_ENABLED` | The relay was enabled because the snapshot allowed machine start. |
+| `MACHINE_START_TRIGGERED` | The physical machine start webhook was recorded. |
+| `SESSION_COMPLETED` | The latest open session for the lane was closed, usually from STOP. |
+
+#### Lane Status
+
+| Status | Meaning |
+| --- | --- |
+| `AVAILABLE` | Lane is free and ready. |
+| `OCCUPIED` | Lane is currently in use. |
+| `RESERVED` | Lane is reserved but not yet started. |
+| `FAULT` | Lane cannot be used until the fault is cleared. |
+| `MAINTENANCE` | Lane is intentionally unavailable. |
+| `CLOSED` | Lane is closed. |
+
+#### Lane State
+
+| State | Meaning |
+| --- | --- |
+| `IDLE` | Resting lane state. |
+| `ENTRANCE_PORT_OPEN_QUEUED` | Entrance gate open is queued. |
+| `ENTRANCE_PORT_OPEN` | Entrance gate is open. |
+| `MACHINE_RELAY_ON_QUEUED` | Machine relay enable is queued. |
+| `MACHINE_RELAY_ON` | Machine relay is on. |
+| `MACHINE_RELAY_OFF_QUEUED` | Machine relay disable is queued. |
+| `MACHINE_RELAY_OFF` | Machine relay is off. |
+| `IN_WASH` | The machine has started or the wash is in progress. |
+| `EXIT_PORT_OPEN_QUEUED` | Exit gate open is queued. |
+| `EXIT_PORT_OPEN` | Exit gate is open. |
+| `FAULT` | Lane fault state. |
+| `MAINTENANCE` | Maintenance state. |
+| `CLOSED` | Closed state. |
+
+## Eligibility Rules
+
+`selfserve_wash_flow` allows machine start only when all of these are true:
+
+1. Every visible question has an answer.
+2. The lane has `relay_machine_id`.
+3. At least one active task exposes the `MACHINE` service.
+
+Visible questions are determined by question `condition_id` gates. Active tasks are determined by `selfserve_condition_evaluator::taskGateSatisfied()`.
+
+`selfserve_condition_evaluator` uses these rules:
+
+- Non-`IS_TRUE_OR_ANY_TRUE` rules are AND-ed together.
+- `IS_TRUE_OR_ANY_TRUE` rules are OR-ed together inside the same condition.
+- A task gate id is resolved as a condition result first. If there is no condition with that id, it falls back to the raw answer for that question id.
+
+Supported rule types:
+
+- `IS_TRUE`
+- `IS_FALSE`
+- `IS_SET`
+- `IS_TRUE_OR_NOT_SET`
+- `IS_FALSE_OR_NOT_SET`
+- `IS_TRUE_OR_ANY_TRUE`
+
+Supported rule object types:
+
+- `question`
+- `condition`
+
+## Public APIs
+
+All `/department/selfserve/*` and `/modules/self-serve/*` routes require an authenticated user session and the listed permissions.
+
+`/relay/button/press/post` is different: it uses plate-scanner authentication through `authentication::get_plate_scanner()`, which accepts the token from:
+
+- `Authorization: Bearer `
+- query parameter `token`
+- POST body `token`
+- JSON body field `token`
+
+### `/department/selfserve/questions`
+
+Purpose: manage shared or legacy self-serve questions.
+
+| Method | Required params | Permissions | Notes |
+| --- | --- | --- | --- |
+| `GET /department/selfserve/questions` | none, optional `id`, `department`, `lane`, `product` | `list_department_selfserve_questions`, optional `view_all_department_selfserve_questions` | Returns one question by `id` or a filtered paginated list. |
+| `POST /department/selfserve/questions` | `question`, `description` | `add_department_selfserve_questions` | Optional `department`, `lane`, `product`, `condition_id`, `order_priority`. Use `0/0/0` for shared questions. |
+| `PUT /department/selfserve/questions` | `id` | `edit_department_selfserve_questions` | Updates any subset of fields. |
+| `DELETE /department/selfserve/questions` | `id` | `delete_department_selfserve_questions` | Soft-deletes the question. |
+
+Typical failures:
+
+- `400` missing required fields
+- `403` user cannot access the target department
+- `404` question not found
+
+### `/department/selfserve/conditions`
+
+Purpose: manage named condition gates used by tasks and question visibility.
+
+| Method | Required params | Permissions | Notes |
+| --- | --- | --- | --- |
+| `GET /department/selfserve/conditions` | none, optional `id`, `department`, `lane`, `product`, `machine_type_id` | `list_department_selfserve_conditions`, optional `view_all_department_selfserve_conditions` | Lists one or many conditions. |
+| `POST /department/selfserve/conditions` | `name`, `description`, and either `machine_type_id` or `department`+`lane`+`product` | `add_department_selfserve_conditions` | Optional parent `condition_id` supports nested trees. |
+| `PUT /department/selfserve/conditions` | `id` | `update_department_selfserve_conditions` | Can move a condition between machine types or legacy scopes. |
+| `DELETE /department/selfserve/conditions` | `id` | `delete_department_selfserve_conditions` | Soft-deletes the condition. |
+
+Typical failures:
+
+- `400` missing scope information
+- `403` user cannot access the source or target department
+- `404` condition not found
+
+### `/department/selfserve/condition/rules`
+
+Purpose: define how a condition becomes true or false.
+
+| Method | Required params | Permissions | Notes |
+| --- | --- | --- | --- |
+| `GET /department/selfserve/condition/rules` | none, optional `id`, `condition_id`, `type`, `object_type`, `object_id` | `list_department_selfserve_condition_rules`, optional `view_all_department_selfserve_condition_rules` | Access is checked through the parent condition's department. |
+| `POST /department/selfserve/condition/rules` | `condition_id`, `type`, `object_type`, `object_id`, `name`, `description` | `add_department_selfserve_condition_rules` | `object_type` must be `question` or `condition`. |
+| `PUT /department/selfserve/condition/rules` | `id` | `update_department_selfserve_condition_rules` | Supports moving the rule to another condition. |
+| `DELETE /department/selfserve/condition/rules` | `id` | `delete_department_selfserve_condition_rules` | Soft-deletes the rule. |
+
+Typical failures:
+
+- `400` missing fields
+- `403` department access denied through the parent condition
+- `404` rule or target condition not found
+
+### `/department/selfserve/tasks`
+
+Purpose: manage the task list shown after eligibility evaluation.
+
+| Method | Required params | Permissions | Notes |
+| --- | --- | --- | --- |
+| `GET /department/selfserve/tasks` | none, optional `id`, `department`, `lane`, `product`, `condition_id`, `machine_type_id` | `list_department_selfserve_tasks`, optional `view_all_department_selfserve_tasks` | Lists one or many tasks. |
+| `POST /department/selfserve/tasks` | `task`, `description`, and either `machine_type_id` or `department`+`lane`+`product` | `add_department_selfserve_tasks` | Optional `condition_id`, `order_priority`, `services`, `buttons`, `dynamic_images_vehicle_type`. |
+| `PUT /department/selfserve/tasks` | `id` | `edit_department_selfserve_tasks` | Updates any subset of fields. |
+| `DELETE /department/selfserve/tasks` | `id` | `delete_department_selfserve_tasks` | Soft-deletes the task. |
+
+Notes:
+
+- Route parameter name is `condition_id`. That value is used as the task gate id.
+- `services` accepts an array, JSON array string, or comma-separated string. The only current enum case is `MACHINE`.
+- If an active task does not expose `MACHINE`, the relay will not be enabled.
+
+Typical failures:
+
+- `400` missing fields or invalid `services` format
+- `403` department access denied
+- `404` task not found
+
+### `/department/selfserve/tasks/attachments*`
+
+Purpose: attach downloadable assets to self-serve tasks.
+
+| Method | Required params | Permissions | Notes |
+| --- | --- | --- | --- |
+| `GET /department/selfserve/tasks/attachments` | `id` | `list_department_selfserve_task_attachments` | Lists attachments for a task. |
+| `GET /department/selfserve/tasks/attachments/download` | `task_id`, `attachment_id` | `download_department_selfserve_task_attachments` | Returns a direct download URL. |
+| `POST /department/selfserve/tasks/attachments/upload` | `task_id`, `base64_file`, `file_name` | `add_department_selfserve_task_attachments` | Stores an attachment and links it to the task. |
+| `DELETE /department/selfserve/tasks/attachments` | `task_id`, `attachment_id` | `delete_department_selfserve_task_attachments` | Removes the linked attachment from the task. |
+
+Typical failures:
+
+- `400` missing fields
+- `403` department access denied
+- `404` task or attachment not found
+
+### `/department/selfserve/vehicle/conditions`
+
+Purpose: create and manage the vehicle-specific answers that drive eligibility.
+
+| Method | Required params | Permissions | Notes |
+| --- | --- | --- | --- |
+| `GET /department/selfserve/vehicle/conditions` | none, optional `id`, `department`, `customer_id`, `lane`, `reg`, `question` | `list_department_selfserve_vehicle_conditions` or `list_own_department_selfserve_vehicle_conditions` | `own_*` access is restricted to the user's customer number. |
+| `POST /department/selfserve/vehicle/conditions` | `department`, `lane`, `reg`, `question`, `value` | `add_department_selfserve_vehicle_conditions` or `add_own_department_selfserve_vehicle_conditions` | Calls `selfserve_wash_flow::synchronizeSession()` and returns both the condition row and `selfserve` summary. |
+| `PUT /department/selfserve/vehicle/conditions` | `id` | `update_department_selfserve_vehicle_conditions` or `update_own_department_selfserve_vehicle_conditions` | Re-synchronizes the session after the update. |
+| `DELETE /department/selfserve/vehicle/conditions` | `id` | `delete_department_selfserve_vehicle_conditions` or `delete_own_department_selfserve_vehicle_conditions` | Deletes the answer and attempts to re-synchronize the session. |
+
+Typical failures:
+
+- `400` missing fields or invalid session
+- `403` permission denied, wrong department, or wrong customer ownership
+- `404` answer row not found
+
+### `/department/selfserve/vehicle/allowed`
+
+Purpose: preview whether self-serve is currently allowed for a vehicle on a lane.
+
+| Method | Required params | Permissions | Notes |
+| --- | --- | --- | --- |
+| `GET /department/selfserve/vehicle/allowed` | `lane_id`, `reg` | `list_department_selfserve_vehicle_conditions` or `list_own_department_selfserve_vehicle_conditions` | Calls `selfserve_wash_flow::previewVehicleEligibility()` and returns questions, tasks, allowed services, and the current session if one exists. |
+
+Typical failures:
+
+- `400` missing `lane_id` or `reg`
+- `403` permission denied or wrong vehicle ownership
+- `404` lane not found
+
+### `/department/selfserve/washes/summary`
+
+Purpose: inspect the summary of a self-serve wash.
+
+| Method | Required params | Permissions | Notes |
+| --- | --- | --- | --- |
+| `GET /department/selfserve/washes/summary` | either `session_id`, or `lane_id` plus `reg` | `list_department_selfserve_vehicle_conditions` or `list_own_department_selfserve_vehicle_conditions` | Returns `session`, `lane`, `machine_type`, `questions`, `tasks`, and `events`. |
+
+Typical failures:
+
+- `400` missing identifying parameters
+- `403` permission denied or wrong vehicle ownership
+- `404` session not found, or no session exists for lane and vehicle
+
+### `/department/selfserve/machine-types`
+
+Purpose: manage reusable machine type profiles.
+
+| Method | Required params | Permissions | Notes |
+| --- | --- | --- | --- |
+| `GET /department/selfserve/machine-types` | none, optional `id` | `list_department_selfserve_machine_types` | Lists one or many machine types. |
+| `POST /department/selfserve/machine-types` | `name` | `add_department_selfserve_machine_types` | Optional `description`. |
+| `PUT /department/selfserve/machine-types` | `id` | `update_department_selfserve_machine_types` | Updates `name` and/or `description`. |
+| `DELETE /department/selfserve/machine-types` | `id` | `delete_department_selfserve_machine_types` | Soft-deletes the machine type. |
+
+Typical failures:
+
+- `400` missing `name` or empty update
+- `404` machine type not found
+
+### `/modules/self-serve/lane/*`
+
+Purpose: operational lane control and relay management.
+
+| Method | Required params | Permissions | Notes |
+| --- | --- | --- | --- |
+| `GET /modules/self-serve/lane/status` | optional `lane_id`, default `1` | `modules_selfserve_lane_status_view` | Returns lane status, mode, state, wash timer, reg, and customer number. |
+| `POST /modules/self-serve/lane/command` | `lane_id`, `command` | `modules_selfserve_lane_command_execute` plus command-specific permission | Valid commands: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`. |
+| `POST /modules/self-serve/lane/services/allowed` | `lane_id`, optional `task_ids` | `modules_selfserve_lane_services_set_allowed` | Writes allowed service names to the lane cache. |
+| `POST /modules/self-serve/lane/relay/machine/enable` | `lane_id`, optional `duration` | `modules_selfserve_lane_relay_enable_machine` | Manual enable, still gated by allowed services. |
+| `POST /modules/self-serve/lane/force/machine/enable` | `lane_id`, optional `duration`, optional `license_plate` | `modules_selfserve_lane_force_machine_enable` | Bypasses service gating and marks the lane as in wash. |
+| `POST /modules/self-serve/lane/force/machine/disable` | `lane_id`, optional `license_plate` | `modules_selfserve_lane_force_machine_disable` | Keeps the lane in wash but turns the machine relay off. |
+
+STOP flow details:
+
+- `selfserve_lane_command::STOP` requires lane status `OCCUPIED`.
+- Unless bypass is enabled, the lane customer number must match the current authenticated user's customer number.
+- STOP calls `invoice()`, opens the exit port, turns off the machine relay if self-serve is enabled for the department, completes the latest open self-serve session, and then resets the lane.
+
+Typical failures:
+
+- `400` invalid parameters
+- `403` missing permission
+- `404` lane not found
+- `403` from `/relay/machine/enable` if the `MACHINE` service is not currently allowed
+
+### `/relay/button/press/post`
+
+Purpose: record the physical machine start trigger.
+
+| Method | Required params | Authentication | Notes |
+| --- | --- | --- | --- |
+| `GET /relay/button/press/post` | optional `reg`, optional `lane_id` depending on department lane count | Plate-scanner auth | Supported for devices that can only call GET. |
+| `POST /relay/button/press/post` | optional `reg`, optional `lane_id` depending on department lane count | Plate-scanner auth | Same behavior as GET. |
+
+Lane resolution behavior:
+
+- If `lane_id` is provided, the route verifies that the lane belongs to the scanner's department.
+- If `lane_id` is not provided and the department has exactly one lane, that lane is used automatically.
+- If `lane_id` is not provided and the department has multiple lanes, the request fails with `400`.
+
+Runtime behavior:
+
+- Calls `selfserve_wash_flow::recordMachineStartWebhook()`.
+- Marks the session as machine-started.
+- Updates lane status/state to occupied and in-wash if needed.
+- Sets registration number, customer number, and wash start timestamp on the lane.
+
+Typical failures:
+
+- `403` invalid plate scanner token
+- `400` missing `lane_id` in a multi-lane department
+- `403` lane does not belong to the scanner department
+- `404` no active self-serve wash session for the lane
+
+## Public Code Interfaces
+
+### `selfserve_wash_flow` and `selfserve_wash_flow_i`
+
+Main orchestration class for the self-serve lifecycle.
+
+Public methods:
+
+| Method | Use it when | Returns |
+| --- | --- | --- |
+| `previewVehicleEligibility(int $laneId, string $reg, ?int $customerNumber = null)` | You need a read-only eligibility preview without mutating state. | Snapshot with `questions`, `tasks`, `allowed_services`, `allowed`, and optional current `session`. |
+| `synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true)` | Answers changed and you want session state, tasks, events, and relay enable to stay in sync. | Full session summary. |
+| `recordMachineStartWebhook(int $laneId, ?string $reg = null, array $payload = [])` | The machine button or hardware event fired. | Full session summary after the machine-start event. |
+| `getSessionSummary(int $sessionId)` | You have a session id already. | Full session summary. |
+| `getLatestSessionSummary(int $laneId, string $reg)` | You want the latest session for a lane and vehicle. | Full session summary. |
+| `completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null)` | STOP has finished and you want to close the latest open session. | Full summary, or `null` if no open session exists. |
+
+Key implementation details:
+
+- Calls `selfserve_schema_bootstrap::ensureTables()` in the constructor, so the session and machine-type tables are created lazily and idempotently at runtime.
+- Loads shared questions first, then machine-type conditions and tasks first, then legacy fallback only when needed.
+- Persists answer, task, and event snapshots on every sync.
+- Enables the Shelly machine relay automatically when the snapshot is eligible and `activateMachine` is `true`.
+
+### `selfserve_condition_evaluator` and `selfserve_condition_evaluator_i`
+
+Pure evaluation layer for conditions and task gates.
+
+Public methods:
+
+| Method | Use it when |
+| --- | --- |
+| `evaluate(array $conditions, array $rules, array $answers)` | You need a boolean result map keyed by condition id. |
+| `taskGateSatisfied(?int $gateId, array $conditionResults, array $answers)` | You need to decide whether a task should be active. |
+
+### `selfserve_lane`
+
+Runtime lane aggregate built from traits.
+
+Important operational methods used by the module:
+
+- `execute(selfserve_lane_command $command, selfserve_lane_command_arguments $arguments)`
+- `turnOnRelay(selfserve_lane_relay::MACHINE, ?int $duration = null)`
+- `turnOffRelay(selfserve_lane_relay::MACHINE)`
+- `forceTurnOnMachineRelay(?int $duration = null)`
+- `forceTurnOffMachineRelay()`
+- `getLaneStatus()`
+- `getLaneState()`
+- `setLaneStatus(...)`
+- `setLaneState(...)`
+- `setWashStartTime(...)`
+- `getElapsedWashTime()`
+- `setLicensePlate(...)`
+- `setCustomerNumber(...)`
+- `setLaneCache(...)`
+- `invoice()`
+
+Important enums:
+
+- `selfserve_lane_command`: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`
+- `selfserve_lane_status`: `AVAILABLE`, `OCCUPIED`, `RESERVED`, `FAULT`, `MAINTENANCE`, `CLOSED`
+- `selfserve_lane_state`: `IDLE`, `IN_WASH`, relay states, gate states, and fault states
+- `selfserve_lane_services`: currently only `MACHINE`
+
+### Machine Type And Wash Session Objects
+
+`selfserve_machine_types_o` is a reusable configuration object. It stores the profile name and description and is attached to a lane through `department_lanes.machine_type_id`.
+
+`selfserve_wash_sessions_o` is the lifecycle record. At behavior level it supports:
+
+- creating a new session with lane, machine type, customer, vehicle, and metadata
+- updating status
+- marking relay enabled
+- marking machine start triggered
+- marking completion with optional `order_id`
+- selecting the latest open or latest overall session for a lane and registration number
+
+## End-To-End Example: Happy Path
+
+The example below shows the intended production flow with one shared question and one machine-type-specific task that exposes `MACHINE`.
+
+### 1. Create a machine type
+
+```bash
+curl -X POST "$BASE_URL/department/selfserve/machine-types" \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "name": "HighPressureFoam",
+ "description": "Shared profile for foam cannon lanes"
+ }'
+```
+
+### 2. Associate the machine type with the lane
+
+This is done through the existing department lanes route, not a self-serve-specific route.
+
+```bash
+curl -X PUT "$BASE_URL/department/lanes" \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "id": 12,
+ "machine_type_id": 3
+ }'
+```
+
+### 3. Create a shared question
+
+```bash
+curl -X POST "$BASE_URL/department/selfserve/questions" \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "department": 0,
+ "lane": 0,
+ "product": 0,
+ "question": "Is the hydraulic lock engaged?",
+ "description": "Required before machine start",
+ "order_priority": 10
+ }'
+```
+
+### 4. Create a machine-type-specific condition and rule
+
+```bash
+curl -X POST "$BASE_URL/department/selfserve/conditions" \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "machine_type_id": 3,
+ "name": "Machine may start",
+ "description": "All mandatory safety checks passed"
+ }'
+```
+
+```bash
+curl -X POST "$BASE_URL/department/selfserve/condition/rules" \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "condition_id": 14,
+ "type": "IS_TRUE",
+ "object_type": "question",
+ "object_id": 21,
+ "name": "Hydraulic lock is engaged",
+ "description": "Question 21 must be answered true"
+ }'
+```
+
+### 5. Create a machine-type-specific task that exposes `MACHINE`
+
+```bash
+curl -X POST "$BASE_URL/department/selfserve/tasks" \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "machine_type_id": 3,
+ "condition_id": 14,
+ "task": "Press the machine start button",
+ "description": "The relay is enabled automatically when all answers allow it.",
+ "order_priority": 10,
+ "services": ["MACHINE"]
+ }'
+```
+
+### 6. Preview eligibility before answering
+
+```bash
+curl "$BASE_URL/department/selfserve/vehicle/allowed?lane_id=12®=AB12345" \
+ -H "Authorization: Bearer $TOKEN"
+```
+
+Typical result before all answers are present:
+
+```json
+{
+ "success": true,
+ "data": {
+ "allowed": false,
+ "all_visible_questions_answered": false,
+ "machine_available": true,
+ "questions": [
+ {
+ "id": 21,
+ "question": "Is the hydraulic lock engaged?",
+ "answer": null
+ }
+ ],
+ "tasks": []
+ }
+}
+```
+
+### 7. Submit the answer
+
+```bash
+curl -X POST "$BASE_URL/department/selfserve/vehicle/conditions" \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "department": 7,
+ "lane": 12,
+ "reg": "AB12345",
+ "question": 21,
+ "value": true,
+ "customer_id": 100234
+ }'
+```
+
+The POST response includes a `selfserve` summary. When the answer makes the vehicle eligible, `synchronizeSession()` will:
+
+- create or update the session
+- snapshot the answer and tasks
+- populate lane cache with `allowed_services`
+- enable the Shelly relay
+- log a `MACHINE_RELAY_ENABLED` event
+
+### 8. Inspect the summary
+
+```bash
+curl "$BASE_URL/department/selfserve/washes/summary?lane_id=12®=AB12345" \
+ -H "Authorization: Bearer $TOKEN"
+```
+
+Expected highlights:
+
+- `session.status` becomes `MACHINE_RELAY_ENABLED`
+- `questions` contains the answered question
+- `tasks` contains the machine start task
+- `events` contains both `SESSION_SYNCED` and `MACHINE_RELAY_ENABLED`
+
+### 9. Record the physical machine start
+
+```bash
+curl -X POST "$BASE_URL/relay/button/press/post?token=$SCANNER_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "lane_id": 12,
+ "reg": "AB12345",
+ "source": "shelly-button"
+ }'
+```
+
+Expected highlights:
+
+- `session.status` becomes `MACHINE_STARTED`
+- the lane is `OCCUPIED`
+- the lane state is `IN_WASH`
+- `machine_start_triggered_at` is set
+- the summary includes a `MACHINE_START_TRIGGERED` event
+
+### 10. Stop the lane and complete billing
+
+```bash
+curl -X POST "$BASE_URL/modules/self-serve/lane/command" \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "lane_id": 12,
+ "command": "STOP"
+ }'
+```
+
+STOP invoices the elapsed wash time using the configured minute product, writes `order_id` to the session when available, logs `SESSION_COMPLETED`, and resets the lane.
+
+## End-To-End Example: Machine Not Allowed
+
+This example shows the failure mode the UI usually needs to handle.
+
+### Preview shows the blocking reason
+
+```bash
+curl "$BASE_URL/department/selfserve/vehicle/allowed?lane_id=12®=AB12345" \
+ -H "Authorization: Bearer $TOKEN"
+```
+
+Example response:
+
+```json
+{
+ "success": true,
+ "data": {
+ "allowed": false,
+ "all_visible_questions_answered": true,
+ "machine_available": false,
+ "allowed_services": [],
+ "questions": [
+ {
+ "id": 21,
+ "question": "Is the hydraulic lock engaged?",
+ "answer": true
+ }
+ ],
+ "tasks": []
+ }
+}
+```
+
+Interpretation:
+
+- All questions are answered.
+- The machine is still not allowed because either `relay_machine_id` is missing on the lane or no active task exposed `MACHINE`.
+
+If an operator tries to enable the relay manually anyway:
+
+```bash
+curl -X POST "$BASE_URL/modules/self-serve/lane/relay/machine/enable" \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "lane_id": 12
+ }'
+```
+
+The route returns `403` when the lane cache does not currently allow `MACHINE`.
+
+## PHP Examples
+
+These examples assume you are running inside the app runtime after the normal bootstrap has loaded the classes and database connection.
+
+### Preview eligibility
+
+```php
+previewVehicleEligibility(12, 'AB12345', 100234);
+
+var_dump([
+ 'allowed' => $preview['allowed'],
+ 'all_visible_questions_answered' => $preview['all_visible_questions_answered'],
+ 'questions' => $preview['questions'],
+ 'tasks' => $preview['tasks'],
+]);
+```
+
+### Synchronize a session after answers change
+
+```php
+add(
+ 7,
+ 12,
+ 'AB12345',
+ 21,
+ true,
+ 100234
+);
+
+$flow = new selfserve_wash_flow();
+$summary = $flow->synchronizeSession(12, 'AB12345', 100234);
+
+var_dump([
+ 'session_id' => $summary['session']['id'],
+ 'status' => $summary['session']['status'],
+ 'events' => $summary['events'],
+]);
+```
+
+### Record the machine start webhook
+
+```php
+recordMachineStartWebhook(12, 'AB12345', [
+ 'source' => 'manual-test',
+ 'button' => 'start',
+]);
+
+var_dump([
+ 'status' => $summary['session']['status'],
+ 'machine_start_triggered' => $summary['session']['machine_start_triggered'],
+ 'events' => $summary['events'],
+]);
+```
+
+### Retrieve the latest wash summary
+
+```php
+getLatestSessionSummary(12, 'AB12345');
+
+var_dump([
+ 'session' => $summary['session'],
+ 'questions' => $summary['questions'],
+ 'tasks' => $summary['tasks'],
+ 'events' => $summary['events'],
+]);
+```
+
+### Define a machine type and associate it with a lane
+
+```php
+add(
+ 'HighPressureFoam',
+ 'Reusable profile for foam cannon lanes'
+);
+
+$lane = (new department_lanes_o())->select(12);
+$lane->machine_type_id->set((int)$machineType->id);
+
+var_dump([
+ 'machine_type_id' => $machineType->id,
+ 'lane_id' => $lane->id,
+]);
+```
+
+### Add a shared question and machine-type-specific conditions, rules, and tasks
+
+```php
+add(
+ 0,
+ 0,
+ 0,
+ 'Is the hydraulic lock engaged?',
+ 'Required before machine start',
+ null,
+ 10
+);
+
+$condition = (new department_selfserve_conditions_o())->add(
+ 0,
+ 0,
+ 0,
+ 'Machine may start',
+ 'All mandatory safety checks passed',
+ null,
+ 3
+);
+
+(new department_selfserve_condition_rules_o())->add(
+ (int)$condition->id,
+ selfserve_condition_rule_type::IS_TRUE->value,
+ selfserve_condition_rule_object_type::QUESTION->value,
+ (int)$question->id,
+ 'Hydraulic lock must be engaged',
+ 'The machine may only start when the shared question is true'
+);
+
+$task = (new department_selfserve_tasks_o())->add(
+ 0,
+ 0,
+ 0,
+ (int)$condition->id,
+ 'Press the machine start button',
+ 'The relay is already enabled when this task becomes active',
+ 10,
+ [selfserve_lane_services::MACHINE],
+ null,
+ null,
+ 3
+);
+
+var_dump([
+ 'question_id' => $question->id,
+ 'condition_id' => $condition->id,
+ 'task_id' => $task->id,
+]);
+```
+
+## Implementation Recipes
+
+### Add a new self-serve machine type
+
+1. Create a `selfserve_machine_types` row through `/department/selfserve/machine-types` or `selfserve_machine_types_o`.
+2. Set `department_lanes.machine_type_id` through `/department/lanes` or `department_lanes_o`.
+3. Add the machine-type-specific conditions.
+4. Add the condition rules that evaluate your questions or nested conditions.
+5. Add the machine-type-specific tasks.
+6. Ensure at least one active task exposes `MACHINE` if the lane should auto-enable the relay.
+
+### Configure shared questions
+
+Use `/department/selfserve/questions` with:
+
+- `department = 0`
+- `lane = 0`
+- `product = 0`
+
+Once shared questions exist, `selfserve_wash_flow` prefers them over legacy lane/product question rows.
+
+### Add machine-type-specific conditions, rules, and tasks
+
+Recommended pattern:
+
+1. Keep questions shared.
+2. Define one or more conditions per machine type.
+3. Attach rules that reference shared questions or nested conditions.
+4. Gate tasks with `condition_id`.
+5. Put `MACHINE` on the task that should allow relay enable.
+
+### Understand webhook and STOP interaction
+
+- `synchronizeSession()` can enable the machine relay before the physical machine has started.
+- `/relay/button/press/post` is the authoritative machine-start signal. That is the point where the session becomes `MACHINE_STARTED` and the lane wash timer is initialized if needed.
+- `STOP` is the point where billing is finalized.
+- `selfserve_lane_invoice_t::invoice()` bills `ceil(elapsedWashTime / 60)` units of the configured minute product.
+- The current implementation creates the order with system user id `2285`.
+- `completeLatestSessionForLane()` stores the final `order_id` on the session when STOP can provide it.
+
+### Billing prerequisites
+
+Billing on STOP depends on:
+
+- self-serve minute product config being set
+- lane status being `OCCUPIED`
+- lane customer number being set
+- lane license plate being set
+- a positive elapsed wash time
+
+Relevant config:
+
+- `selfserve.enabled`
+- `selfserve.minute_product`
+- department variable `selfserve_enabled`
+
+## Troubleshooting
+
+### `allowed` is always `false`
+
+Check all three eligibility requirements:
+
+1. Every visible question must have an answer.
+2. The lane must have `relay_machine_id`.
+3. At least one active task must expose `MACHINE`.
+
+Also check whether the machine-type-specific tasks and conditions exist for the lane's `machine_type_id`. If they do not, the flow may fall back to legacy lane/product data instead.
+
+### The relay is not enabled after an answer update
+
+Common causes:
+
+- `synchronizeSession()` was called with `activateMachine = false`
+- the active task list does not expose `MACHINE`
+- the lane is `CLOSED`, `MAINTENANCE`, or `FAULT`
+- `relay_machine_id` is missing on the lane
+
+Inspect the latest summary and look for:
+
+- `allowed_services`
+- `session.machine_relay_enabled`
+- a `MACHINE_RELAY_ENABLED` event
+
+### The machine button webhook returns `404`
+
+This means the route could not find an active self-serve session for the resolved lane.
+
+Check:
+
+- the lane id resolved from the scanner department is correct
+- the vehicle registration matches the session registration
+- the session was synchronized before the button press
+
+If you pass `reg` and no open session exists yet, `recordMachineStartWebhook()` will attempt a non-activating synchronize first. If that still cannot resolve a session, inspect the lane and answer data.
+
+### The summary does not show questions, tasks, or events you expected
+
+Check:
+
+- whether you are reading by `session_id` or by `lane_id` plus `reg`
+- whether a newer session exists for the same lane and vehicle
+- whether the lane changed machine type after the session was created
+- whether the question or task was visible at the moment the session was synchronized
+
+Remember that session answers and tasks are snapshots, not live joins.
+
+### The webhook fails in a multi-lane department
+
+If the plate scanner belongs to a department with more than one lane, you must include `lane_id` in the webhook request.
+
+### STOP did not create an `order_id` on the session
+
+Check:
+
+- the minute product configuration
+- lane customer number and registration number
+- that wash time was greater than zero
+- whether `invoice()` threw before completion
+
+The STOP flow tries not to let relay or session-completion errors block the lane reset. If billing failed earlier, the lane may still reset without a final `order_id`.
+
+## Operational Notes
+
+- Runtime schema changes are additive and lazy through `classes/selfserve_schema_bootstrap.php`.
+- The session tables are safe to create idempotently from runtime flows because the project does not use a centralized migration runner.
+- Machine relay control is delegated to the Shelly module through `selfserve_lane_relay_controller_t`.
+- Manual relay enable is still gated by the lane cache, while force enable and force disable bypass that gate.
+
+## Suggested Usage Pattern
+
+For new implementations, the intended setup is:
+
+1. Define one or more reusable machine types.
+2. Attach each self-serve lane to a machine type.
+3. Keep questions shared.
+4. Put machine-specific logic in machine-type conditions, rules, and tasks.
+5. Let vehicle answer changes call `synchronizeSession()`.
+6. Let the physical button or PLC call `/relay/button/press/post`.
+7. Let the normal STOP lane command complete billing and close the session.