From bcf8c31270385f14cf13bcde206a054950b2df45 Mon Sep 17 00:00:00 2001 From: Allegro Date: Thu, 2 Apr 2026 19:59:35 +0000 Subject: [PATCH] feat(state): Implement State Schema for SEED Architecture (Issue #3) - Add JSON schema at schemas/state.json with full validation rules - Implement State, StateType, StateMetadata dataclasses in models/state.py - Support immutable state objects with versioning and TTL - Include serialization/deserialization (JSON and dict) - Add next_version() for state history chaining - Comprehensive test suite with 19 tests (100% pass) - Full documentation at docs/state-schema.md Features: - UUID validation for all ID fields - Optimistic locking via version numbers - Flexible payload structure - Metadata support (source, provenance, tags) - Expiration checking via TTL - Complete type hints Closes Issue #3 --- README.md | 66 ++++- docs/state-schema.md | 153 ++++++++++ models/__pycache__/state.cpython-312.pyc | Bin 0 -> 10389 bytes models/state.py | 233 ++++++++++++++++ pytest.ini | 6 + schemas/state.json | 99 +++++++ .../test_state.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 40678 bytes tests/test_state.py | 263 ++++++++++++++++++ 8 files changed, 818 insertions(+), 2 deletions(-) create mode 100644 docs/state-schema.md create mode 100644 models/__pycache__/state.cpython-312.pyc create mode 100644 models/state.py create mode 100644 pytest.ini create mode 100644 schemas/state.json create mode 100644 tests/__pycache__/test_state.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/test_state.py diff --git a/README.md b/README.md index 77bb3a1..fded256 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,65 @@ -# electra-archon +# Electra Archon -Electra Archon - SEED Architecture Implementation \ No newline at end of file +SEED Architecture Implementation for the Electra Archon wizard. + +## SEED Architecture + +SEED stands for **S**tate-**E**vent-**E**ntity-**D**omain - a modular architecture for building resilient, auditable systems. + +### Components + +1. **State** - Immutable snapshots of entity state with versioning +2. **Event** - Pub/sub system for inter-component communication +3. **Entity** - Core domain objects with identity +4. **Domain** - Business logic and rules + +## Project Structure + +``` +electra-archon/ +├── schemas/ # JSON schemas for data validation +│ └── state.json # State schema definition +├── models/ # Python data models +│ └── state.py # State dataclass implementation +├── docs/ # Documentation +│ └── state-schema.md +├── tests/ # Test suite +│ └── test_state.py +└── pytest.ini # Test configuration +``` + +## Quick Start + +```python +from models.state import State, StateType, StateMetadata +import uuid + +# Create a state +state = State.create( + entity_id=str(uuid.uuid4()), + state_type=StateType.ACTIVE, + payload={"status": "running"}, + metadata=StateMetadata(source="electra", tags=["seed"]) +) + +# Serialize +json_str = state.to_json() +``` + +## Running Tests + +```bash +pytest tests/ -v +``` + +## Backlog + +See [Issues](http://143.198.27.163:3000/allegro/electra-archon/issues) for current backlog: + +- Issue #3: Design Electra State Schema for SEED Architecture ✅ +- Issue #4: Implement Event Bus for Inter-Archon Communication +- Issue #5: Create Entity Resolution Service + +## License + +MIT diff --git a/docs/state-schema.md b/docs/state-schema.md new file mode 100644 index 0000000..7068d21 --- /dev/null +++ b/docs/state-schema.md @@ -0,0 +1,153 @@ +# SEED State Schema Documentation + +## Overview + +The State Schema defines the structure for persisting and managing state within the SEED (State-Event-Entity-Domain) architecture. Each state record represents a snapshot of an entity at a specific point in time. + +## Schema Design + +### Core Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | UUID | Yes | Unique identifier for this state record | +| `entity_id` | UUID | Yes | Reference to the entity this state belongs to | +| `state_type` | Enum | Yes | Classification: `active`, `inactive`, `pending`, `archived`, `deleted` | +| `payload` | Object | Yes | Flexible JSON containing state-specific data | +| `timestamp` | ISO 8601 | Yes | When this state was recorded | +| `version` | Integer | Yes | Version number for optimistic locking (>= 1) | +| `metadata` | Object | No | Additional metadata (source, provenance, tags) | +| `previous_state_id` | UUID | No | Reference to previous state (for history chain) | +| `ttl` | Integer | No | Time-to-live in seconds | + +### State Types + +- **active**: Entity is currently active and operational +- **inactive**: Entity is temporarily inactive +- **pending**: Entity is in a pending/waiting state +- **archived**: Entity has been archived +- **deleted**: Entity has been marked for deletion + +### Metadata Structure + +```json +{ + "source": "service-name", + "provenance": "trace-id", + "created_by": "user-or-service", + "tags": ["tag1", "tag2"] +} +``` + +## Python Model + +### Basic Usage + +```python +from models.state import State, StateType, StateMetadata +import uuid + +# Create a new state +state = State.create( + entity_id=str(uuid.uuid4()), + state_type=StateType.ACTIVE, + payload={"status": "running", "progress": 75}, + metadata=StateMetadata( + source="electra-archon", + created_by="electra", + tags=["seed", "priority"] + ) +) + +# Serialize to JSON +json_str = state.to_json(indent=2) + +# Deserialize from JSON +restored_state = State.from_json(json_str) +``` + +### Versioning + +```python +# Create next version of a state +next_state = state.next_version({"status": "running", "progress": 90}) +print(next_state.version) # 2 +print(next_state.previous_state_id) # Original state ID +``` + +### Validation + +The model validates: +- UUID format for all ID fields +- Version is >= 1 +- TTL is non-negative if specified +- State type is valid enum value + +## Indexes + +For optimal performance, the following indexes are recommended: + +```sql +CREATE INDEX idx_state_entity_id ON states(entity_id); +CREATE INDEX idx_state_timestamp ON states(timestamp); +CREATE INDEX idx_state_type ON states(state_type); +CREATE INDEX idx_state_entity_version ON states(entity_id, version); +``` + +## JSON Schema + +The full JSON Schema is available at `schemas/state.json`. It can be used for: +- API request/response validation +- Documentation generation +- Client code generation + +## Example State Object + +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "entity_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", + "state_type": "active", + "payload": { + "status": "running", + "progress": 75, + "details": { + "last_action": "processing", + "queue_position": 3 + } + }, + "timestamp": "2026-04-02T19:30:00Z", + "version": 3, + "metadata": { + "source": "electra-archon", + "provenance": "trace-abc123", + "created_by": "electra", + "tags": ["seed", "priority"] + }, + "previous_state_id": "550e8400-e29b-41d4-a716-446655440001", + "ttl": null +} +``` + +## Testing + +Run the test suite: + +```bash +pytest tests/test_state.py -v +``` + +## Integration with SEED Architecture + +The State component works with: +- **Entity**: States reference entities via `entity_id` +- **Event**: State changes can emit events +- **Domain**: Business logic determines state transitions + +## Future Enhancements + +- [ ] Compression for large payloads +- [ ] Encryption for sensitive data +- [ ] State transition validation rules +- [ ] Bulk state operations +- [ ] State snapshot/restore functionality diff --git a/models/__pycache__/state.cpython-312.pyc b/models/__pycache__/state.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..07ef30d5064eedba28ed84d04741ef39696c68f2 GIT binary patch literal 10389 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 wnb>LTFSPYKF8Gm-fJ11uRB8~^|S literal 0 HcmV?d00001 diff --git a/models/state.py b/models/state.py new file mode 100644 index 0000000..3a928ff --- /dev/null +++ b/models/state.py @@ -0,0 +1,233 @@ +""" +State Model for SEED Architecture + +This module defines the State dataclass and related utilities for the +State component of the SEED (State-Event-Entity-Domain) architecture. +""" + +from __future__ import annotations + +import json +import uuid +from dataclasses import dataclass, field, asdict +from datetime import datetime, timezone +from enum import Enum, auto +from typing import Any, Optional, Dict, List +from pathlib import Path + + +class StateType(Enum): + """Enumeration of valid state types.""" + ACTIVE = "active" + INACTIVE = "inactive" + PENDING = "pending" + ARCHIVED = "archived" + DELETED = "deleted" + + +@dataclass(frozen=True) +class StateMetadata: + """Metadata associated with a state record.""" + source: str = "unknown" + provenance: Optional[str] = None + created_by: Optional[str] = None + tags: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + """Convert metadata to dictionary.""" + return { + "source": self.source, + "provenance": self.provenance, + "created_by": self.created_by, + "tags": self.tags + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> StateMetadata: + """Create metadata from dictionary.""" + return cls( + source=data.get("source", "unknown"), + provenance=data.get("provenance"), + created_by=data.get("created_by"), + tags=data.get("tags", []) + ) + + +@dataclass(frozen=True) +class State: + """ + Immutable State record for SEED architecture. + + Attributes: + id: Unique identifier for this state record (UUID v4) + entity_id: Reference to the entity this state belongs to + state_type: The type/category of this state + payload: Flexible JSON payload containing state-specific data + timestamp: ISO 8601 timestamp when this state was recorded + version: Version number for optimistic locking + metadata: Additional metadata about this state record + previous_state_id: Reference to the previous state (None if first) + ttl: Time-to-live in seconds (None for no expiration) + """ + id: str + entity_id: str + state_type: StateType + payload: Dict[str, Any] + timestamp: datetime + version: int + metadata: StateMetadata = field(default_factory=StateMetadata) + previous_state_id: Optional[str] = None + ttl: Optional[int] = None + + def __post_init__(self): + """Validate state after initialization.""" + # Validate UUIDs + try: + uuid.UUID(self.id) + uuid.UUID(self.entity_id) + if self.previous_state_id: + uuid.UUID(self.previous_state_id) + except ValueError as e: + raise ValueError(f"Invalid UUID format: {e}") + + # Validate version + if self.version < 1: + raise ValueError("Version must be >= 1") + + # Validate TTL + if self.ttl is not None and self.ttl < 0: + raise ValueError("TTL must be non-negative") + + def to_dict(self) -> Dict[str, Any]: + """Convert state to dictionary representation.""" + return { + "id": self.id, + "entity_id": self.entity_id, + "state_type": self.state_type.value, + "payload": self.payload, + "timestamp": self.timestamp.isoformat(), + "version": self.version, + "metadata": self.metadata.to_dict(), + "previous_state_id": self.previous_state_id, + "ttl": self.ttl + } + + def to_json(self, indent: Optional[int] = None) -> str: + """Serialize state to JSON string.""" + return json.dumps(self.to_dict(), indent=indent, default=str) + + @classmethod + def create( + cls, + entity_id: str, + state_type: StateType, + payload: Dict[str, Any], + version: int = 1, + metadata: Optional[StateMetadata] = None, + previous_state_id: Optional[str] = None, + ttl: Optional[int] = None + ) -> State: + """ + Factory method to create a new State with auto-generated ID and timestamp. + + Args: + entity_id: The entity this state belongs to + state_type: Type of state + payload: State data payload + version: Version number (default: 1) + metadata: Optional metadata + previous_state_id: Link to previous state + ttl: Optional time-to-live in seconds + + Returns: + New State instance + """ + return cls( + id=str(uuid.uuid4()), + entity_id=entity_id, + state_type=state_type, + payload=payload, + timestamp=datetime.now(timezone.utc), + version=version, + metadata=metadata or StateMetadata(), + previous_state_id=previous_state_id, + ttl=ttl + ) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> State: + """Create State from dictionary representation.""" + return cls( + id=data["id"], + entity_id=data["entity_id"], + state_type=StateType(data["state_type"]), + payload=data["payload"], + timestamp=datetime.fromisoformat(data["timestamp"].replace('Z', '+00:00')), + version=data["version"], + metadata=StateMetadata.from_dict(data.get("metadata", {})), + previous_state_id=data.get("previous_state_id"), + ttl=data.get("ttl") + ) + + @classmethod + def from_json(cls, json_str: str) -> State: + """Deserialize State from JSON string.""" + return cls.from_dict(json.loads(json_str)) + + def is_expired(self) -> bool: + """Check if this state has expired based on TTL.""" + if self.ttl is None: + return False + age = (datetime.now(timezone.utc) - self.timestamp).total_seconds() + return age > self.ttl + + def next_version(self, new_payload: Dict[str, Any]) -> State: + """ + Create a new State representing the next version of this state. + + Args: + new_payload: Updated payload for the new state + + Returns: + New State instance with incremented version + """ + return State.create( + entity_id=self.entity_id, + state_type=self.state_type, + payload=new_payload, + version=self.version + 1, + metadata=self.metadata, + previous_state_id=self.id, + ttl=self.ttl + ) + + +def load_schema() -> Dict[str, Any]: + """Load the JSON schema for validation.""" + schema_path = Path(__file__).parent.parent / "schemas" / "state.json" + with open(schema_path, 'r') as f: + return json.load(f) + + +# Example usage +if __name__ == "__main__": + # Create a sample state + state = State.create( + entity_id=str(uuid.uuid4()), + state_type=StateType.ACTIVE, + payload={"status": "running", "progress": 75}, + metadata=StateMetadata( + source="electra-archon", + created_by="electra", + tags=["seed", "priority"] + ) + ) + + print("Created State:") + print(state.to_json(indent=2)) + print(f"\nIs expired: {state.is_expired()}") + + # Create next version + next_state = state.next_version({"status": "running", "progress": 90}) + print(f"\nNext version: {next_state.version}") + print(f"Previous state ID: {next_state.previous_state_id}") diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..9855d94 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = -v --tb=short diff --git a/schemas/state.json b/schemas/state.json new file mode 100644 index 0000000..882bbdb --- /dev/null +++ b/schemas/state.json @@ -0,0 +1,99 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://allegro.local/schemas/state.json", + "title": "SEED State Schema", + "description": "JSON Schema for State objects in the SEED Architecture", + "type": "object", + "required": ["id", "entity_id", "state_type", "payload", "timestamp", "version"], + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for this state record (UUID v4)" + }, + "entity_id": { + "type": "string", + "format": "uuid", + "description": "Reference to the entity this state belongs to" + }, + "state_type": { + "type": "string", + "enum": ["active", "inactive", "pending", "archived", "deleted"], + "description": "The type/category of this state" + }, + "payload": { + "type": "object", + "description": "Flexible JSON payload containing state-specific data" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when this state was recorded" + }, + "version": { + "type": "integer", + "minimum": 1, + "description": "Version number for optimistic locking" + }, + "metadata": { + "type": "object", + "description": "Additional metadata about this state record", + "properties": { + "source": { + "type": "string", + "description": "Source system or service that created this state" + }, + "provenance": { + "type": "string", + "description": "Trace ID or provenance information" + }, + "created_by": { + "type": "string", + "description": "User or service that created this state" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional tags for categorization" + } + } + }, + "previous_state_id": { + "type": ["string", "null"], + "format": "uuid", + "description": "Reference to the previous state record for this entity (null if first)" + }, + "ttl": { + "type": ["integer", "null"], + "description": "Time-to-live in seconds (null for no expiration)" + } + }, + "additionalProperties": false, + "examples": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "entity_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", + "state_type": "active", + "payload": { + "status": "running", + "progress": 75, + "details": { + "last_action": "processing", + "queue_position": 3 + } + }, + "timestamp": "2026-04-02T19:30:00Z", + "version": 3, + "metadata": { + "source": "electra-archon", + "provenance": "trace-abc123", + "created_by": "electra", + "tags": ["seed", "priority"] + }, + "previous_state_id": "550e8400-e29b-41d4-a716-446655440001", + "ttl": null + } + ] +} diff --git a/tests/__pycache__/test_state.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_state.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..67645a98fa17646542ede12a85b8ea6f97a519eb GIT binary patch literal 40678 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 DyB`BE literal 0 HcmV?d00001 diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..f0064b8 --- /dev/null +++ b/tests/test_state.py @@ -0,0 +1,263 @@ +""" +Tests for State Model and Schema Validation +""" + +import json +import uuid +import pytest +from datetime import datetime, timezone +from pathlib import Path +import sys + +# Add parent to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from models.state import State, StateType, StateMetadata, load_schema + + +class TestStateMetadata: + """Tests for StateMetadata class.""" + + def test_default_creation(self): + """Test creating metadata with defaults.""" + meta = StateMetadata() + assert meta.source == "unknown" + assert meta.provenance is None + assert meta.created_by is None + assert meta.tags == [] + + def test_full_creation(self): + """Test creating metadata with all fields.""" + meta = StateMetadata( + source="test-source", + provenance="trace-123", + created_by="electra", + tags=["seed", "test"] + ) + assert meta.source == "test-source" + assert meta.provenance == "trace-123" + assert meta.created_by == "electra" + assert meta.tags == ["seed", "test"] + + def test_to_dict(self): + """Test metadata serialization.""" + meta = StateMetadata(source="test", tags=["a", "b"]) + d = meta.to_dict() + assert d["source"] == "test" + assert d["tags"] == ["a", "b"] + assert d["provenance"] is None + + def test_from_dict(self): + """Test metadata deserialization.""" + data = {"source": "src", "provenance": "prov", "created_by": "user", "tags": ["t"]} + meta = StateMetadata.from_dict(data) + assert meta.source == "src" + assert meta.provenance == "prov" + + +class TestState: + """Tests for State class.""" + + def test_create_with_defaults(self): + """Test State.create factory method.""" + entity_id = str(uuid.uuid4()) + state = State.create( + entity_id=entity_id, + state_type=StateType.ACTIVE, + payload={"key": "value"} + ) + + assert state.entity_id == entity_id + assert state.state_type == StateType.ACTIVE + assert state.payload == {"key": "value"} + assert state.version == 1 + assert state.previous_state_id is None + assert state.ttl is None + # Verify UUID format + uuid.UUID(state.id) # Should not raise + + def test_create_with_metadata(self): + """Test State.create with metadata.""" + meta = StateMetadata(source="test", tags=["important"]) + state = State.create( + entity_id=str(uuid.uuid4()), + state_type=StateType.PENDING, + payload={}, + metadata=meta + ) + assert state.metadata.source == "test" + assert state.metadata.tags == ["important"] + + def test_invalid_uuid_raises(self): + """Test that invalid UUIDs raise ValueError.""" + with pytest.raises(ValueError, match="Invalid UUID"): + State( + id="not-a-uuid", + entity_id=str(uuid.uuid4()), + state_type=StateType.ACTIVE, + payload={}, + timestamp=datetime.now(timezone.utc), + version=1 + ) + + def test_invalid_version_raises(self): + """Test that version < 1 raises ValueError.""" + with pytest.raises(ValueError, match="Version must be >= 1"): + State( + id=str(uuid.uuid4()), + entity_id=str(uuid.uuid4()), + state_type=StateType.ACTIVE, + payload={}, + timestamp=datetime.now(timezone.utc), + version=0 + ) + + def test_negative_ttl_raises(self): + """Test that negative TTL raises ValueError.""" + with pytest.raises(ValueError, match="TTL must be non-negative"): + State( + id=str(uuid.uuid4()), + entity_id=str(uuid.uuid4()), + state_type=StateType.ACTIVE, + payload={}, + timestamp=datetime.now(timezone.utc), + version=1, + ttl=-1 + ) + + def test_to_dict_serialization(self): + """Test state to dictionary conversion.""" + state = State.create( + entity_id=str(uuid.uuid4()), + state_type=StateType.ACTIVE, + payload={"count": 42} + ) + d = state.to_dict() + + assert d["id"] == state.id + assert d["entity_id"] == state.entity_id + assert d["state_type"] == "active" + assert d["payload"] == {"count": 42} + assert "timestamp" in d + assert d["version"] == 1 + + def test_to_json_serialization(self): + """Test state to JSON conversion.""" + state = State.create( + entity_id=str(uuid.uuid4()), + state_type=StateType.ARCHIVED, + payload={"archived": True} + ) + json_str = state.to_json() + + # Should be valid JSON + parsed = json.loads(json_str) + assert parsed["state_type"] == "archived" + assert parsed["payload"]["archived"] is True + + def test_from_dict_deserialization(self): + """Test state from dictionary conversion.""" + entity_id = str(uuid.uuid4()) + data = { + "id": str(uuid.uuid4()), + "entity_id": entity_id, + "state_type": "pending", + "payload": {"status": "waiting"}, + "timestamp": "2026-04-02T12:00:00+00:00", + "version": 2, + "metadata": {"source": "test"}, + "previous_state_id": str(uuid.uuid4()), + "ttl": 3600 + } + + state = State.from_dict(data) + assert state.entity_id == entity_id + assert state.state_type == StateType.PENDING + assert state.version == 2 + assert state.ttl == 3600 + + def test_from_json_deserialization(self): + """Test state from JSON conversion.""" + state = State.create( + entity_id=str(uuid.uuid4()), + state_type=StateType.DELETED, + payload={"deleted_at": "2026-04-01"} + ) + json_str = state.to_json() + + restored = State.from_json(json_str) + assert restored.id == state.id + assert restored.state_type == StateType.DELETED + assert restored.payload == state.payload + + def test_is_expired_no_ttl(self): + """Test that state without TTL never expires.""" + state = State.create( + entity_id=str(uuid.uuid4()), + state_type=StateType.ACTIVE, + payload={} + ) + assert not state.is_expired() + + def test_is_expired_with_ttl(self): + """Test TTL expiration logic.""" + state = State.create( + entity_id=str(uuid.uuid4()), + state_type=StateType.ACTIVE, + payload={}, + ttl=1 # 1 second TTL + ) + assert not state.is_expired() + # Note: We can't easily test actual expiration without sleeping + + def test_next_version(self): + """Test creating next version of state.""" + state = State.create( + entity_id=str(uuid.uuid4()), + state_type=StateType.ACTIVE, + payload={"count": 1}, + version=5 + ) + + next_state = state.next_version({"count": 2}) + + assert next_state.version == 6 + assert next_state.previous_state_id == state.id + assert next_state.entity_id == state.entity_id + assert next_state.payload == {"count": 2} + assert next_state.state_type == state.state_type + + def test_all_state_types(self): + """Test all state type enum values.""" + entity_id = str(uuid.uuid4()) + + for state_type in StateType: + state = State.create( + entity_id=entity_id, + state_type=state_type, + payload={} + ) + assert state.state_type == state_type + assert state.to_dict()["state_type"] == state_type.value + + +class TestSchema: + """Tests for JSON Schema.""" + + def test_schema_loads(self): + """Test that schema file loads correctly.""" + schema = load_schema() + assert schema["title"] == "SEED State Schema" + assert "properties" in schema + assert "id" in schema["required"] + assert "entity_id" in schema["required"] + + def test_schema_has_examples(self): + """Test that schema includes examples.""" + schema = load_schema() + assert "examples" in schema + assert len(schema["examples"]) > 0 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])