Record Types
Mutable entities vs immutable facts
Two Kinds of Data
PSP distinguishes between two fundamental record types:
Mutable records with identity. A Task can be updated, completed, deleted. Its state changes over time.
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
@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.
@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:
@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:
@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:
@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.
When in doubt, ask: "Does this represent current state or something that happened?" Current state is an entity. Something that happened is a fact.
- 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