Two Kinds of Data

PSP distinguishes between two fundamental record types:

Entities

Mutable records with identity. A Task can be updated, completed, deleted. Its state changes over time.

Facts

Immutable records of something that happened. Once written, never changed. Used for audit trails, events, and history.

Entities: Mutable State

Entities represent things that exist and change. They have:

  • Identity — A unique ID that persists across changes
  • Lifecycle — Created, updated, possibly deleted
  • Current state — Only the latest version matters
todos/domain/models.py
@dataclass
class Task:
    """Mutable entity: state changes over time."""
    id: UUID
    owner_id: UUID
    title: str
    status: TaskStatus  # changes: pending → completed
    priority: Priority   # can be updated
    due_date: date | None  # can be set/cleared

    def complete(self) -> None:
        if self.status == TaskStatus.COMPLETED:
            raise AlreadyCompletedError()
        self.status = TaskStatus.COMPLETED

When you update an entity, you're modifying its current state. Previous states are lost unless explicitly tracked.

Facts: Immutable History

Facts record what happened. They're append-only—you can add new facts but never modify existing ones.

todos/domain/events.py
@dataclass(frozen=True)
class TaskCompleted:
    """Fact: this happened and can't unhappen."""
    task_id: UUID
    owner_id: UUID
    completed_at: datetime
    completed_by: UUID

Facts use frozen=True to enforce immutability. The TaskCompleted event records a moment in time—even if the task is later "uncompleted," this fact remains.

Events as Facts

Domain events are the most common facts in PSP. They're wrapped in an EventEnvelope that adds metadata:

platform/contracts/envelope.py
@dataclass(frozen=True)
class EventEnvelope[T]:
    id: UUID               # Unique event ID
    timestamp: datetime    # When it happened
    actor_id: UUID         # Who did it
    correlation_id: UUID   # Request tracing
    payload: T             # The event itself

The envelope is also frozen—the entire event chain is immutable.

Activity Log: Structured Facts

The ActivityLogRepo stores facts about entity changes:

platform/audit/models.py
@dataclass(frozen=True)
class ActivityEntry:
    id: UUID
    entity_type: str      # "Task", "BudgetPlan"
    entity_id: UUID
    action: str           # "created", "completed"
    actor_id: UUID
    timestamp: datetime
    changes: dict         # {"status": ["pending", "completed"]}

This enables queries like "what changed on this task?" or "what did this user do today?"—answered by reading facts, not reconstructing state.

When to Use Each

Use Case Type Reason
Task, BudgetPlan, Person Entity Has identity, changes over time
TaskCompleted, BudgetSpent Fact (Event) Records something that happened
Audit log entry Fact Historical record, never modified
User preferences Entity (Facet) Can be updated
Ledger transaction Fact Append-only, balance computed from facts

Append-Only Patterns

Some domains are naturally fact-based. Budget's ledger is append-only:

budget/domain/models.py
@dataclass(frozen=True)
class LedgerEntry:
    """Immutable: to correct, add a reversal entry."""
    id: UUID
    owner_id: UUID
    token_type_id: UUID
    amount: int           # positive = credit, negative = debit
    reason: str
    timestamp: datetime
    reference_id: UUID | None  # task_id, debt_id, etc.

Balance is computed by summing entries, not stored as mutable state. To "undo" a transaction, add a reversing entry—the original fact remains.

Design Principle

When in doubt, ask: "Does this represent current state or something that happened?" Current state is an entity. Something that happened is a fact.

Rules
  • Facts MUST use frozen=True
  • Facts MUST NOT have update methods
  • To "correct" a fact, append a new fact (reversal, amendment)
  • Entities track current state; facts track history