Skip to content

symfonic.platform.budget

budget

BUD-1..7 — the admission decision, and nothing else.

Three shipped defects retire here.

  1. The string-prefix branch. str(exc).startswith("Budget exceeded:") appears in /chat, /stream and /stream/typed. Reword the message and a 429 becomes a 500. :class:BudgetExceededError carries the decision in its class and its code.
  2. The raw header read. The shipped dependency reads X-Tenant-ID directly so it can run before scope construction. That let an unauthenticated caller probe another tenant's budget state; BUD-3 retires it, and this service takes an already-derived principal so the shortcut cannot be spelled.
  3. The silent no-op. With no tracker bound the gate does nothing — correct, and preserved — but says nothing either. :meth:BudgetService.describe makes "budgets are off" an observable deployment property (BUD-7).

BudgetService

BudgetService(*, budget_check: BudgetCheck | None, cost_read_model: CostReadModel | None = None, audit: AuditSeam | None = None)

Admission from the narrow port, plus the cost read models.

Source code in src/symfonic/platform/budget.py
def __init__(
    self,
    *,
    budget_check: BudgetCheck | None,
    cost_read_model: CostReadModel | None = None,
    audit: AuditSeam | None = None,
) -> None:
    self._check = budget_check
    self._read_model = cost_read_model
    self._audit = audit if audit is not None else AuditSeam()

admit async

admit(principal: AuthenticatedPrincipal) -> BudgetDecision

The decision, without raising. enforce is the gate.

Split because BUD-1 has two consumers: the platform admits a request, and the invocation re-checks before its own effects. Both read the same :class:BudgetDecision; only the first turns it into a refusal status.

Source code in src/symfonic/platform/budget.py
async def admit(self, principal: AuthenticatedPrincipal) -> BudgetDecision:
    """The decision, without raising. ``enforce`` is the gate.

    Split because BUD-1 has two consumers: the platform admits a request,
    and the invocation re-checks before its own effects. Both read the same
    :class:`BudgetDecision`; only the first turns it into a refusal status.
    """
    if self._check is None:
        return BudgetDecision(allowed=True, code="budget_unenforced")
    try:
        return await self._check.check(
            principal.scope, is_admin=principal.is_admin
        )
    except Exception as exc:  # noqa: BLE001 - fail closed, then say why
        logger.warning(
            "budget port failed for scope=%s: %s", principal.scope.scope_key, exc
        )
        return BudgetDecision(
            allowed=False,
            reason=f"budget port unavailable: {type(exc).__name__}",
            code="budget_unavailable",
        )

describe

describe() -> dict[str, Any]

BUD-7: what this host does about budgets, stated rather than implied.

Source code in src/symfonic/platform/budget.py
def describe(self) -> dict[str, Any]:
    """BUD-7: what this host does about budgets, stated rather than implied."""
    return {
        "enforcing": self._check is not None,
        "cost_read_model": self._read_model is not None,
    }

enforce async

enforce(principal: AuthenticatedPrincipal) -> BudgetDecision

SCOPE-14 step 5: fail-closed, evented, typed.

A broken ledger denies rather than admits (SEC-FCP-1). The alternative — "the accountant is down, so everything is free" — is the failure mode a budget exists to prevent.

Source code in src/symfonic/platform/budget.py
async def enforce(self, principal: AuthenticatedPrincipal) -> BudgetDecision:
    """SCOPE-14 step 5: fail-closed, evented, typed.

    A broken ledger denies rather than admits (SEC-FCP-1). The alternative —
    "the accountant is down, so everything is free" — is the failure mode a
    budget exists to prevent.
    """
    decision = await self.admit(principal)
    if decision.allowed:
        return decision
    await self._audit.record(
        AuditRecord(
            action="budget_denied",
            outcome="denied",
            principal_id=principal.principal_id,
            scope_key=principal.scope.scope_key,
            resource_type="budget",
            metadata={"code": decision.code, "reason": decision.reason or ""},
        )
    )
    raise BudgetExceededError(
        f"budget refused for {principal.scope.scope_key}: {decision.reason}",
        code=decision.code if decision.code != "budget_ok" else "budget_exceeded",
        reason=decision.reason,
        retry_after=decision.retry_after_seconds,
        scope_key=principal.scope.scope_key,
    )

summarize async

summarize(principal: AuthenticatedPrincipal, *, window: str) -> Any | None

BUD-6: a projection, scoped to the caller like every other route.

Returns None when no read model is bound — an absent projection, not an empty one. Reporting zeros for a ledger nobody is keeping is how a cost dashboard learns to lie.

Source code in src/symfonic/platform/budget.py
async def summarize(
    self, principal: AuthenticatedPrincipal, *, window: str
) -> Any | None:
    """BUD-6: a projection, scoped to the caller like every other route.

    Returns ``None`` when no read model is bound — an absent projection, not
    an empty one. Reporting zeros for a ledger nobody is keeping is how a
    cost dashboard learns to lie.
    """
    if self._read_model is None:
        return None
    return await self._read_model.summarize(principal.scope, window=window)