Files
sta/specs/001-modbus-relay-control/data-model.md

1032 lines
26 KiB
Markdown
Raw Normal View History

# Data Model: Modbus Relay Control System
**Created**: 2025-01-09
**Feature**: [spec.md](./spec.md)
**Related**: [types-design.md](./types-design.md) - Domain type definitions
**Status**: Design
## Overview
This document defines the data model for the Modbus relay control system, including database schemas, API data transfer objects (DTOs), serialization formats, and persistence layer structures. This complements `types-design.md` which defines domain types and validation logic.
## Scope
**This document covers**:
- SQLite database schemas for persistent storage
- API request/response DTOs (JSON contracts)
- Modbus protocol data structures
- Configuration file formats (YAML)
- Serialization/deserialization mappings
**Out of scope** (see `types-design.md`):
- Domain type validation logic
- Business rule enforcement
- Type safety guarantees
---
## Database Schemas
### SQLite Database: `relay_labels.db`
**Purpose**: Persist custom relay labels across application restarts.
**Location**: Configurable via `settings.database.path` (default: `relay_labels.db`)
**Schema Version**: 1.0
---
#### Table: `relay_labels`
**Purpose**: Store custom labels for each of the 8 relays.
```sql
CREATE TABLE IF NOT EXISTS relay_labels (
relay_id INTEGER PRIMARY KEY NOT NULL,
label TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
-- Constraints
CHECK(relay_id >= 1 AND relay_id <= 8),
CHECK(length(label) >= 1 AND length(label) <= 50),
CHECK(label NOT GLOB '*[^a-zA-Z0-9 _-]*') -- Only alphanumeric, spaces, hyphens, underscores
);
-- Index for timestamp queries (future analytics)
CREATE INDEX IF NOT EXISTS idx_relay_labels_updated_at
ON relay_labels(updated_at);
```
**Columns**:
| Column | Type | Nullable | Description |
|--------------|---------|----------|---------------------------------------------|
| `relay_id` | INTEGER | NO | Relay identifier (1-8), PRIMARY KEY |
| `label` | TEXT | NO | Custom label (1-50 characters) |
| `created_at` | TEXT | NO | ISO 8601 timestamp when label was first set |
| `updated_at` | TEXT | NO | ISO 8601 timestamp of last label update |
**Constraints**:
- `relay_id` range: 1-8 (enforced by CHECK constraint)
- `label` length: 1-50 characters (enforced by CHECK constraint)
- `label` characters: Alphanumeric, spaces, hyphens, underscores only (enforced by CHECK constraint)
**Initial Data**:
```sql
-- Pre-populate with default labels on database initialization
INSERT OR IGNORE INTO relay_labels (relay_id, label) VALUES
(1, 'Relay 1'),
(2, 'Relay 2'),
(3, 'Relay 3'),
(4, 'Relay 4'),
(5, 'Relay 5'),
(6, 'Relay 6'),
(7, 'Relay 7'),
(8, 'Relay 8');
```
**Migration Strategy**:
- For MVP: Schema auto-created by SQLx on first run
- For future versions: Use SQLx migrations with version tracking
---
#### Example Queries
**Get label for relay 3**:
```sql
SELECT label FROM relay_labels WHERE relay_id = 3;
```
**Set/update label for relay 5**:
```sql
INSERT OR REPLACE INTO relay_labels (relay_id, label, updated_at)
VALUES (5, 'Water Pump', datetime('now'));
```
**Get all labels**:
```sql
SELECT relay_id, label
FROM relay_labels
ORDER BY relay_id ASC;
```
**Get labels modified in last 24 hours**:
```sql
SELECT relay_id, label, updated_at
FROM relay_labels
WHERE updated_at >= datetime('now', '-1 day')
ORDER BY updated_at DESC;
```
---
## API Data Transfer Objects (DTOs)
### JSON Serialization Format
**Content-Type**: `application/json`
**Character Encoding**: UTF-8
**Date Format**: ISO 8601 (e.g., `2025-01-09T14:30:00Z`)
---
### RelayDto
**Purpose**: Represents a single relay's complete state for API responses.
**Used in**:
- `GET /api/relays` response (array)
- `GET /api/relays/{id}` response (single)
- `POST /api/relays/{id}/toggle` response (single)
- `PATCH /api/relays/{id}/label` response (single)
**JSON Schema**:
```json
{
"type": "object",
"required": ["id", "state"],
"properties": {
"id": {
"type": "integer",
"minimum": 1,
"maximum": 8,
"description": "Relay identifier (1-8)"
},
"state": {
"type": "string",
"enum": ["on", "off"],
"description": "Current relay state"
},
"label": {
"type": "string",
"minLength": 1,
"maxLength": 50,
"pattern": "^[a-zA-Z0-9 _-]+$",
"description": "Custom relay label (optional)"
}
}
}
```
**Example**:
```json
{
"id": 3,
"state": "on",
"label": "Water Pump"
}
```
**Rust Type Mapping**:
```rust
use serde::{Deserialize, Serialize};
use poem_openapi::Object;
#[derive(Debug, Clone, Serialize, Deserialize, Object)]
pub struct RelayDto {
/// Relay identifier (1-8)
pub id: u8,
/// Current relay state: "on" or "off"
pub state: String,
/// Custom relay label (optional)
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
```
**Domain → DTO Conversion**:
```rust
impl From<Relay> for RelayDto {
fn from(relay: Relay) -> Self {
Self {
id: relay.id().as_u8(),
state: match relay.state() {
RelayState::On => "on".to_string(),
RelayState::Off => "off".to_string(),
},
label: relay.label().map(|l| l.as_str().to_string()),
}
}
}
```
---
### RelayListResponse
**Purpose**: Response for endpoints returning multiple relays.
**Used in**:
- `GET /api/relays` response
- `POST /api/relays/bulk/on` response
- `POST /api/relays/bulk/off` response
**JSON Schema**:
```json
{
"type": "object",
"required": ["relays"],
"properties": {
"relays": {
"type": "array",
"minItems": 8,
"maxItems": 8,
"items": { "$ref": "#/components/schemas/RelayDto" },
"description": "Array of all 8 relays in order (IDs 1-8)"
}
}
}
```
**Example**:
```json
{
"relays": [
{ "id": 1, "state": "off", "label": "Garage Light" },
{ "id": 2, "state": "on", "label": "Water Pump" },
{ "id": 3, "state": "off", "label": "Relay 3" },
{ "id": 4, "state": "off", "label": "Relay 4" },
{ "id": 5, "state": "on", "label": "Relay 5" },
{ "id": 6, "state": "off", "label": "Relay 6" },
{ "id": 7, "state": "off", "label": "Relay 7" },
{ "id": 8, "state": "off", "label": "Relay 8" }
]
}
```
**Rust Type Mapping**:
```rust
#[derive(Debug, Clone, Serialize, Deserialize, Object)]
pub struct RelayListResponse {
/// Array of all 8 relays
pub relays: Vec<RelayDto>,
}
```
---
### UpdateLabelRequest
**Purpose**: Request body for updating a relay label.
**Used in**:
- `PATCH /api/relays/{id}/label` request body
**JSON Schema**:
```json
{
"type": "object",
"required": ["label"],
"properties": {
"label": {
"type": "string",
"minLength": 1,
"maxLength": 50,
"pattern": "^[a-zA-Z0-9 _-]+$",
"description": "New label for the relay"
}
}
}
```
**Example**:
```json
{
"label": "Office Fan"
}
```
**Rust Type Mapping**:
```rust
#[derive(Debug, Clone, Serialize, Deserialize, Object)]
pub struct UpdateLabelRequest {
/// New label for the relay (1-50 characters)
pub label: String,
}
```
---
### HealthResponse
**Purpose**: Device health and connectivity status.
**Used in**:
- `GET /api/health` response
**JSON Schema**:
```json
{
"type": "object",
"required": ["status", "device_connected"],
"properties": {
"status": {
"type": "string",
"enum": ["healthy", "degraded", "unhealthy"],
"description": "Overall health status"
},
"device_connected": {
"type": "boolean",
"description": "Whether Modbus device is reachable"
},
"firmware_version": {
"type": "string",
"maxLength": 20,
"description": "Device firmware version (optional)"
},
"last_contact": {
"type": "string",
"format": "date-time",
"description": "ISO 8601 timestamp of last successful communication"
},
"consecutive_errors": {
"type": "integer",
"minimum": 0,
"description": "Number of consecutive errors (for degraded/unhealthy status)"
}
}
}
```
**Examples**:
**Healthy**:
```json
{
"status": "healthy",
"device_connected": true,
"firmware_version": "v2.00",
"last_contact": "2025-01-09T14:30:00Z",
"consecutive_errors": 0
}
```
**Degraded**:
```json
{
"status": "degraded",
"device_connected": true,
"firmware_version": "v2.00",
"last_contact": "2025-01-09T14:28:00Z",
"consecutive_errors": 3
}
```
**Unhealthy**:
```json
{
"status": "unhealthy",
"device_connected": false,
"last_contact": "2025-01-09T14:00:00Z",
"consecutive_errors": 10
}
```
**Rust Type Mapping**:
```rust
#[derive(Debug, Clone, Serialize, Deserialize, Object)]
pub struct HealthResponse {
/// Overall health status
pub status: String, // "healthy" | "degraded" | "unhealthy"
/// Whether Modbus device is currently reachable
pub device_connected: bool,
/// Device firmware version (if available)
#[serde(skip_serializing_if = "Option::is_none")]
pub firmware_version: Option<String>,
/// ISO 8601 timestamp of last successful communication
#[serde(skip_serializing_if = "Option::is_none")]
pub last_contact: Option<String>,
/// Number of consecutive errors
#[serde(skip_serializing_if = "Option::is_none")]
pub consecutive_errors: Option<u32>,
}
```
---
### ErrorResponse
**Purpose**: Standardized error response for all API errors.
**Used in**:
- All API endpoints (4xx, 5xx responses)
**JSON Schema**:
```json
{
"type": "object",
"required": ["error", "message"],
"properties": {
"error": {
"type": "string",
"description": "Error code or type"
},
"message": {
"type": "string",
"description": "Human-readable error message"
},
"details": {
"type": "object",
"description": "Additional error context (optional)"
}
}
}
```
**Examples**:
**400 Bad Request** (Invalid relay ID):
```json
{
"error": "InvalidRelayId",
"message": "Relay ID 9 out of range (valid: 1-8)",
"details": {
"value": 9,
"min": 1,
"max": 8
}
}
```
**400 Bad Request** (Invalid label):
```json
{
"error": "InvalidLabel",
"message": "Relay label too long (max: 50, got: 73)"
}
```
**500 Internal Server Error** (Modbus communication failure):
```json
{
"error": "ModbusCommunicationError",
"message": "Failed to read relay state: Connection timeout after 3 seconds"
}
```
**504 Gateway Timeout** (Modbus timeout):
```json
{
"error": "ModbusTimeout",
"message": "Modbus operation timed out after 3 seconds"
}
```
**Rust Type Mapping**:
```rust
#[derive(Debug, Clone, Serialize, Deserialize, Object)]
pub struct ErrorResponse {
/// Error code or type
pub error: String,
/// Human-readable error message
pub message: String,
/// Additional error context (optional)
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
}
```
---
## Modbus Protocol Data Structures
### Modbus TCP Protocol
**Protocol**: Modbus TCP (not RTU over serial)
**Port**: 502 (standard Modbus TCP port)
**Transport**: TCP/IP
**Framing**: MBAP header (no CRC validation needed)
---
### Modbus Function Codes
| Function Code | Name | Purpose | Request | Response |
|---------------|------------------------|-----------------------|----------------------------------------------|--------------------------------|
| `0x01` | Read Coils | Read relay states | Start address (0-7), quantity (1-8) | Coil values (bit array) |
| `0x05` | Write Single Coil | Toggle single relay | Address (0-7), value (0xFF00=ON, 0x0000=OFF) | Echo request |
| `0x0F` | Write Multiple Coils | Bulk relay control | Start address, quantity, byte count, values | Echo request |
| `0x03` | Read Holding Registers | Read firmware version | Register address (0x8000), quantity (1) | Register value (version * 100) |
---
### Coil Address Mapping
**User-facing RelayId (1-8) → Modbus Address (0-7)**:
| Relay ID (User) | Modbus Coil Address | Description |
|-----------------|---------------------|-------------|
| 1 | 0x0000 (0) | Relay 1 |
| 2 | 0x0001 (1) | Relay 2 |
| 3 | 0x0002 (2) | Relay 3 |
| 4 | 0x0003 (3) | Relay 4 |
| 5 | 0x0004 (4) | Relay 5 |
| 6 | 0x0005 (5) | Relay 6 |
| 7 | 0x0006 (6) | Relay 7 |
| 8 | 0x0007 (7) | Relay 8 |
**Conversion Formula**:
- User → Modbus: `modbus_address = relay_id - 1`
- Modbus → User: `relay_id = modbus_address + 1`
---
### Read Coils Request (0x01)
**Purpose**: Read current state of one or more relays.
**Request Structure** (tokio-modbus handles framing):
```rust
// Read all 8 relays
ctx.read_coils(0x0000, 8).await?
// Read single relay (ID 3 = address 2)
ctx.read_coils(0x0002, 1).await?
```
**Response**: `Result<Vec<bool>, tokio_modbus::Error>`
- `true` = Relay ON (coil energized)
- `false` = Relay OFF (coil de-energized)
**Example**:
```rust
// Read all relays
let coils = client.read_coils(0x0000, 8).await?;
// coils = [false, true, false, false, true, false, false, false]
// Relays 2 and 5 are ON, others are OFF
```
---
### Write Single Coil Request (0x05)
**Purpose**: Toggle a single relay on or off.
**Request Structure** (tokio-modbus handles framing):
```rust
// Turn relay 3 ON (address 2)
ctx.write_single_coil(0x0002, true).await?
// Turn relay 3 OFF
ctx.write_single_coil(0x0002, false).await?
```
**Response**: `Result<(), tokio_modbus::Error>`
**Coil Value Encoding**:
- `true` = ON (0xFF00 in Modbus protocol)
- `false` = OFF (0x0000 in Modbus protocol)
---
### Write Multiple Coils Request (0x0F)
**Purpose**: Set state of multiple relays in one operation (bulk control).
**Request Structure** (tokio-modbus handles framing):
```rust
// Turn all 8 relays ON
let all_on = vec![true; 8];
ctx.write_multiple_coils(0x0000, &all_on).await?
// Turn all 8 relays OFF
let all_off = vec![false; 8];
ctx.write_multiple_coils(0x0000, &all_off).await?
```
**Response**: `Result<(), tokio_modbus::Error>`
---
### Read Holding Registers Request (0x03)
**Purpose**: Read firmware version from device.
**Request Structure** (tokio-modbus handles framing):
```rust
// Read firmware version register
let registers = ctx.read_holding_registers(0x8000, 1).await?;
let version_raw = registers[0];
let version = f32::from(version_raw) / 100.0;
let version_str = format!("v{:.2}", version);
// Example: version_raw = 200 → "v2.00"
```
**Response**: `Result<Vec<u16>, tokio_modbus::Error>`
**Encoding**:
- Firmware version is stored as `u16` = version × 100
- Example: `200` = version 2.00, `157` = version 1.57
**Note**: This may not be supported by all devices. Handle `ModbusException` gracefully.
---
### Error Handling
**tokio-modbus Error Types**:
```rust
// Nested Result structure
Result<Result<T, Exception>, io::Error>
// Exception codes (from Modbus protocol)
pub enum Exception {
IllegalFunction = 0x01,
IllegalDataAddress = 0x02,
IllegalDataValue = 0x03,
ServerDeviceFailure = 0x04,
// ... (other codes)
}
```
**Mapping to Domain Errors**:
```rust
match result {
Ok(Ok(data)) => Ok(data), // Success
Ok(Err(Exception::IllegalDataAddress)) =>
Err(ControllerError::InvalidRelayId),
Ok(Err(exception)) =>
Err(ControllerError::ModbusException(format!("{:?}", exception))),
Err(io_error) =>
Err(ControllerError::ConnectionError(io_error.to_string())),
}
```
---
## Configuration File Format (YAML)
### settings/base.yaml
**Purpose**: Base configuration shared across all environments.
```yaml
application:
name: "STA - Smart Temperature & Appliance Control"
version: "1.0.0"
host: "0.0.0.0"
port: 8080
modbus:
# Modbus device IP address (update for your network)
host: "192.168.0.200"
# Modbus TCP port (standard: 502)
port: 502
# Modbus slave/unit ID (typically 0 or 1)
slave_id: 0
# Operation timeout in seconds
timeout_secs: 5
# Number of retry attempts on failure
retry_attempts: 1
relay:
# Maximum label length (characters)
label_max_length: 50
database:
# SQLite database file path
path: "relay_labels.db"
rate_limit:
# Requests per minute per IP
requests_per_minute: 100
cors:
# CORS allowed origins (production should override)
allowed_origins: []
# Allow credentials (cookies, authorization headers)
allow_credentials: false
# Preflight cache duration (seconds)
max_age_secs: 3600
logging:
# Log level: trace, debug, info, warn, error
level: "info"
# Enable structured JSON logging
json: false
```
---
### settings/development.yaml
**Purpose**: Development environment overrides.
```yaml
application:
host: "127.0.0.1"
port: 8080
modbus:
# Local test device or mock
host: "192.168.0.200"
timeout_secs: 3
cors:
# Permissive CORS for local development
allowed_origins: ["*"]
allow_credentials: false
logging:
level: "debug"
json: false
```
---
### settings/production.yaml
**Purpose**: Production environment configuration.
```yaml
application:
host: "0.0.0.0"
port: 8080
modbus:
# Production device IP (configured during deployment)
host: "${MODBUS_DEVICE_IP}"
timeout_secs: 5
cors:
# Specific origin for production frontend
allowed_origins: ["https://sta.yourdomain.com"]
allow_credentials: true
database:
path: "/var/lib/sta/relay_labels.db"
logging:
level: "info"
json: true # Structured logging for production
```
---
## Persistence Layer Repository Interface
### RelayLabelRepository Trait
**Purpose**: Abstract interface for relay label persistence.
```rust
use async_trait::async_trait;
#[async_trait]
pub trait RelayLabelRepository: Send + Sync {
/// Get label for a specific relay
async fn get_label(&self, id: RelayId)
-> Result<Option<RelayLabel>, RepositoryError>;
/// Set label for a specific relay
async fn set_label(&self, id: RelayId, label: RelayLabel)
-> Result<(), RepositoryError>;
/// Get all relay labels (IDs 1-8)
async fn get_all_labels(&self)
-> Result<Vec<(RelayId, RelayLabel)>, RepositoryError>;
/// Delete label for a relay (revert to default)
async fn delete_label(&self, id: RelayId)
-> Result<(), RepositoryError>;
}
```
---
### SQLite Repository Implementation
**Implementation**: `SqliteRelayLabelRepository`
```rust
use sqlx::{SqlitePool, Row};
pub struct SqliteRelayLabelRepository {
pool: SqlitePool,
}
impl SqliteRelayLabelRepository {
pub async fn new(db_path: &str) -> Result<Self, RepositoryError> {
let pool = SqlitePool::connect(db_path).await?;
// Initialize schema
sqlx::query(include_str!("schema.sql"))
.execute(&pool)
.await?;
Ok(Self { pool })
}
pub async fn in_memory() -> Result<Self, RepositoryError> {
Self::new("sqlite::memory:").await
}
}
#[async_trait]
impl RelayLabelRepository for SqliteRelayLabelRepository {
async fn get_label(&self, id: RelayId)
-> Result<Option<RelayLabel>, RepositoryError>
{
let label_str: Option<String> = sqlx::query_scalar(
"SELECT label FROM relay_labels WHERE relay_id = ?"
)
.bind(id.as_u8())
.fetch_optional(&self.pool)
.await?;
match label_str {
Some(s) => Ok(Some(RelayLabel::new(s)?)),
None => Ok(None),
}
}
async fn set_label(&self, id: RelayId, label: RelayLabel)
-> Result<(), RepositoryError>
{
sqlx::query(
"INSERT OR REPLACE INTO relay_labels (relay_id, label, updated_at)
VALUES (?, ?, datetime('now'))"
)
.bind(id.as_u8())
.bind(label.as_str())
.execute(&self.pool)
.await?;
Ok(())
}
async fn get_all_labels(&self)
-> Result<Vec<(RelayId, RelayLabel)>, RepositoryError>
{
let rows = sqlx::query(
"SELECT relay_id, label FROM relay_labels ORDER BY relay_id"
)
.fetch_all(&self.pool)
.await?;
let mut result = Vec::new();
for row in rows {
let id_val: u8 = row.try_get("relay_id")?;
let label_str: String = row.try_get("label")?;
let id = RelayId::new(id_val)?;
let label = RelayLabel::new(label_str)?;
result.push((id, label));
}
Ok(result)
}
async fn delete_label(&self, id: RelayId)
-> Result<(), RepositoryError>
{
sqlx::query("DELETE FROM relay_labels WHERE relay_id = ?")
.bind(id.as_u8())
.execute(&self.pool)
.await?;
Ok(())
}
}
```
---
## Domain to DTO Mapping Summary
| Domain Type | DTO Type | Mapping |
|----------------------|---------------------|---------------------------------------------|
| `Relay` | `RelayDto` | Direct field mapping with string conversion |
| `RelayState::On` | `"on"` | Lowercase string |
| `RelayState::Off` | `"off"` | Lowercase string |
| `RelayId(3)` | `3` | Extract inner u8 |
| `RelayLabel("Pump")` | `"Pump"` | Extract inner String |
| `Vec<Relay>` | `RelayListResponse` | Wrap in `relays` field |
| `HealthStatus` | `HealthResponse` | Convert enum to status string + fields |
| `DeviceHealth` | `HealthResponse` | Extract fields, format timestamps |
---
## Serialization Examples
### Relay State Transitions
**Domain Event**:
```rust
let mut relay = Relay::new(RelayId::new(3)?, RelayState::Off);
relay.set_label(Some(RelayLabel::new("Water Pump".to_string())?));
relay.toggle();
```
**API Response**:
```json
{
"id": 3,
"state": "on",
"label": "Water Pump"
}
```
---
### Database to Domain
**SQLite Row**:
```
relay_id | label | created_at | updated_at
---------|-------------|-------------------------|-------------------------
3 | Water Pump | 2025-01-08 10:00:00 | 2025-01-09 14:30:00
```
**Domain Object**:
```rust
let relay_id = RelayId::new(3)?;
let label = RelayLabel::new("Water Pump".to_string())?;
```
---
### Modbus to Domain
**Modbus Read Response** (Function Code 0x01):
```
Read Coils(address=0, quantity=8)
Response: [false, true, false, false, true, false, false, false]
```
**Domain Mapping**:
```rust
let relay_states = vec![
(RelayId::new(1)?, RelayState::Off),
(RelayId::new(2)?, RelayState::On),
(RelayId::new(3)?, RelayState::Off),
(RelayId::new(4)?, RelayState::Off),
(RelayId::new(5)?, RelayState::On),
(RelayId::new(6)?, RelayState::Off),
(RelayId::new(7)?, RelayState::Off),
(RelayId::new(8)?, RelayState::Off),
];
```
---
## Data Flow Summary
```
┌─────────────────────────────────────────────────────────────┐
│ API Layer (JSON) │
│ RelayDto, UpdateLabelRequest, HealthResponse, etc. │
└────────────────────────┬────────────────────────────────────┘
│ Parse/Validate
┌─────────────────────────────────────────────────────────────┐
│ Domain Layer (Types) │
│ RelayId, RelayState, Relay, RelayLabel, etc. │
└────────────┬────────────────────────────────┬───────────────┘
│ │
▼ ▼
┌────────────────────────┐ ┌────────────────────────────┐
│ Infrastructure │ │ Infrastructure │
│ (Modbus Protocol) │ │ (SQLite Persistence) │
│ │ │ │
│ - Coil addresses (0-7) │ │ - relay_labels table │
│ - Function codes │ │ - RelayLabelRepository │
│ - Read/Write ops │ │ - SQLx queries │
└────────────────────────┘ └────────────────────────────┘
```
---
## References
- [Feature Specification](./spec.md) - Complete requirements and user stories
- [Type Design](./types-design.md) - Domain type definitions and validation
- [Implementation Plan](./plan.md) - Technical architecture and implementation strategy
- [Modbus Hardware Documentation](../../docs/Modbus_POE_ETH_Relay.md) - Device protocol details
---
## Revision History
| Version | Date | Author | Changes |
|---------|------------|-------------------|----------------------------------|
| 1.0 | 2025-01-09 | Type Design Agent | Initial data model specification |