Architecture 09 Architecture Decisions - lukislp/mowdeck GitHub Wiki

9. Architecture decisions

All decisions are recorded as ADRs in the ADR index.

ADR Decision
0001 OpenFGA instead of self-built authorization
0002 Self-built Identity service in FastAPI
0003 SvelteKit for the web app
0004 Monorepo with a uv workspace
0005 AGPL-3.0 and Apache-2.0 licensing
0006 PostgreSQL for all persistence
0007 NATS JetStream as event bus
0008 Traefik as gateway
0009 Command and event envelopes, subject naming and de-duplication
0010 Python 3.13 for all services and libraries
0011 Separate GPL-3.0 package for the PyMammotion adapter
0012 Mower control interface and safety rules
0013 Device-Gateway registry, sharding and per-mower concurrency
0014 Authorization model, tuple ownership and the mowdeck-authz client
0015 Tokens, sessions and key rotation
0016 Fleet commands and authorization per command kind
0017 Telemetry storage and retention
0018 Realtime WebSocket protocol
0019 Mammotion adapter: cloud first, BLE via ESPHome proxy, manual drive only over BLE, secret references, exact pins
0020 Web app architecture
0021 Browser authentication flow
0022 Notifications: rules derived from telemetry in the service, subscribers re-checked with can_view, outbox with SKIP LOCKED, channel plug-ins
0023 Map rendering and projection: Leaflet with a metric grid in local metres (CRS.Simple) by default, WGS84 only with configured tiles, conversion in the browser checked against golden vectors, Geoman deferred
0024 Scheduler: recurrence on zoneinfo, firing through Fleet's fleet.commands.issue on behalf of the owner with a re-check of can_control, SKIP LOCKED ticker, Open-Meteo rain pause behind a weather port
0025 Home Assistant integration: pure API client without shared Python code, API token exchange, push over the Realtime WebSocket with polling fallback, dedicated HACS repository published from the monorepo
0026 Manual driving: session opened once over the Realtime WebSocket and recorded by Fleet (fleet.drive.start/stop, can_drive, one open session per mower), frames published by Realtime as ManualDrive commands (rate limited, ttl_ms 1000), the Device-Gateway controller stays the only safety authority
0027 Health history computed incrementally in Telemetry's state consumer (UTC days, blade hours heuristic, blade reset, telemetry.blade_hours.threshold), theft alarms in Notifications (lift, unexpected dock leave, geofence exit; critical, bypass quiet hours, acknowledge silences an hour)
0028 Plans as data seeded from infra/entitlements/plans.yaml, MOWDECK_ENTITLEMENTS_BILLING_ENABLED (off: every account unlimited), hierarchical feature keys from module descriptors, entitlements.check with a per-account snapshot cache in the kit client (no answer: the local billing switch decides), grace and expired keep only view and emergency stop, operator endpoints on platform:main
0029 Helm chart and release pipeline: one umbrella chart with a template per service and the Compose settings under the same names, NATS and OpenFGA as optional official subcharts, PostgreSQL through CloudNativePG (one database and role per service) or external, migrations as hooks (external database) or per-revision Jobs (in-cluster) with pods waiting for their schema and model, Ingress resources instead of Traefik CRDs, multi-arch images through QEMU and the chart as an OCI artifact in GHCR from tags only
0030 Mowing patterns as plug-ins in the pure library mowdeck-patterns (Shapely, entry point group mowdeck.patterns, settings as JSON Schema), one validation chain for every plan, chains and templates, plans inline in StartMow.spot_plan (encoded segments, SHA-256 hash, optional native job), execution by capabilities.custom_paths or the native job, Fleet gates spot_mowing and records the hash, coverage from Telemetry's track over tracks.positions.query, the Planner owns only templates and a snapshot directory
0031 Billing as an optional service (Compose profile billing, Helm services.billing.enabled, not started when disabled; the web app probes GET /billing/status), PaymentProvider with a Stripe adapter (Checkout and customer portal, one customer per account, prices as data in infra/billing/prices.yaml) and a fake, webhooks verified by signature and processed once (webhook_events ledger, stale events ignored), Stripe statuses mapped to plan statuses set in Entitlements over entitlements.plan.set (source=billing), grace from the end of the last paid period, owner only (can_manage_billing)
0032 Live camera over Agora: the browser joins the Mammotion camera channel directly with the Agora Web SDK (no media through MowDeck); the adapter fetches the stream token, the Device-Gateway answers gateway.mower.<id>.camera.session, Fleet checks can_view and the plan feature camera and audits; the Agora domains enter the CSP only with MOWDECK_WEB_CAMERA_ENABLED=true
0033 Vision-assisted recovery: a vision model (any family through LiteLLM; per-user providers with encrypted keys, an optional installation model behind ai_recover_hosted) proposes at most six drive steps, a user with can_drive approves, the service drives them through fleet.drive.* on the approver's behalf (origin: vision) at 5 Hz with ttl_ms 1000 and blades off, aborts on emergency stop, sensor stop, geofence, missing state or cancellation, verifies, at most three rounds; drive_recoverable errors are the only ones the controller lets drive frames through
0034 Linked cloud accounts: Fleet stores Mammotion accounts per MowDeck account (password AES-256-GCM under MOWDECK_CREDENTIAL_ENCRYPTION_KEY), the Device-Gateway logs in and lists the devices on gateway.cloud.discover, cloud mowers reference a linked account and its device, one PyMammotion session per account shared by its mowers and the discovery, one physical mower (IoT id) in one MowDeck account installation-wide (409 mower-already-registered), old cloud mowers moved on start

Mirrored from docs/architecture/09-architecture-decisions.md at commit cb46ac5074dee783a333b60ecc6a9b05a1c7cf33. Edit the file in the repository, not this wiki page.