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 0000000..07ef30d Binary files /dev/null and b/models/__pycache__/state.cpython-312.pyc differ 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 0000000..67645a9 Binary files /dev/null and b/tests/__pycache__/test_state.cpython-312-pytest-9.0.2.pyc differ 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"])