Budget Component Architecture
01

Overview

The Budget component provides capacity planning for tasks. Users define token types (categories of effort like "deep work" or "admin"), allocate budgets per period (day/week), schedule tasks by reserving tokens, and handle overages via borrowing and debt repayment.

src/psp/components/budget/
budget/
├── __init__.py           # exports BudgetModule, BudgetComponent
├── component.py          # composition root (framework-agnostic)
├── module.py             # catalogue registration metadata
│
├── domain/
│   ├── models/
│   │   ├── token_type.py   # TokenType entity
│   │   ├── budget_plan.py  # BudgetPlan with policies
│   │   ├── period.py       # Period value object (day/week)
│   │   ├── transaction.py  # LedgerTransaction (append-only)
│   │   ├── scheduled_task.py # ScheduledTask with cost
│   │   ├── debt.py         # DebtObligation entity
│   │   └── snapshot.py     # PeriodSnapshot (derived)
│   ├── events.py           # 17 domain events
│   └── errors.py           # domain-specific errors
│
├── application/
│   ├── ports/
│   │   └── repos.py        # 5 repository protocols
│   └── use_cases/
│       ├── token_type_crud.py  # token type CRUD
│       ├── budget_plan_crud.py # budget plan CRUD
│       ├── snapshot.py         # snapshot calculations
│       ├── get_period_snapshot.py # lazy grants
│       ├── check_budget.py     # budget validation
│       ├── schedule_task.py    # task scheduling
│       ├── borrow.py           # debt management
│       └── advanced.py         # split, rollover, auto-balance
│
└── adapters/
    ├── http/
    │   ├── router.py         # FastAPI routes
    │   ├── dependencies.py   # DI wrappers
    │   └── schemas.py        # Pydantic models
    └── persistence/
        └── repo_memory.py    # in-memory implementations
02

Domain Entities

TokenType

Represents categories of effort or resources (e.g., deep work, admin, social). Token types are unique per owner.

budget/domain/models/token_type.py
@dataclass
class TokenType:
    id: UUID
    owner_id: UUID
    name: str           # unique per owner, max 32 chars
    color: str | None   # optional hex color
    created_at: datetime
    updated_at: datetime

    def __post_init__(self):
        # Invariant: name is lowercase alphanumeric with underscores
        if not re.match(r'^[a-z0-9_]+$', self.name):
            raise InvalidTokenTypeError("Invalid name format")

    def rename(self, new_name: str) -> "TokenType": ...
    def update_color(self, color: str | None) -> "TokenType": ...

BudgetPlan

Defines token allocations for a period type (day or week) with enforcement and rollover policies.

budget/domain/models/budget_plan.py
class EnforcementPolicy(Enum):
    HARD_LIMIT = "hard_limit"     # reject if over budget
    SOFT_LIMIT = "soft_limit"     # warn but allow
    AUTO_BORROW = "auto_borrow"   # borrow from future

class RolloverPolicy(Enum):
    NONE = "none"      # unused tokens expire
    FULL = "full"      # all unused carry over
    CAPPED = "capped"  # carry over up to cap

@dataclass
class BudgetPlan:
    id: UUID
    owner_id: UUID
    period_type: PeriodType       # DAY or WEEK
    effective_from: date
    allocations: dict[UUID, int]  # token_type_id -> amount
    enforcement_policy: EnforcementPolicy
    rollover_policy: RolloverPolicy
    rollover_cap: int | None      # for CAPPED policy

    def get_allocation(self, token_type_id: UUID) -> int: ...
    def with_allocation(self, token_type_id: UUID, amount: int) -> "BudgetPlan": ...

Period

Value object representing a budget time window. For WEEK periods, start_date must be a Monday.

budget/domain/models/period.py
class PeriodType(Enum):
    DAY = "day"
    WEEK = "week"

@dataclass(frozen=True)
class Period:
    start_date: date
    period_type: PeriodType

    def contains(self, d: date) -> bool: ...
    def next(self) -> "Period": ...
    def previous(self) -> "Period": ...

    @classmethod
    def for_date(cls, d: date, period_type: PeriodType) -> "Period": ...

    @classmethod
    def today(cls, period_type: PeriodType) -> "Period": ...

LedgerTransaction

Append-only record of budget changes. The ledger is the source of truth for budget state.

budget/domain/models/transaction.py
class TransactionType(Enum):
    GRANT = "grant"           # budget allocation
    RESERVE = "reserve"       # scheduled task
    RELEASE = "release"       # unscheduled task
    SPEND = "spend"           # completed task
    BORROW = "borrow"         # borrowed from future
    REPAY = "repay"           # debt repayment
    EXPIRE = "expire"         # unused budget expired
    ROLLOVER_IN = "rollover_in"  # carried from previous

@dataclass(frozen=True)
class LedgerTransaction:
    id: UUID
    owner_id: UUID
    period: Period
    token_type_id: UUID
    transaction_type: TransactionType
    amount: int              # positive or negative
    task_id: UUID | None     # reference to Todos component
    source_period: Period | None  # for REPAY transactions
    created_at: datetime

    @property
    def is_credit(self) -> bool: ...
    @property
    def is_debit(self) -> bool: ...

ScheduledTask and DebtObligation

  • ScheduledTask — Task scheduled within a budget period with reserved tokens. Status: RESERVED, ACTIVE, SPENT, RELEASED
  • DebtObligation — Tracks borrowed budget requiring repayment. Status: ACTIVE, PARTIALLY_PAID, PAID, FORGIVEN
  • TaskCost — Value object mapping token types to amounts
  • PeriodSnapshot — Derived read model aggregating granted, reserved, spent, borrowed per token type
03

Ports (Interfaces)

All persistence is abstracted through Protocol-based ports, enabling swappable implementations.

budget/application/ports/repos.py
class TokenTypeRepository(Protocol):
    def get(self, token_type_id: UUID) -> TokenType | None: ...
    def save(self, token_type: TokenType) -> None: ...
    def get_by_name(self, owner_id: UUID, name: str) -> TokenType | None: ...
    def list_by_owner(self, owner_id: UUID) -> list[TokenType]: ...

class BudgetPlanRepository(Protocol):
    def get_effective(
        self, owner_id: UUID, period_type: PeriodType, as_of: date
    ) -> BudgetPlan | None:
        """Get the budget plan effective on a given date."""
        ...

class LedgerRepository(Protocol):
    """Append-only ledger for budget transactions."""
    def append(self, transaction: LedgerTransaction) -> None: ...
    def get_transactions(
        self, owner_id: UUID, period: Period, token_type_id: UUID | None = None
    ) -> list[LedgerTransaction]: ...

class ScheduledTaskRepository(Protocol): ...
class DebtObligationRepository(Protocol): ...
Port Key Methods
TokenTypeRepository get, save, delete, get_by_name, list_by_owner, query
BudgetPlanRepository get, save, delete, get_effective, list_by_owner
LedgerRepository append, get_transactions, get_transactions_for_task, get_transactions_in_range
ScheduledTaskRepository get, save, delete, get_by_task_id, list_by_owner_and_period
DebtObligationRepository get, save, list_by_owner, get_total_outstanding
04

Use Cases

Use cases orchestrate domain logic with platform primitives. The Budget component has 25+ use cases organized by domain.

GetPeriodSnapshot

Aggregates ledger transactions into a budget snapshot. Implements lazy grants: if no GRANT transaction exists for a period, it auto-creates one from the effective budget plan.

budget/application/use_cases/get_period_snapshot.py
class GetPeriodSnapshot:
    def __init__(
        self,
        ledger_repo: LedgerRepository,
        plan_repo: BudgetPlanRepository,
        clock: Clock,
    ):
        ...

    def execute(self, owner_id: UUID, period: Period) -> PeriodSnapshot:
        # Get all transactions for this period
        transactions = self.ledger_repo.get_transactions(owner_id, period)

        # Check if GRANT exists, if not create it (lazy initialization)
        if not any(t.transaction_type == TransactionType.GRANT for t in transactions):
            plan = self.plan_repo.get_effective(owner_id, period.period_type, period.start_date)
            if plan:
                for token_type_id, amount in plan.allocations.items():
                    self.ledger_repo.append(LedgerTransaction(
                        transaction_type=TransactionType.GRANT,
                        amount=amount,
                        ...
                    ))

        # Aggregate into snapshot
        return self._calculate_snapshot(transactions)

ScheduleTask

Schedules a task within a budget period by reserving tokens. Integrates with the Todos component via task_id.

budget/application/use_cases/schedule_task.py
class ScheduleTask:
    def execute(self, input: ScheduleTaskInput) -> ScheduledTaskOutput:
        # 1. Check budget availability
        snapshot = self.get_snapshot.execute(input.owner_id, input.period)
        for token_type_id, amount in input.cost.costs.items():
            if snapshot.remaining(token_type_id) < amount:
                raise InsufficientBudgetError(...)

        # 2. Create scheduled task
        scheduled = ScheduledTask(
            id=uuid4(),
            task_id=input.task_id,
            owner_id=input.owner_id,
            period=input.period,
            cost=input.cost,
            status=ScheduledTaskStatus.RESERVED,
        )
        self.scheduled_repo.save(scheduled)

        # 3. Record RESERVE transactions
        for token_type_id, amount in input.cost.costs.items():
            self.ledger_repo.append(LedgerTransaction(
                transaction_type=TransactionType.RESERVE,
                amount=-amount,  # debit
                task_id=input.task_id,
                ...
            ))

        # 4. Publish event
        self.event_bus.publish(TaskScheduled(...))

Use Case Categories

Category Use Cases
Token Types CreateTokenType, UpdateTokenType, DeleteTokenType, GetTokenType, ListTokenTypes
Budget Plans CreateBudgetPlan, UpdateBudgetPlan, DeleteBudgetPlan, GetBudgetPlan, GetEffectiveBudgetPlan, ListBudgetPlans
Snapshots GetPeriodSnapshot, CalculateSnapshot, CalculateSnapshotRange, CheckBudget, EnforceBudget
Scheduling ScheduleTask, UnscheduleTask, RescheduleTask, SpendTaskBudget
Debt BorrowBudget, RepayDebt, ForgiveDebt, ListDebts
Advanced SplitTask, AutoBalance, ProcessRollover, GetPeriodBoundaryStatus
05

Component Composition

The BudgetComponent class is the framework-agnostic composition root. It wires up all use cases with their dependencies.

budget/component.py
class BudgetComponent:
    """Framework-agnostic composition root."""

    def __init__(
        self,
        # Repositories
        token_type_repo: TokenTypeRepository,
        budget_plan_repo: BudgetPlanRepository,
        ledger_repo: LedgerRepository,
        scheduled_task_repo: ScheduledTaskRepository,
        debt_repo: DebtObligationRepository,
        # Platform services
        clock: Clock,
        event_bus: EventBus,
        borrow_policy: BorrowPolicy,
    ) -> None:
        # Token type use cases
        self.create_token_type = CreateTokenType(token_type_repo, clock, event_bus)
        self.list_token_types = ListTokenTypes(token_type_repo)

        # Budget plan use cases
        self.create_budget_plan = CreateBudgetPlan(budget_plan_repo, clock, event_bus)
        self.get_effective_budget_plan = GetEffectiveBudgetPlan(budget_plan_repo)

        # Snapshot use cases
        self.get_period_snapshot = GetPeriodSnapshot(ledger_repo, budget_plan_repo, clock)
        self.check_budget = CheckBudget(self.get_period_snapshot)

        # Scheduling use cases
        self.schedule_task = ScheduleTask(
            scheduled_task_repo, ledger_repo, self.get_period_snapshot, clock, event_bus
        )
        self.spend_task_budget = SpendTaskBudget(
            scheduled_task_repo, ledger_repo, clock, event_bus
        )

        # Debt use cases
        self.borrow_budget = BorrowBudget(debt_repo, ledger_repo, borrow_policy, clock, event_bus)
        self.repay_debt = RepayDebt(debt_repo, ledger_repo, clock, event_bus)
        # ... all 25+ use cases

Factory Function

budget/component.py
def create_budget_component(
    *,
    clock: Clock | None = None,
    event_bus: EventBus | None = None,
    borrow_policy: BorrowPolicy | None = None,
) -> BudgetComponent:
    """Factory with default in-memory repositories."""
    return BudgetComponent(
        token_type_repo=InMemoryTokenTypeRepository(),
        budget_plan_repo=InMemoryBudgetPlanRepository(),
        ledger_repo=InMemoryLedgerRepository(),
        scheduled_task_repo=InMemoryScheduledTaskRepository(),
        debt_repo=InMemoryDebtObligationRepository(),
        clock=clock or SystemClock(),
        event_bus=event_bus or InMemoryEventBus(),
        borrow_policy=borrow_policy or DefaultBorrowPolicy(),
    )

# Usage
component = create_budget_component()
snapshot = component.get_period_snapshot.execute(owner_id, period)
Budget Flow
%%{init: {"flowchart": {"useMaxWidth": false}}}%% graph LR PLAN[BudgetPlan] --> GRANT[GRANT tx] GRANT --> SNAPSHOT[PeriodSnapshot] SCHEDULE[ScheduleTask] --> RESERVE[RESERVE tx] RESERVE --> SNAPSHOT COMPLETE[SpendTaskBudget] --> SPEND[SPEND tx] SPEND --> SNAPSHOT BORROW[BorrowBudget] --> BORROW_TX[BORROW tx] BORROW_TX --> DEBT[DebtObligation] BORROW_TX --> SNAPSHOT style SNAPSHOT fill:#f3e8ff,stroke:#9333ea style DEBT fill:#fef3c7,stroke:#d97706
06

HTTP Routes

All routes are prefixed with /v1/budget. The router is thin—parse, call use case, return.

budget/adapters/http/router.py (example)
@router.get("/snapshot/{period_date}")
def get_snapshot(
    period_date: date,
    period_type: PeriodType = Query(PeriodType.DAY),
    x_owner_id: UUID = Header(...),
    use_case: GetPeriodSnapshot = Depends(get_period_snapshot),
) -> SnapshotResponse:
    period = Period.for_date(period_date, period_type)
    snapshot = use_case.execute(x_owner_id, period)
    return snapshot_to_response(snapshot)

@router.post("/schedule")
def schedule_task(
    request: ScheduleTaskRequest,
    x_owner_id: UUID = Header(...),
    use_case: ScheduleTask = Depends(get_schedule_task),
) -> ScheduledTaskResponse:
    output = use_case.execute(ScheduleTaskInput(
        owner_id=x_owner_id,
        task_id=request.task_id,
        period=Period.for_date(request.period_date, request.period_type),
        cost=TaskCost(request.costs),
    ))
    return scheduled_task_to_response(output)

API Endpoints

Method Path Description
POST /token-types Create token type
GET /token-types List owner's token types
PATCH /token-types/{id} Update token type
POST /plans Create budget plan
GET /plans List budget plans
GET /snapshot/{date} Get period snapshot
POST /schedule Schedule task
POST /scheduled-tasks/{id}/unschedule Unschedule task
POST /scheduled-tasks/{id}/complete Spend budget on completion
GET /debts Get debt summary
POST /borrow Borrow budget
POST /debts/{id}/repay Repay debt
07

Cross-Component Integration

The Budget component integrates with Todos via task_id references and the hook system.

Task Scheduling Flow

When a task is scheduled in Budget, it references a task from the Todos component by task_id. Budget doesn't store task data—it only tracks the cost allocation.

Integration Flow
# 1. Create task in Todos
task = todos.create_task.execute(CreateTaskInput(title="Write report", ...))

# 2. Schedule task in Budget with cost
scheduled = budget.schedule_task.execute(ScheduleTaskInput(
    task_id=task.id,  # Reference to Todos task
    period=Period.today(PeriodType.DAY),
    cost=TaskCost({deep_work_token_id: 4}),  # 4 hours of deep work
))

# 3. When task completes in Todos, spend budget in Budget
budget.spend_task_budget.execute(SpendTaskBudgetInput(
    scheduled_task_id=scheduled.id,
))

Hook Integration

Budget can subscribe to Todos' BEFORE_COMPLETE_TASK hook to veto task completion if budget would be exceeded:

Hook Example
# Register hook handler
hooks.register(BEFORE_COMPLETE_TASK, budget_enforcement_handler)

def budget_enforcement_handler(task_id: UUID) -> HookResult:
    scheduled = budget.scheduled_task_repo.get_by_task_id(task_id)
    if not scheduled:
        return HookResult.allow()  # Unscheduled task, allow

    # Check if spending would exceed budget
    result = budget.check_budget.execute(
        scheduled.owner_id,
        scheduled.period,
        scheduled.cost,
    )
    if result.exceeds_hard_limit:
        return HookResult.veto("Insufficient budget for this task")

    return HookResult.allow()
Component Integration
%%{init: {"flowchart": {"useMaxWidth": false}}}%% graph TB TODOS[Todos Component] -->|task_id| BUDGET[Budget Component] BUDGET -->|BEFORE_COMPLETE hook| TODOS BUDGET -->|publishes| EVENTS[TaskScheduled, BudgetSpent] TODOS -->|publishes| EVENTS2[TaskCompleted] style TODOS fill:#ecfeff,stroke:#0891b2 style BUDGET fill:#f3e8ff,stroke:#9333ea
08

Domain Events

The Budget component publishes 17 domain events for inter-component communication.

budget/domain/events.py
# Token type events
TokenTypeCreated, TokenTypeUpdated, TokenTypeDeleted

# Budget plan events
BudgetPlanCreated, BudgetPlanUpdated, BudgetGranted

# Transaction events
@dataclass(frozen=True)
class TransactionRecorded:
    transaction_id: UUID
    owner_id: UUID
    period: Period
    transaction_type: TransactionType
    amount: int

# Scheduling events
TaskScheduled, TaskUnscheduled, TaskRescheduled, BudgetSpent, TaskSplit

# Debt events
@dataclass(frozen=True)
class BudgetBorrowed:
    debt_id: UUID
    owner_id: UUID
    token_type_id: UUID
    amount: int
    source_period: Period

DebtRepaid, DebtForgiven

# Period events
BudgetRolledOver, BudgetAutoBalanced
09

Module Registration

The BudgetModule class registers the component with the application host:

budget/module.py
from psp.runtime.module import Module

class BudgetModule(Module):
    name = "budget"
    version = "0.1.0"
    status = ModuleStatus.ALPHA

    @property
    def capabilities(self) -> list[str]:
        return ["commands", "queries", "events"]

    def router(self, ctx: RuntimeContext) -> APIRouter:
        return create_router()

    def describe(self) -> ComponentSpec:
        """Return catalogue metadata."""
        return ComponentSpec(
            name=self.name,
            ports=self._get_ports(),
            schemas=self._get_schemas(),
            events=self._get_events(),
            stories=self._get_stories(),
            examples=self._get_examples(),
        )
Key Patterns
  • Append-only ledger — All budget changes recorded as immutable transactions
  • Lazy grants — Budget allocated on-demand from effective plan
  • Policy pattern — Enforcement, rollover, and borrow policies are pluggable
  • Cross-component references — Tasks referenced by ID, not duplicated
  • Derived snapshots — PeriodSnapshot computed from ledger, not stored
  • Value objects — Period, TaskCost are immutable and validated