Files
sta/specs/001-modbus-relay-control/tasks.md
Lucien Cartier-Tilet 5f0aaacb74 chore(config): configure CORS and update frontend URL in development settings
Set up CORS policy to allow requests from frontend development server and
update development.yaml with proper frontend origin URL configuration.

Ref: T011 (specs/001-modbus-relay-control)
2026-01-22 00:57:11 +01:00

50 KiB

Implementation Tasks: Modbus Relay Control System

Feature: 001-modbus-relay-control Total Tasks: 102 tasks across 9 phases MVP Delivery: Phase 4 complete (Task 57) Parallelizable Tasks: 39 tasks marked with [P] Approach: Type-Driven Development (TyDD) + Test-Driven Development (TDD), Backend API first


Phase 1: Setup & Foundation (0.5 days) DONE

Purpose: Initialize project dependencies and directory structure

  • T001 [Setup] [TDD] Add Rust dependencies to Cargo.toml

    • Add: tokio-modbus = { version = "0.17.0", default-features = false, features = ["tcp"] }, sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }, mockall = "0.13", async-trait = "0.1"
    • Test: cargo check passes
    • Complexity: Low | Uncertainty: Low
  • T002 [P] [Setup] [TDD] Create module structure in src/

    • Create: src/domain/, src/application/, src/infrastructure/, src/presentation/
    • Test: Module declarations compile without errors
    • Complexity: Low | Uncertainty: Low
  • T003 [P] [Setup] [TDD] Update settings.rs with Modbus configuration

    • Add ModbusSettings struct with host, port, slave_id, timeout_secs fields
    • Add RelaySettings struct with label_max_length field
    • Update Settings struct to include modbus and relay fields
    • Test: Settings loads from settings/base.yaml with test Modbus config
    • Complexity: Low | Uncertainty: Low
  • T004 [P] [Setup] [TDD] Create settings/base.yaml with Modbus defaults

    • Add modbus section: host: "192.168.0.200", port: 502, slave_id: 0, timeout_secs: 5
    • Add relay section: label_max_length: 8
    • Test: Settings::new() loads config without errors
    • Complexity: Low | Uncertainty: Low
  • T005 [P] [Setup] [TDD] Add SQLite schema file

    • Create infrastructure/persistence/schema.sql with relay_labels table
    • Table: relay_labels (relay_id INTEGER PRIMARY KEY CHECK(relay_id BETWEEN 1 AND 8), label TEXT NOT NULL CHECK(length(label) <= 50))
    • Test: Schema file syntax is valid SQL
    • Complexity: Low | Uncertainty: Low
  • T006 [P] [Setup] [TDD] Initialize SQLite database module

    • Create infrastructure/persistence/mod.rs
    • Create infrastructure/persistence/sqlite_repository.rs with SqliteRelayLabelRepository struct
    • Implement SqliteRelayLabelRepository::new(path) using SqlitePool
    • Test: SqliteRelayLabelRepository::in_memory() creates in-memory DB with schema
    • Complexity: Medium | Uncertainty: Low
  • T007 [P] [Setup] [TDD] Add frontend project scaffolding

    • Create frontend/ directory with Vite + Vue 3 + TypeScript
    • Run: npm create vite@latest frontend -- --template vue-ts
    • Install: axios, @types/node
    • Test: npm run dev starts frontend dev server
    • Complexity: Low | Uncertainty: Low
  • T008 [P] [Setup] [TDD] Generate TypeScript API client from OpenAPI

    • Add poem-openapi spec generation in startup.rs
    • Generate frontend/src/api/client.ts from OpenAPI spec
    • Test: TypeScript client compiles without errors
    • Complexity: Medium | Uncertainty: Medium
    • Note: May need manual adjustments to generated code

Phase 0.5: CORS Configuration & Production Security (0.5 days)

Purpose: Replace permissive Cors::new() with configurable production-ready CORS

⚠️ TDD CRITICAL: Write failing tests FIRST for every configuration and function

  • T009 [P] [Setup] [TDD] Write tests for CorsSettings struct

    • Test: CorsSettings deserializes from YAML correctly ✓
    • Test: Default CorsSettings has empty allowed_origins (restrictive fail-safe) ✓
    • Test: CorsSettings with wildcard origin deserializes correctly ✓
    • Test: Settings::new() loads cors section from development.yaml ✓
    • Test: CorsSettings with partial fields uses defaults ✓
    • File: backend/src/settings.rs (in tests module)
    • Complexity: Low | Uncertainty: Low
    • Tests Written: 5 tests (cors_settings_deserialize_from_yaml, cors_settings_default_has_empty_origins, cors_settings_with_wildcard_deserializes, settings_loads_cors_section_from_yaml, cors_settings_deserialize_with_defaults)
  • T010 [P] [Setup] [TDD] Add CorsSettings struct to settings.rs

    • Struct fields: allowed_origins: Vec<String>, allow_credentials: bool, max_age_secs: i32
    • Implement Default with restrictive settings: allowed_origins: vec![], allow_credentials: false, max_age_secs: 3600
    • Add #[derive(Debug, serde::Deserialize, Clone)] to struct
    • Add #[serde(default)] attribute to Settings.cors field
    • Update Settings struct to include pub cors: CorsSettings
    • File: backend/src/settings.rs
    • Complexity: Low | Uncertainty: Low
  • T011 [Setup] [TDD] Update development.yaml with permissive CORS settings

    • Add cors section with allowed_origins: ["*"], allow_credentials: false, max_age_secs: 3600
    • Update frontend_url from http://localhost:3000 to http://localhost:5173 (Vite default port)
    • Test: cargo run loads development config without errors
    • File: backend/settings/development.yaml
    • Complexity: Low | Uncertainty: Low
  • T012 [P] [Setup] [TDD] Create production.yaml with restrictive CORS settings

    • Add cors section with allowed_origins: ["REACTED"], allow_credentials: true, max_age_secs: 3600
    • Add frontend_url: "https://REDACTED"
    • Add production-specific application settings (protocol: https, host: 0.0.0.0)
    • Test: Settings::new() with APP_ENVIRONMENT=production loads config
    • File: backend/settings/production.yaml
    • Complexity: Low | Uncertainty: Low
  • T013 [Setup] [TDD] Write tests for build_cors() function

    • Test: build_cors() with wildcard origin creates permissive Cors (allows any origin)
    • Test: build_cors() with specific origin creates restrictive Cors
    • Test: build_cors() with credentials=true and wildcard origin returns error (browser constraint violation)
    • Test: build_cors() sets correct methods (GET, POST, PUT, PATCH, DELETE, OPTIONS)
    • Test: build_cors() sets correct headers (content-type, authorization)
    • Test: build_cors() sets max_age from settings
    • File: backend/src/startup.rs (in tests module)
    • Complexity: Medium | Uncertainty: Low
  • T014 [Setup] [TDD] Implement build_cors() free function in startup.rs

    • Function signature: fn build_cors(settings: &CorsSettings) -> Cors
    • Validate: if allow_credentials=true AND allowed_origins contains "*", panic with clear error message
    • Iterate over allowed_origins and call cors.allow_origin() for each
    • Hardcode methods: vec![Method::GET, Method::POST, Method::PUT, Method::PATCH, Method::DELETE, Method::OPTIONS]
    • Hardcode headers: vec![header::CONTENT_TYPE, header::AUTHORIZATION]
    • Set allow_credentials from settings
    • Set max_age from settings.max_age_secs
    • Add structured logging: tracing::info!(allowed_origins = ?settings.allowed_origins, allow_credentials = settings.allow_credentials, "CORS middleware configured")
    • File: backend/src/startup.rs
    • Complexity: Medium | Uncertainty: Low

    Pseudocode:

    fn build_cors(settings: &CorsSettings) -> Cors {
        // Validation
        if settings.allow_credentials && settings.allowed_origins.contains(&"*".to_string()) {
            panic!("CORS misconfiguration: wildcard origin not allowed with credentials=true");
        }
    
        let mut cors = Cors::new();
    
        // Configure origins
        for origin in &settings.allowed_origins {
            cors = cors.allow_origin(origin.as_str());
        }
    
        // Hardcoded methods (API-specific)
        cors = cors.allow_methods(vec![
            Method::GET, Method::POST, Method::PUT,
            Method::PATCH, Method::DELETE, Method::OPTIONS
        ]);
    
        // Hardcoded headers (minimum for API)
        cors = cors.allow_headers(vec![
            header::CONTENT_TYPE,
            header::AUTHORIZATION,
        ]);
    
        // Configure from settings
        cors = cors
            .allow_credentials(settings.allow_credentials)
            .max_age(settings.max_age_secs);
    
        tracing::info!(
            target: "backend::startup",
            allowed_origins = ?settings.allowed_origins,
            allow_credentials = settings.allow_credentials,
            max_age_secs = settings.max_age_secs,
            "CORS middleware configured"
        );
    
        cors
    }
    
  • T015 [Setup] [TDD] Replace Cors::new() with build_cors() in middleware chain

    • In From<Application> for RunnableApplication, replace .with(Cors::new()) with .with(build_cors(&value.settings.cors))
    • Add necessary imports: poem::http::{Method, header}
    • Ensure CORS is applied after rate limiting (order: RateLimit → CORS → Data)
    • Test: Integration test verifies CORS headers are present
    • File: backend/src/startup.rs (line ~86)
    • Complexity: Low | Uncertainty: Low
  • T016 [P] [Setup] [TDD] Write integration tests for CORS headers

    • Test: OPTIONS preflight request to /api/health returns correct CORS headers
    • Test: GET /api/health with Origin header returns Access-Control-Allow-Origin header
    • Test: Preflight response includes Access-Control-Max-Age matching configuration
    • Test: Response includes Access-Control-Allow-Credentials when configured
    • Test: Response includes correct Access-Control-Allow-Methods (GET, POST, PUT, PATCH, DELETE, OPTIONS)
    • File: backend/tests/integration/cors_test.rs (new file)
    • Complexity: Medium | Uncertainty: Low

Checkpoint: CORS configuration complete, production-ready security with environment-specific settings


Phase 2: Domain Layer - Type-Driven Development (1 day)

Purpose: Build domain types with 100% test coverage, bottom-to-top

⚠️ TDD CRITICAL: Write failing tests FIRST for every type, then implement

  • T017 [US1] [TDD] Write tests for RelayId newtype

    • Test: RelayId::new(1) → Ok(RelayId(1))
    • Test: RelayId::new(8) → Ok(RelayId(8))
    • Test: RelayId::new(0) → Err(InvalidRelayId)
    • Test: RelayId::new(9) → Err(InvalidRelayId)
    • Test: RelayId::as_u8() returns inner value
    • File: src/domain/relay.rs
    • Complexity: Low | Uncertainty: Low
  • T018 [US1] [TDD] Implement RelayId newtype with validation

    • #[repr(transparent)] newtype wrapping u8
    • Constructor validates 1..=8 range
    • Implement Display, Debug, Clone, Copy, PartialEq, Eq
    • File: src/domain/relay.rs
    • Complexity: Low | Uncertainty: Low
  • T019 [P] [US1] [TDD] Write tests for RelayState enum

    • Test: RelayState::On → serializes to "on"
    • Test: RelayState::Off → serializes to "off"
    • Test: Parse "on"/"off" from strings
    • File: src/domain/relay.rs
    • Complexity: Low | Uncertainty: Low
  • T020 [P] [US1] [TDD] Implement RelayState enum

    • Enum: On, Off
    • Implement Display, Debug, Clone, Copy, PartialEq, Eq, serde::Serialize/Deserialize
    • File: src/domain/relay.rs
    • Complexity: Low | Uncertainty: Low
  • T021 [US1] [TDD] Write tests for Relay aggregate

    • Test: Relay::new(RelayId(1), RelayState::Off, None) creates relay
    • Test: relay.toggle() flips state
    • Test: relay.turn_on() sets state to On
    • Test: relay.turn_off() sets state to Off
    • File: src/domain/relay.rs
    • Complexity: Low | Uncertainty: Low
  • T022 [US1] [TDD] Implement Relay aggregate

    • Struct: Relay { id: RelayId, state: RelayState, label: Option }
    • Methods: new(), toggle(), turn_on(), turn_off(), state(), label()
    • File: src/domain/relay.rs
    • Complexity: Low | Uncertainty: Low
  • T023 [P] [US4] [TDD] Write tests for RelayLabel newtype

    • Test: RelayLabel::new("Pump") → Ok
    • Test: RelayLabel::new("A".repeat(50)) → Ok
    • Test: RelayLabel::new("") → Err(EmptyLabel)
    • Test: RelayLabel::new("A".repeat(51)) → Err(LabelTooLong)
    • File: src/domain/relay.rs
    • Complexity: Low | Uncertainty: Low
  • T024 [P] [US4] [TDD] Implement RelayLabel newtype

    • #[repr(transparent)] newtype wrapping String
    • Constructor validates 1..=50 length
    • Implement Display, Debug, Clone, PartialEq, Eq
    • File: src/domain/relay.rs
    • Complexity: Low | Uncertainty: Low
  • T025 [US1] [TDD] Write tests for ModbusAddress type

    • Test: ModbusAddress::from(RelayId(1)) → ModbusAddress(0)
    • Test: ModbusAddress::from(RelayId(8)) → ModbusAddress(7)
    • File: src/domain/modbus.rs
    • Complexity: Low | Uncertainty: Low
  • T026 [US1] [TDD] Implement ModbusAddress type with From

    • #[repr(transparent)] newtype wrapping u16
    • Implement From with offset: user 1-8 → Modbus 0-7
    • File: src/domain/modbus.rs
    • Complexity: Low | Uncertainty: Low
  • T027 [US3] [TDD] Write tests and implement HealthStatus enum

    • Enum: Healthy, Degraded { consecutive_errors: u32 }, Unhealthy { reason: String }
    • Test transitions between states
    • File: src/domain/health.rs
    • Complexity: Medium | Uncertainty: Low

Checkpoint: Domain types complete with 100% test coverage


Phase 3: Infrastructure Layer (2 days)

Purpose: Implement Modbus client, mocks, and persistence

  • T028 [P] [US1] [TDD] Write tests for MockRelayController

    • Test: read_state() returns mocked state
    • Test: write_state() updates mocked state
    • Test: read_all() returns 8 relays in known state
    • File: src/infrastructure/modbus/mock_controller.rs
    • Complexity: Low | Uncertainty: Low
  • T029 [P] [US1] [TDD] Implement MockRelayController

    • Struct with Arc<Mutex<HashMap<RelayId, RelayState>>>
    • Implement RelayController trait with in-memory state
    • File: src/infrastructure/modbus/mock_controller.rs
    • Complexity: Low | Uncertainty: Low
  • T030 [US1] [TDD] Define RelayController trait

    • async fn read_state(&self, id: RelayId) → Result<RelayState, ControllerError>
    • async fn write_state(&self, id: RelayId, state: RelayState) → Result<(), ControllerError>
    • async fn read_all(&self) → Result<Vec<(RelayId, RelayState)>, ControllerError>
    • async fn write_all(&self, state: RelayState) → Result<(), ControllerError>
    • File: src/infrastructure/modbus/controller.rs
    • Complexity: Low | Uncertainty: Low
  • T031 [P] [US1] [TDD] Define ControllerError enum

    • Variants: ConnectionError(String), Timeout(u64), ModbusException(String), InvalidRelayId(u8)
    • Implement std::error::Error, Display, Debug
    • Use thiserror derive macros
    • File: src/infrastructure/modbus/error.rs
    • Complexity: Low | Uncertainty: Low
  • T032 [US1] [TDD] Write tests for ModbusRelayController

    • REQUIRES HARDWARE/MOCK: Integration test with tokio_modbus::test utilities
    • Test: Connection succeeds with valid config (Modbus TCP on port 502)
    • Test: read_state() returns correct coil value
    • Test: write_state() sends correct Modbus TCP command (no CRC needed)
    • File: src/infrastructure/modbus/modbus_controller.rs
    • Complexity: High → DECOMPOSED below
    • Uncertainty: High

T025: ModbusRelayController Implementation (DECOMPOSED)

Complexity: High → Broken into 6 sub-tasks Uncertainty: High Rationale: Nested Result handling, Arc synchronization, timeout wrapping Protocol: Native Modbus TCP (MBAP header, no CRC16 validation)

  • T025a [US1] [TDD] Implement ModbusRelayController connection setup

    • Struct: ModbusRelayController { ctx: Arc<Mutex>, timeout_duration: Duration }
    • Constructor: new(host, port, slave_id, timeout_secs) → Result<Self, ControllerError>
    • Use tokio_modbus::client::tcp::connect_slave()
    • File: src/infrastructure/modbus/modbus_controller.rs
    • Complexity: Medium | Uncertainty: Medium

    Pseudocode:

    pub struct ModbusRelayController {
        ctx: Arc<Mutex<tokio_modbus::client::Context>>,
        timeout_duration: Duration,
    }
    
    impl ModbusRelayController {
        pub async fn new(host: &str, port: u16, slave_id: u8, timeout_secs: u64)
            -> Result<Self, ControllerError>
        {
            use tokio_modbus::prelude::*;
    
            // Connect using native Modbus TCP protocol (port 502)
            let socket_addr = format!("{}:{}", host, port)
                .parse()
                .map_err(|e| ControllerError::ConnectionError(format!("Invalid address: {}", e)))?;
    
            let ctx = tcp::connect_slave(socket_addr, Slave(slave_id))
                .await
                .map_err(|e| ControllerError::ConnectionError(e.to_string()))?;
    
            Ok(Self {
                ctx: Arc::new(Mutex::new(ctx)),
                timeout_duration: Duration::from_secs(timeout_secs),
            })
        }
    }
    

    TDD Checklist (write these tests FIRST):

    • Test: new() with valid config connects successfully
    • Test: new() with invalid host returns ConnectionError
    • Test: new() stores correct timeout_duration
  • T025b [US1] [TDD] Implement timeout-wrapped read_coils helper

    • Private method: read_coils_with_timeout(addr: u16, count: u16) → Result<Vec, ControllerError>
    • Wrap ctx.read_coils() with tokio::time::timeout()
    • Handle nested Result: timeout → io::Error → Modbus Exception
    • Note: Modbus TCP uses MBAP header (no CRC validation needed)
    • File: src/infrastructure/modbus/modbus_controller.rs
    • Complexity: Medium | Uncertainty: Medium

    Pseudocode (CRITICAL PATTERN):

    async fn read_coils_with_timeout(&self, addr: u16, count: u16)
        -> Result<Vec<bool>, ControllerError>
    {
        use tokio::time::timeout;
    
        let ctx = self.ctx.lock().await;
    
        // tokio-modbus returns nested Results: Result<Result<T, Exception>, io::Error>
        // We must unwrap 3 layers: timeout → io::Error → Modbus Exception
    
        let result = timeout(self.timeout_duration, ctx.read_coils(addr, count))
            .await  // Result<Result<Result<Vec<bool>, Exception>, io::Error>, Elapsed>
            .map_err(|_| ControllerError::Timeout(self.timeout_duration.as_secs()))?  // Handle timeout
            .map_err(|e| ControllerError::ConnectionError(e.to_string()))?  // Handle io::Error
            .map_err(|e| ControllerError::ModbusException(format!("{:?}", e)))?;  // Handle Exception
    
        Ok(result)
    }
    

    TDD Checklist:

    • Test: read_coils_with_timeout() returns coil values on success
    • Test: read_coils_with_timeout() returns Timeout error when operation exceeds timeout
    • Test: read_coils_with_timeout() returns ConnectionError on io::Error
    • Test: read_coils_with_timeout() returns ModbusException on protocol error
  • T025c [US1] [TDD] Implement timeout-wrapped write_single_coil helper

    • Private method: write_single_coil_with_timeout(addr: u16, value: bool) → Result<(), ControllerError>
    • Similar nested Result handling as T025b
    • File: src/infrastructure/modbus/modbus_controller.rs
    • Complexity: Low | Uncertainty: Low

    Pseudocode:

    async fn write_single_coil_with_timeout(&self, addr: u16, value: bool)
        -> Result<(), ControllerError>
    {
        use tokio::time::timeout;
    
        let ctx = self.ctx.lock().await;
    
        timeout(self.timeout_duration, ctx.write_single_coil(addr, value))
            .await
            .map_err(|_| ControllerError::Timeout(self.timeout_duration.as_secs()))?
            .map_err(|e| ControllerError::ConnectionError(e.to_string()))?
            .map_err(|e| ControllerError::ModbusException(format!("{:?}", e)))?;
    
        Ok(())
    }
    

    TDD Checklist:

    • Test: write_single_coil_with_timeout() succeeds for valid write
    • Test: write_single_coil_with_timeout() returns Timeout on slow device
    • Test: write_single_coil_with_timeout() returns appropriate error on failure
  • T025d [US1] [TDD] Implement RelayController::read_state() using helpers

    • Convert RelayId → ModbusAddress (0-based)
    • Call read_coils_with_timeout(addr, 1)
    • Convert bool → RelayState
    • File: src/infrastructure/modbus/modbus_controller.rs
    • Complexity: Low | Uncertainty: Low

    Pseudocode:

    #[async_trait]
    impl RelayController for ModbusRelayController {
        async fn read_state(&self, id: RelayId) -> Result<RelayState, ControllerError> {
            let addr = ModbusAddress::from(id).as_u16();
            let coils = self.read_coils_with_timeout(addr, 1).await?;
    
            Ok(if coils[0] { RelayState::On } else { RelayState::Off })
        }
    }
    

    TDD Checklist:

    • Test: read_state(RelayId(1)) returns On when coil is true
    • Test: read_state(RelayId(1)) returns Off when coil is false
    • Test: read_state() propagates ControllerError from helper
  • T025e [US1] [TDD] Implement RelayController::write_state() using helpers

    • Convert RelayId → ModbusAddress
    • Convert RelayState → bool (On=true, Off=false)
    • Call write_single_coil_with_timeout()
    • File: src/infrastructure/modbus/modbus_controller.rs
    • Complexity: Low | Uncertainty: Low

    Pseudocode:

    async fn write_state(&self, id: RelayId, state: RelayState) -> Result<(), ControllerError> {
        let addr = ModbusAddress::from(id).as_u16();
        let value = matches!(state, RelayState::On);
        self.write_single_coil_with_timeout(addr, value).await
    }
    

    TDD Checklist:

    • Test: write_state(RelayId(1), RelayState::On) writes true to coil
    • Test: write_state(RelayId(1), RelayState::Off) writes false to coil
  • T025f [US1] [TDD] Implement RelayController::read_all() and write_all()

    • read_all(): Call read_coils_with_timeout(0, 8), map to Vec<(RelayId, RelayState)>
    • write_all(): Loop over RelayId 1-8, call write_state() for each
    • Add firmware_version() method (read holding register 0x9999, optional)
    • File: src/infrastructure/modbus/modbus_controller.rs
    • Complexity: Medium | Uncertainty: Low

    Pseudocode:

    async fn read_all(&self) -> Result<Vec<(RelayId, RelayState)>, ControllerError> {
        let coils = self.read_coils_with_timeout(0, 8).await?;
    
        let mut relays = Vec::new();
        for (idx, &coil_value) in coils.iter().enumerate() {
            let relay_id = RelayId::new((idx + 1) as u8)?;
            let state = if coil_value { RelayState::On } else { RelayState::Off };
            relays.push((relay_id, state));
        }
        Ok(relays)
    }
    
    async fn write_all(&self, state: RelayState) -> Result<(), ControllerError> {
        for i in 1..=8 {
            let relay_id = RelayId::new(i)?;
            self.write_state(relay_id, state).await?;
        }
        Ok(())
    }
    

    TDD Checklist:

    • Test: read_all() returns 8 relay states
    • Test: write_all(RelayState::On) turns all relays on
    • Test: write_all(RelayState::Off) turns all relays off

  • T034 [US1] [TDD] Integration test with real hardware (optional)

    • REQUIRES PHYSICAL DEVICE: Test against actual Modbus relay at configured IP
    • Skip if device unavailable, rely on MockRelayController for CI
    • File: tests/integration/modbus_hardware_test.rs
    • Complexity: Medium | Uncertainty: High
    • Note: Use #[ignore] attribute, run with cargo test -- --ignored
  • T035 [P] [US4] [TDD] Write tests for RelayLabelRepository trait

    • Test: get_label(RelayId(1)) → Option
    • Test: set_label(RelayId(1), label) → Result<(), RepositoryError>
    • Test: delete_label(RelayId(1)) → Result<(), RepositoryError>
    • File: src/infrastructure/persistence/label_repository.rs
    • Complexity: Low | Uncertainty: Low
  • T036 [P] [US4] [TDD] Implement SQLite RelayLabelRepository

    • Implement get_label(), set_label(), delete_label() using SQLx
    • Use sqlx::query! macros for compile-time SQL verification
    • File: src/infrastructure/persistence/sqlite_label_repository.rs
    • Complexity: Medium | Uncertainty: Low
  • T037 [US4] [TDD] Write tests for in-memory mock LabelRepository

    • For testing without SQLite dependency
    • File: src/infrastructure/persistence/mock_label_repository.rs
    • Complexity: Low | Uncertainty: Low
  • T038 [US4] [TDD] Implement in-memory mock LabelRepository

    • HashMap-based implementation
    • File: src/infrastructure/persistence/mock_label_repository.rs
    • Complexity: Low | Uncertainty: Low
  • T039 [US3] [TDD] Write tests for HealthMonitor service

    • Test: track_success() transitions Degraded → Healthy
    • Test: track_failure() transitions Healthy → Degraded → Unhealthy
    • File: src/application/health_monitor.rs
    • Complexity: Medium | Uncertainty: Low
  • T040 [US3] [TDD] Implement HealthMonitor service

    • Track consecutive errors, transition states per FR-020, FR-021
    • File: src/application/health_monitor.rs
    • Complexity: Medium | Uncertainty: Low

Checkpoint: Infrastructure layer complete with trait abstractions


Phase 4: US1 - Monitor & Toggle Relay States (MVP) (2 days)

Goal: View current state of all 8 relays + toggle individual relay on/off

Independent Test: GET /api/relays returns 8 relays, POST /api/relays/{id}/toggle changes state

Application Layer

  • T041 [US1] [TDD] Write tests for ToggleRelayUseCase

    • Test: execute(RelayId(1)) toggles relay state via controller
    • Test: execute() returns error if controller fails
    • File: src/application/use_cases/toggle_relay.rs
    • Complexity: Low | Uncertainty: Low
  • T042 [US1] [TDD] Implement ToggleRelayUseCase

    • Orchestrate: read current state → toggle → write new state
    • File: src/application/use_cases/toggle_relay.rs
    • Complexity: Low | Uncertainty: Low
  • T043 [P] [US1] [TDD] Write tests for GetAllRelaysUseCase

    • Test: execute() returns all 8 relays with states
    • File: src/application/use_cases/get_all_relays.rs
    • Complexity: Low | Uncertainty: Low
  • T044 [P] [US1] [TDD] Implement GetAllRelaysUseCase

    • Call controller.read_all(), map to domain Relay objects
    • File: src/application/use_cases/get_all_relays.rs
    • Complexity: Low | Uncertainty: Low

Presentation Layer (Backend API)

  • T045 [US1] [TDD] Define RelayDto in presentation layer

    • Fields: id (u8), state ("on"/"off"), label (Option)
    • Implement From for RelayDto
    • File: src/presentation/dto/relay_dto.rs
    • Complexity: Low | Uncertainty: Low
  • T046 [US1] [TDD] Define API error responses

    • ApiError enum with status codes and messages
    • Implement poem::error::ResponseError
    • File: src/presentation/error.rs
    • Complexity: Low | Uncertainty: Low

T039: Dependency Injection Setup (DECOMPOSED)

Complexity: High → Broken into 4 sub-tasks Uncertainty: Medium Rationale: Graceful degradation (FR-023), conditional mock/real controller

  • T039a [US1] [TDD] Create ModbusRelayController factory with retry and fallback

    • Factory function: create_relay_controller(settings, use_mock) → Arc
    • Retry 3 times with 2s backoff on connection failure
    • Graceful degradation: fallback to MockRelayController if all retries fail (FR-023)
    • File: src/infrastructure/modbus/factory.rs
    • Complexity: Medium | Uncertainty: Medium

    Pseudocode:

    pub async fn create_relay_controller(
        settings: &ModbusSettings,
        use_mock: bool,
    ) -> Arc<dyn RelayController> {
        if use_mock {
            tracing::info!("Using MockRelayController (test mode)");
            return Arc::new(MockRelayController::new());
        }
    
        // Retry 3 times with 2s backoff
        for attempt in 1..=3 {
            match ModbusRelayController::new(
                &settings.host,
                settings.port,
                settings.slave_id,
                settings.timeout_secs,
            ).await {
                Ok(controller) => {
                    tracing::info!("Connected to Modbus device on attempt {}", attempt);
                    return Arc::new(controller);
                }
                Err(e) => {
                    tracing::warn!(
                        attempt,
                        error = %e,
                        "Failed to connect to Modbus device, retrying..."
                    );
                    if attempt < 3 {
                        tokio::time::sleep(Duration::from_secs(2)).await;
                    }
                }
            }
        }
    
        // Graceful degradation: fallback to MockRelayController
        tracing::error!(
            "Could not connect to Modbus device after 3 attempts, \
             using MockRelayController as fallback"
        );
        Arc::new(MockRelayController::new())
    }
    

    TDD Checklist:

    • Test: use_mock=true returns MockRelayController immediately
    • Test: Successful connection returns ModbusRelayController
    • Test: Connection failure after 3 retries returns MockRelayController
    • Test: Retry delays are 2 seconds between attempts
    • Test: Logs appropriate messages for each connection attempt
  • T039b [US4] [TDD] Create RelayLabelRepository factory

    • Factory function: create_label_repository(db_path, use_mock) → Arc
    • If use_mock: return MockLabelRepository
    • Else: return SQLiteLabelRepository connected to db_path
    • File: src/infrastructure/persistence/factory.rs
    • Complexity: Low | Uncertainty: Low

    Pseudocode:

    pub fn create_label_repository(
        db_path: &str,
        use_mock: bool,
    ) -> Result<Arc<dyn RelayLabelRepository>, RepositoryError> {
        if use_mock {
            tracing::info!("Using MockLabelRepository (test mode)");
            return Ok(Arc::new(MockLabelRepository::new()));
        }
    
        let db = Database::new(db_path)?;
        Ok(Arc::new(SQLiteLabelRepository::new(db)))
    }
    

    TDD Checklist:

    • Test: use_mock=true returns MockLabelRepository
    • Test: use_mock=false returns SQLiteLabelRepository
    • Test: Invalid db_path returns RepositoryError
  • T039c [US1] [TDD] Wire dependencies in Application::build()

    • Determine test mode: cfg!(test) || env::var("CI").is_ok()
    • Call create_relay_controller() and create_label_repository()
    • Pass dependencies to RelayApi::new()
    • File: src/startup.rs
    • Complexity: Medium | Uncertainty: Low

    Pseudocode:

    impl Application {
        pub async fn build(settings: Settings) -> Result<Self, StartupError> {
            let use_mock = cfg!(test) || std::env::var("CI").is_ok();
    
            // Create dependencies
            let relay_controller = create_relay_controller(&settings.modbus, use_mock).await;
            let label_repository = create_label_repository(&settings.database.path, use_mock)?;
    
            // Create API with dependencies
            let relay_api = RelayApi::new(relay_controller, label_repository);
    
            // Build OpenAPI service
            let api_service = OpenApiService::new(relay_api, "STA API", "1.0.0")
                .server("http://localhost:8080");
    
            let ui = api_service.swagger_ui();
            let spec = api_service.spec();
    
            let app = Route::new()
                .nest("/api", api_service)
                .nest("/", ui)
                .at("/openapi.json", poem::endpoint::make_sync(move |_| spec.clone()));
    
            Ok(Self { app, settings })
        }
    }
    

    TDD Checklist:

    • Test: Application::build() succeeds in test mode
    • Test: Application::build() creates correct mock dependencies when CI=true
    • Test: Application::build() creates real dependencies when not in test mode
  • T039d [US1] [TDD] Register RelayApi in route aggregator

    • Add RelayApi to OpenAPI service
    • Tag: "Relays"
    • File: src/startup.rs
    • Complexity: Low | Uncertainty: Low

    TDD Checklist:

    • Test: OpenAPI spec includes /api/relays endpoints
    • Test: Swagger UI renders Relays tag

  • T048 [US1] [TDD] Write contract tests for GET /api/relays

    • Test: Returns 200 with array of 8 RelayDto
    • Test: Each relay has id 1-8, state, and optional label
    • File: tests/contract/test_relay_api.rs
    • Complexity: Low | Uncertainty: Low
  • T049 [US1] [TDD] Implement GET /api/relays endpoint

    • #[oai(path = "/relays", method = "get")]
    • Call GetAllRelaysUseCase, map to RelayDto
    • File: src/presentation/api/relay_api.rs
    • Complexity: Low | Uncertainty: Low
  • T050 [US1] [TDD] Write contract tests for POST /api/relays/{id}/toggle

    • Test: Returns 200 with updated RelayDto
    • Test: Returns 404 for id < 1 or id > 8
    • Test: State actually changes in controller
    • File: tests/contract/test_relay_api.rs
    • Complexity: Low | Uncertainty: Low
  • T051 [US1] [TDD] Implement POST /api/relays/{id}/toggle endpoint

    • #[oai(path = "/relays/:id/toggle", method = "post")]
    • Parse id, call ToggleRelayUseCase, return updated state
    • File: src/presentation/api/relay_api.rs
    • Complexity: Low | Uncertainty: Low

Frontend Implementation

  • T052 [P] [US1] [TDD] Create RelayDto TypeScript interface

    • Generate from OpenAPI spec or manually define
    • File: frontend/src/types/relay.ts
    • Complexity: Low | Uncertainty: Low
  • T053 [P] [US1] [TDD] Create API client service

    • getAllRelays(): Promise<RelayDto[]>
    • toggleRelay(id: number): Promise
    • File: frontend/src/api/relayApi.ts
    • Complexity: Low | Uncertainty: Low

T046: HTTP Polling Composable (DECOMPOSED)

Complexity: High → Broken into 4 sub-tasks Uncertainty: Medium Rationale: Vue 3 lifecycle hooks, polling management, memory leak prevention

  • T046a [US1] [TDD] Create useRelayPolling composable structure

    • Setup reactive refs: relays, isLoading, error, lastFetchTime
    • Define interval variable and fetch function signature
    • File: frontend/src/composables/useRelayPolling.ts
    • Complexity: Low | Uncertainty: Low

    Pseudocode:

    import { ref, Ref } from 'vue';
    import type { RelayDto } from '@/types/relay';
    
    export function useRelayPolling(intervalMs: number = 2000) {
      const relays: Ref<RelayDto[]> = ref([]);
      const isLoading = ref(true);
      const error: Ref<string | null> = ref(null);
      const lastFetchTime: Ref<Date | null> = ref(null);
      const isConnected = ref(false);
    
      let pollingInterval: number | null = null;
    
      // TODO: Implement fetchData, startPolling, stopPolling
    
      return {
        relays,
        isLoading,
        error,
        isConnected,
        lastFetchTime,
        refresh: fetchData,
        startPolling,
        stopPolling,
      };
    }
    

    TDD Checklist:

    • Test: Composable returns correct reactive refs
    • Test: Initial state is loading=true, relays=[], error=null
  • T046b [US1] [TDD] Implement fetchData with parallel requests

    • Fetch relays and health status in parallel using Promise.all
    • Update reactive state on success
    • Handle errors gracefully, set isConnected based on success
    • File: frontend/src/composables/useRelayPolling.ts
    • Complexity: Medium | Uncertainty: Low

    Pseudocode:

    const fetchData = async () => {
      try {
        const [relayData, healthData] = await Promise.all([
          apiClient.getAllRelays(),
          apiClient.getHealth(),
        ]);
    
        relays.value = relayData.relays;
        isConnected.value = healthData.status === 'healthy';
        lastFetchTime.value = new Date();
        error.value = null;
      } catch (err: any) {
        error.value = err.message || 'Failed to fetch relay data';
        isConnected.value = false;
        console.error('Polling error:', err);
      } finally {
        isLoading.value = false;
      }
    };
    

    TDD Checklist:

    • Test: fetchData() updates relays on success
    • Test: fetchData() sets error on API failure
    • Test: fetchData() sets isLoading=false after completion
    • Test: fetchData() updates lastFetchTime
  • T046c [US1] [TDD] Implement polling lifecycle with cleanup

    • startPolling(): Fetch immediately, then setInterval
    • stopPolling(): clearInterval and cleanup
    • Use onMounted/onUnmounted for automatic lifecycle management
    • File: frontend/src/composables/useRelayPolling.ts
    • Complexity: Medium | Uncertainty: Low

    Pseudocode:

    import { onMounted, onUnmounted } from 'vue';
    
    const startPolling = () => {
      if (pollingInterval !== null) return; // Already polling
    
      fetchData(); // Immediate first fetch
      pollingInterval = window.setInterval(fetchData, intervalMs);
    };
    
    const stopPolling = () => {
      if (pollingInterval !== null) {
        clearInterval(pollingInterval);
        pollingInterval = null;
      }
    };
    
    // CRITICAL: Lifecycle cleanup to prevent memory leaks
    onMounted(() => {
      startPolling();
    });
    
    onUnmounted(() => {
      stopPolling();
    });
    

    TDD Checklist:

    • Test: startPolling() triggers immediate fetch
    • Test: startPolling() sets interval for subsequent fetches
    • Test: stopPolling() clears interval
    • Test: onUnmounted hook calls stopPolling()
  • T046d [US1] [TDD] Add connection status tracking

    • Track isConnected based on fetch success/failure
    • Display connection status in UI
    • File: frontend/src/composables/useRelayPolling.ts
    • Complexity: Low | Uncertainty: Low

    Pseudocode:

    // Already implemented in T046b, just ensure it's exposed
    return {
      relays,
      isLoading,
      error,
      isConnected,  // ← Connection status indicator
      lastFetchTime,
      refresh: fetchData,
      startPolling,
      stopPolling,
    };
    

    TDD Checklist:

    • Test: isConnected is true after successful fetch
    • Test: isConnected is false after failed fetch

  • T055 [US1] [TDD] Create RelayCard component

    • Props: relay (RelayDto)
    • Display relay ID, state, label
    • Emit toggle event on button click
    • File: frontend/src/components/RelayCard.vue
    • Complexity: Low | Uncertainty: Low
  • T056 [US1] [TDD] Create RelayGrid component

    • Use useRelayPolling composable
    • Render 8 RelayCard components
    • Handle toggle events by calling API
    • Display loading/error states
    • File: frontend/src/components/RelayGrid.vue
    • Complexity: Medium | Uncertainty: Low
  • T057 [US1] [TDD] Integration test for US1

    • End-to-end test: Load page → see 8 relays → toggle relay 1 → verify state change
    • Use Playwright or Cypress
    • File: frontend/tests/e2e/relay-control.spec.ts
    • Complexity: Medium | Uncertainty: Medium

Checkpoint: US1 MVP complete - users can view and toggle individual relays


Phase 5: US2 - Bulk Relay Controls (0.5 days)

Goal: Turn all relays on/off with single action

Independent Test: POST /api/relays/all/on turns all 8 relays on

  • T058 [US2] [TDD] Write tests for BulkControlUseCase

    • Test: execute(BulkOperation::AllOn) turns all relays on
    • Test: execute(BulkOperation::AllOff) turns all relays off
    • File: src/application/use_cases/bulk_control.rs
    • Complexity: Low | Uncertainty: Low
  • T059 [US2] [TDD] Implement BulkControlUseCase

    • Call controller.write_all(state)
    • File: src/application/use_cases/bulk_control.rs
    • Complexity: Low | Uncertainty: Low
  • T060 [US2] [TDD] Define BulkOperation enum

    • Variants: AllOn, AllOff
    • File: src/domain/relay.rs
    • Complexity: Low | Uncertainty: Low
  • T061 [US2] [TDD] Write contract tests for POST /api/relays/all/on

    • Test: Returns 200, all relays turn on
    • File: tests/contract/test_relay_api.rs
    • Complexity: Low | Uncertainty: Low
  • T062 [US2] [TDD] Implement POST /api/relays/all/on endpoint

    • Call BulkControlUseCase with AllOn
    • File: src/presentation/api/relay_api.rs
    • Complexity: Low | Uncertainty: Low
  • T063 [P] [US2] [TDD] Write contract tests for POST /api/relays/all/off

    • Test: Returns 200, all relays turn off
    • File: tests/contract/test_relay_api.rs
    • Complexity: Low | Uncertainty: Low
  • T064 [P] [US2] [TDD] Implement POST /api/relays/all/off endpoint

    • Call BulkControlUseCase with AllOff
    • File: src/presentation/api/relay_api.rs
    • Complexity: Low | Uncertainty: Low
  • T065 [US2] [TDD] Add bulk control buttons to frontend

    • Add "All On" and "All Off" buttons to RelayGrid component
    • Call API endpoints and refresh relay states
    • File: frontend/src/components/RelayGrid.vue
    • Complexity: Low | Uncertainty: Low
  • T066 [US2] [TDD] Integration test for US2

    • Click "All On" → verify all 8 relays turn on
    • Click "All Off" → verify all 8 relays turn off
    • File: frontend/tests/e2e/bulk-control.spec.ts
    • Complexity: Low | Uncertainty: Low

Checkpoint: US2 complete - bulk controls functional


Phase 6: US3 - Health Monitoring (1 day)

Goal: Display connection status and device health

Independent Test: GET /api/health returns health status

  • T067 [US3] [TDD] Write tests for GetHealthUseCase

    • Test: Returns Healthy when controller is responsive
    • Test: Returns Degraded after 3 consecutive errors
    • Test: Returns Unhealthy after 10 consecutive errors
    • File: src/application/use_cases/get_health.rs
    • Complexity: Medium | Uncertainty: Low
  • T068 [US3] [TDD] Implement GetHealthUseCase

    • Use HealthMonitor to track controller status
    • Return current HealthStatus
    • File: src/application/use_cases/get_health.rs
    • Complexity: Medium | Uncertainty: Low
  • T069 [US3] [TDD] Define HealthDto

    • Fields: status ("healthy"/"degraded"/"unhealthy"), consecutive_errors (optional), reason (optional)
    • File: src/presentation/dto/health_dto.rs
    • Complexity: Low | Uncertainty: Low
  • T070 [US3] [TDD] Write contract tests for GET /api/health

    • Test: Returns 200 with HealthDto
    • File: tests/contract/test_health_api.rs
    • Complexity: Low | Uncertainty: Low
  • T071 [US3] [TDD] Implement GET /api/health endpoint

    • Call GetHealthUseCase, map to HealthDto
    • File: src/presentation/api/health_api.rs
    • Complexity: Low | Uncertainty: Low
  • T072 [P] [US3] [TDD] Add firmware version display (optional)

    • If controller supports firmware_version(), display in UI
    • File: frontend/src/components/DeviceInfo.vue
    • Complexity: Low | Uncertainty: Medium
    • Note: Device may not support this feature
  • T073 [US3] [TDD] Create HealthIndicator component

    • Display connection status with color-coded indicator
    • Show firmware version if available
    • File: frontend/src/components/HealthIndicator.vue
    • Complexity: Low | Uncertainty: Low
  • T074 [US3] [TDD] Integrate HealthIndicator in RelayGrid

    • Fetch health status in useRelayPolling composable
    • Pass to HealthIndicator component
    • File: frontend/src/components/RelayGrid.vue
    • Complexity: Low | Uncertainty: Low

Checkpoint: US3 complete - health monitoring visible


Phase 7: US4 - Relay Labeling (0.5 days)

Goal: Set custom labels for each relay

Independent Test: PUT /api/relays/{id}/label sets label, GET /api/relays returns label

  • T075 [US4] [TDD] Write tests for SetLabelUseCase

    • Test: execute(RelayId(1), "Pump") sets label
    • Test: execute with empty label returns error
    • Test: execute with 51-char label returns error
    • File: src/application/use_cases/set_label.rs
    • Complexity: Low | Uncertainty: Low
  • T076 [US4] [TDD] Implement SetLabelUseCase

    • Validate label with RelayLabel::new()
    • Call label_repository.set_label()
    • File: src/application/use_cases/set_label.rs
    • Complexity: Low | Uncertainty: Low
  • T077 [US4] [TDD] Write contract tests for PUT /api/relays/{id}/label

    • Test: Returns 200, label is persisted
    • Test: Returns 400 for invalid label
    • File: tests/contract/test_relay_api.rs
    • Complexity: Low | Uncertainty: Low
  • T078 [US4] [TDD] Implement PUT /api/relays/{id}/label endpoint

    • Parse id and label, call SetLabelUseCase
    • File: src/presentation/api/relay_api.rs
    • Complexity: Low | Uncertainty: Low
  • T079 [US4] [TDD] Add label editing to RelayCard component

    • Click label → show input field
    • Submit → call PUT /api/relays/{id}/label
    • File: frontend/src/components/RelayCard.vue
    • Complexity: Medium | Uncertainty: Low
  • T080 [US4] [TDD] Integration test for US4

    • Set label for relay 1 → refresh → verify label persists
    • File: frontend/tests/e2e/relay-labeling.spec.ts
    • Complexity: Low | Uncertainty: Low

Checkpoint: US4 complete - relay labeling functional


Phase 8: Polish & Deployment (1 day)

Purpose: Testing, documentation, and production readiness

  • T081 [P] Add comprehensive logging at all architectural boundaries

    • Log all API requests/responses
    • Log all Modbus operations
    • Log health status transitions
    • Files: All API and infrastructure modules
    • Complexity: Low | Uncertainty: Low
  • T082 [P] Add OpenAPI documentation for all endpoints

    • Document request/response schemas
    • Add example values
    • Tag endpoints appropriately
    • File: src/presentation/api/*.rs
    • Complexity: Low | Uncertainty: Low
  • T083 [P] Run cargo clippy and fix all warnings

    • Ensure compliance with strict linting
    • Complexity: Low | Uncertainty: Low
  • T084 [P] Run cargo fmt and format all code

    • Complexity: Low | Uncertainty: Low
  • T085 Generate test coverage report

    • Run: just coverage
    • Ensure > 80% coverage for domain and application layers
    • Complexity: Low | Uncertainty: Low
  • T086 [P] Run cargo audit for dependency vulnerabilities

    • Fix any high/critical vulnerabilities
    • Complexity: Low | Uncertainty: Medium
  • T087 [P] Update README.md with deployment instructions

    • Document environment variables
    • Document Modbus device configuration
    • Add quickstart guide
    • File: README.md
    • Complexity: Low | Uncertainty: Low
  • T088 [P] Create Docker image for backend

    • Multi-stage build with Rust
    • Include SQLite database setup
    • File: Dockerfile
    • Complexity: Medium | Uncertainty: Low
  • T089 [P] Create production settings/production.yaml

    • Configure for actual device IP
    • Set appropriate timeouts and retry settings
    • File: settings/production.yaml
    • Complexity: Low | Uncertainty: Low
  • T090 Deploy to production environment

    • Test with actual Modbus relay device
    • Verify all user stories work end-to-end
    • Complexity: Medium | Uncertainty: High

Checkpoint: Production ready, all user stories validated


Dependencies & Execution Order

Phase Dependencies

  1. Phase 1 (Setup): No dependencies - start immediately
  2. Phase 2 (Domain TyDD): Depends on Phase 1 module structure
  3. Phase 3 (Infrastructure): Depends on Phase 2 domain types
  4. Phase 4 (US1 MVP): Depends on Phase 3 infrastructure
  5. Phase 5 (US2): Depends on Phase 4 backend API complete
  6. Phase 6 (US3): Depends on Phase 4 backend API complete (can parallelize with US2)
  7. Phase 7 (US4): Depends on Phase 4 backend API complete (can parallelize with US2/US3)
  8. Phase 8 (Polish): Depends on all desired user stories complete

User Story Independence

  • US1: No dependencies on other stories
  • US2: Reuses US1 backend infrastructure, but independently testable
  • US3: Reuses US1 backend infrastructure, but independently testable
  • US4: Reuses US1 backend infrastructure, adds new persistence layer

Critical Path

MVP (US1 only): Phase 1 → Phase 2 → Phase 3 → Phase 4 (5 days) Full Feature: MVP + Phase 5 + Phase 6 + Phase 7 + Phase 8 (7 days)

Parallel Opportunities

  • Phase 1: T002, T003, T004, T005, T006, T007, T008 can run in parallel
  • Phase 2: T011, T015 can run in parallel
  • Phase 3: T020, T027, T028, T029, T030 can run in parallel after T022 complete
  • Phase 4: T035, T044, T045 can run in parallel
  • After Phase 4: US2, US3, US4 can be developed in parallel by different developers
  • Phase 8: T073, T074, T075, T076, T078, T079, T080, T081 can run in parallel

Total Parallelizable Tasks: 35 tasks marked [P]


Test-Driven Development Workflow

CRITICAL: For every task marked [TDD], follow this exact sequence:

  1. Write failing test FIRST (red)
  2. Verify test fails for the right reason
  3. Implement minimum code to pass test (green)
  4. Refactor while keeping tests green
  5. Commit after each task or logical group

Example TDD Workflow (T010):

# 1. Write failing test for RelayId::new() validation
# In src/domain/relay.rs:
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_relay_id_valid_range() {
        assert!(RelayId::new(1).is_ok());
        assert!(RelayId::new(8).is_ok());
    }

    #[test]
    fn test_relay_id_invalid_range() {
        assert!(RelayId::new(0).is_err());
        assert!(RelayId::new(9).is_err());
    }
}

# 2. Run test → VERIFY IT FAILS
cargo test test_relay_id

# 3. Implement RelayId to make test pass
# 4. Run test again → VERIFY IT PASSES
# 5. Refactor if needed, keep tests green
# 6. Commit
jj describe -m "feat: implement RelayId with validation (T010)"

Notes

  • [P] = Parallelizable (different files, no dependencies)
  • [US1/US2/US3/US4] = User story mapping for traceability
  • [TDD] = Test-Driven Development required
  • Complexity: Low (< 1 hour) | Medium (1-3 hours) | High (> 3 hours or decomposed)
  • Uncertainty: Low (clear path) | Medium (some unknowns) | High (requires research/spike)
  • Commit after each task or logical group using jj describe or jj commit
  • MVP delivery at task T049 (end of Phase 4)
  • Stop at any checkpoint to independently validate user story