The Master Machine Lifecycle
One canonical state machine for every Kiosk-X machine, from capital to
decommission, across every installation flavor. Model: app/lifecycle.py.
API: app/routes/lifecycle.py. Tests: tests/test_machine_lifecycle.py.
Live proof: scripts/lifecycle_flavors_live.py.
Scope note:
docs/lifecycle-matrix.mdis the device-protocol matrix (first boot, dispense, OTA per Reyeah/ZHZN dialect). This document is the business lifecycle — where a machine is in its LIFE, not whether its motors spin.
The canonical state machine
preorder → ordered → production → shipped → delivered
→ installing → onboarding → live
live ⇄ maintenance (claims/repair)
live ⇄ relocating (site moves)
any non-terminal → retired | resold | returned (end of life)
- Forward-only with skip-fill on the acquisition chain: an in-stock unit
can jump
ordered → shipped; every skipped stage lands on the event trail asstage.passed(mirrors the manufacturer pipeline's webhook semantics). - Loops are explicit:
maintenanceandrelocatingonly enter fromliveand return tolive(relocating → onboardingis legal when a move needs re-verification). - Terminals close a TENURE, not a serial: a retired/resold serial drops
to the unclaimed pool; the next operator's claim archives the old tenure
(
machineNo#startedAt) and starts a fresh record atonboarding. That IS the resale/transfer flow. - Illegal moves 409 and name the legal targets. Admin
forceexists and is recorded on the trail as a forced correction — never silent. - Stage changes and ops events (Nayax bind, restock, OTA report, claims,
relocation) append to a per-machine event trail (capped at 200), and
operator actions also land in
STORE.audit.
Generated diagram
Generated from the live transition tables by app/graph_spec.py — do not edit; run python scripts/gen_state_machine_docs.py to regenerate (CI enforces freshness).
- Pre-live chain is forward-only with skip-fill (forward jumps allowed; skipped stages get pass-through events).
- Any non-terminal stage may exit to a terminal stage (retired, resold, returned).
- Terminals end the tenure; a reclaimed serial starts a fresh tenure.
The same spec (JSON graph + Mermaid) is served live at
GET /api/v1/meta/state-machines/machine_lifecycle, and the super-admin
console renders it with per-stage live counts. Step replay of any machine's
event trail: GET /api/v1/machines/{no}/lifecycle/replay.
Protection claims (maintenance linkage)
Generated from the live transition tables by app/graph_spec.py — do not edit; run python scripts/gen_state_machine_docs.py to regenerate (CI enforces freshness).
- Opening a claim moves a live machine to maintenance; resolve/deny returns it to live once no claim stays open.
Consumption, not duplication
The DFY deal stage machine (app/dfy.py) and the manufacturer order
pipeline (app/dfy_manufacturing.py, deal["manufacturing"]) stay the
source of truth for their own flows. lifecycle.sync_from_deal maps them
onto canonical stages idempotently and forward-only:
| Fragment | Canonical stage |
|---|---|
deal pre_ordered … signed |
preorder |
deal ready_to_ship / mfg placed, confirmed |
ordered |
mfg in_production, qa |
production |
deal/mfg shipped |
shipped |
mfg delivered |
delivered |
deal installing / mfg installed |
installing / onboarding |
deal/mfg live |
live |
A stale fragment can never rewind the canonical stage. The record migrates
from the DFY placeholder pin to the real serial when the wizard links it
(serial.linked event). Machines that predate this model backfill
lazily on first touch (a claimed machine with settled vends backfills at
live; otherwise at onboarding).
Installation flavors
Every record carries the flavor that brought the machine onto the platform, with per-flavor entry points and next actions:
| Flavor | Entry point | Detection |
|---|---|---|
dfy |
deal → preorder/ordered at pin pre-provision |
autopilot/placement capability on |
self_install |
deal (order) → ordered; wizard install |
installMode self, autopilot off |
delegated |
deal → install work order (dfy_install) |
installMode team / work order present |
existing |
claim → onboarding (order/ship never happened on our books) |
claimed serial with no deal |
concierge |
any of the above, team-driven | dfy_modes concierge active (deal or machine) |
Hardware variant (reyeah/zhzn) rides the record too: from the deal's
install.hardwareVariant, from the ZHZN gateway tagging (/zhzn/* traffic),
or inferred from the Reyeah /apk/apkUp build report.
Protection-plan offer triggers (per flavor)
Crossing a flavor's trigger stage opens the Machine Protection Plan
offer (app/protection_offer.py, docs/protection-plan-coverage.md):
dfy/concierge/delegated at delivered (exposure starts when
freight lands, before install completes); self_install/existing at
live (onboarding completion — the guaranteed in-app moment; existing
machines never had a delivery on our books). Skip-fill jumps that pass the
trigger still fire; loop returns (maintenance → live,
relocating → live) never do. Offers, acceptances, declines, and
signatures all land on the machine's event trail
(protection.offered/.accepted/.declined/.signed), and machines already
covered / mid-signature / recently declined are never re-asked.
Onboarding checks (flavor-agnostic first boot)
onboarding → live auto-advances when ALL pass (any lifecycle read
reconciles): claimed (not the unclaimed pool), online (APK/agent
pointed at the Kiosk-X cloud), paymentsReady (a real reader binding or a
supported live QR configuration), planogramLoaded (stock on a usable aisle), boardCanDispense,
addressConfirmed, and installationConfirmed. Follow the
address and installation guide for the last
two. Production refuses forced completion; force is restricted to full
administrators in a non-production sandbox. Resolve incomplete readiness,
address and on-site evidence through the normal installation workflow. Existing
live machines retain their stage and hardware-based sellability while missing
location evidence is displayed. Completing a move clears old placement
evidence and returns to onboarding until the new site is confirmed.
Lifecycle × flavor matrix — BEFORE → AFTER
Legend: ✅ handled · 🟡 partial/fragmented · ❌ missing.
| Stage \ Flavor | DFY/autopilot | Self-install | Delegated | Existing (Reyeah/ZHZN) | Concierge |
|---|---|---|---|---|---|
| preorder | 🟡 deal stage only → ✅ | 🟡 → ✅ | 🟡 → ✅ | n/a | 🟡 → ✅ |
| ordered/production | 🟡 mfg block on deal (no machine view) → ✅ consumed | 🟡 → ✅ | 🟡 → ✅ | n/a | 🟡 → ✅ |
| shipped/delivered | 🟡 deal shipped (no delivered) → ✅ |
🟡 → ✅ | 🟡 → ✅ | n/a | 🟡 → ✅ |
| installing | 🟡 deal installing → ✅ |
🟡 wizard → ✅ | 🟡 WO states unstitched → ✅ stitched (source.workOrderId) |
n/a | 🟡 → ✅ |
| onboarding | 🟡 install checks (deal-only) → ✅ | 🟡 → ✅ | 🟡 → ✅ | ❌ nothing after claim → ✅ checklist + auto-advance | 🟡 → ✅ |
| live | 🟡 dfy.state/online only → ✅ |
🟡 online only → ✅ | 🟡 → ✅ | 🟡 online only → ✅ | 🟡 → ✅ |
| maintenance (claims) | ❌ no claim workflow → ✅ claims: open→approved→in_repair→resolved/denied + repair WO | ❌ → ✅ | ❌ → ✅ | ❌ → ✅ | ❌ → ✅ |
| relocating | ❌ advisory only (Yield Scout) → ✅ relocate start/complete mutation | ❌ → ✅ | ❌ → ✅ | ❌ → ✅ | ❌ → ✅ |
| retired/resold/returned | 🟡 bare decommission → ✅ retire/resell/return + fresh tenure on re-claim | 🟡 → ✅ | 🟡 → ✅ | 🟡 → ✅ | 🟡 → ✅ |
Before: presence lived on machine.status (online/offline),
acquisition on the DFY deal, manufacturing on deal["manufacturing"],
install progress on work orders, EOL as a one-shot decommission — no single
answer to "where is this machine in its life?", and nothing at all for
existing-machine onboarding progress, claims, or relocation.
API surface
| Endpoint | What |
|---|---|
GET /api/v1/machines/{no}/lifecycle |
full view: stage, flavor, rail, legal next, next actions, checks, open claims, event trail (reads reconcile fragments) |
POST /api/v1/machines/{no}/lifecycle/transition |
explicit transition; force admin-only, audited |
GET/POST /api/v1/machines/{no}/lifecycle/onboarding[/complete] |
first-boot checklist / explicit go-live |
POST /api/v1/machines/{no}/lifecycle/relocate[/complete] |
relocation loop; completion writes the new location |
POST /api/v1/machines/{no}/lifecycle/retire |
mode: retired | resold | returned (decommissions + closes tenure) |
POST/GET /api/v1/machines/{no}/claims, GET /api/v1/claims, POST /api/v1/claims/{id}/advance |
protection-plan claims (coverage-gated via app/protection.py; opens/closes a protection_repair work order) |
GET/POST /api/v1/machines/{no}/protection-offer[/accept\|/decline] |
the lifecycle-triggered protection-plan offer at delivery / install completion (app/protection_offer.py; consent-gated accept → Documenso e-sign → rider activates) |
GET /api/v1/fleet/lifecycle |
stage × flavor rollup + per-machine rows |
Machine payloads (GET /api/v1/machines[...]) embed a compact
lifecycle block (stage, flavor, labels, hardware, terminal).
Persistence & concurrency posture
Stores dfy_lifecycle / dfy_lifecycle_claims live on the FLEET tier,
reset-proof, riding the existing dfy_ peer-apply dispatch (registered
into dfy.DFY_STORES and appended to persistence.FLEET_STORES at import —
zero edits to app/persistence.py, same pattern as dfy_superadmin).
Hooks (on_machine_registered, on_machine_decommissioned,
on_nayax_bound, on_restock, ZHZN hardware tagging) never raise into
their host flows.
Operator surfaces
- Web console:
components/LifecycleCard.tsxon machine detail (stage rail, checklist, claims, relocate/retire actions, event trail) vialib/lifecycle.ts. - Operator X (Flutter):
screens/lifecycle.dart—MachineLifecycleCardon machine detail +FleetLifecycleScreen(stage × flavor rollup) registered as thelifecycleroute under Fleet innav_registry.dart.
Proof
tests/test_machine_lifecycle.py— 25 tests: FSM guarantees, all five flavors (Reyeah AND ZHZN for existing), manufacturer-pipeline consumption, placeholder→serial migration, claims linkage, relocation, EOL + re-claim tenure, fleet rollup, payload blocks, tenant isolation, reset-proofness.scripts/lifecycle_flavors_live.py— drives one machine per flavor through its lifecycle against the live stack and prints the evidence ledger.
Live-run ledger — api.kiosk-x.ai, run 8657613949 (2026-08-12)
| Cell | Status | Evidence |
|---|---|---|
| existing-reyeah | PASS | 866908657613950: first-contact → claim (onboarding) → Nayax + restock → LIVE auto-advance; 4-event trail |
| relocate | PASS | live → relocating → live; machine.location updated to the destination |
| claims-gate | PASS | claim without coverage → 409 with enroll pointer (full covered-claim loop proven in pytest) |
| fsm-guard | PASS | live → shipped refused: 409 naming the legal targets |
| eol-resale | PASS | resold → serial unclaimed → second operator re-claims → FRESH tenure at onboarding |
| rollup | PASS | operator rollup byStage={live:1}, byFlavor={existing:1} |
| dfy/delegated | PASS | admin rollup: 54 machines; 7 deal-driven (self_install:4, delegated:3) consumed onto the canonical lifecycle from real deals on this stack |
| self-install entry | PASS | deal dfy_ab38808967: pre-order paid via real Stripe TEST checkout (4242 card, scripts/dfy_stripe_checkout.py) → order=paid, stage pre_ordered |
| concierge entry | PASS | same deal: concierge ACTIVE ($99/mo) ⇒ flavor concierge at pin time |
| existing-zhzn | PASS | LCF-ZH-8657773248: ZHZN register → claim → LIVE, hardware tagged zhzn (2026-08-12 run 8657773248). KIOSKX_ZHZN_DEVICE_SECRET now CI-bootstrapped into kiosk-x-secrets + wired in the deployment (kube-infra b534dd65) |
Deal flavors past the pre-order checkpoint (lease signature → pin →
manufacturing consumption) run through real Documenso signatures by design;
that stretch is proven end-to-end in tests/test_machine_lifecycle.py
(deal + manufacturing sync tests) and by the 7 deal-driven machines already
on the live rollup above.