Budget Threshold Attestation

This document describes how unknown budget thresholds are attested in verdict bundles for reproducibility and audit purposes.

Overview

Budget attestation captures the budget configuration applied during policy evaluation, enabling:

Budget Check Predicate

The budget check is included in the verdict predicate:

{
  "_type": "https://stellaops.dev/predicates/policy-verdict@v1",
  "tenantId": "tenant-1",
  "policyId": "default-policy",
  "policyVersion": 1,
  "verdict": { ... },
  "budgetCheck": {
    "environment": "production",
    "config": {
      "maxUnknownCount": 10,
      "maxCumulativeUncertainty": 2.5,
      "action": "warn",
      "reasonLimits": {
        "Reachability": 5,
        "Identity": 3
      }
    },
    "actualCounts": {
      "total": 3,
      "cumulativeUncertainty": 1.2,
      "byReason": {
        "Reachability": 2,
        "Identity": 1
      }
    },
    "result": "pass",
    "configHash": "sha256:abc123...",
    "evaluatedAt": "2025-12-25T12:00:00Z",
    "violations": []
  }
}

Fields

budgetCheck.config

FieldTypeDescription
maxUnknownCountintMaximum total unknowns allowed
maxCumulativeUncertaintydoubleMaximum uncertainty score
actionstringAction when exceeded: warn, block
reasonLimitsobjectPer-reason code limits

budgetCheck.actualCounts

FieldTypeDescription
totalintTotal unknowns observed
cumulativeUncertaintydoubleSum of uncertainty factors
byReasonobjectBreakdown by reason code

budgetCheck.result

Possible values:

budgetCheck.configHash

SHA-256 hash of the budget configuration for determinism verification. Format: sha256:{64 hex characters}

budgetCheck.violations

List of violations when limits are exceeded:

{
  "violations": [
    {
      "type": "total",
      "limit": 10,
      "actual": 15
    },
    {
      "type": "reason",
      "limit": 5,
      "actual": 8,
      "reason": "Reachability"
    }
  ]
}

Usage

Extracting Budget Check from Verdict

using StellaOps.Policy.Engine.Attestation;

// Parse verdict predicate from DSSE envelope
var predicate = VerdictPredicate.Parse(dssePayload);

// Access budget check
if (predicate.BudgetCheck is not null)
{
    var check = predicate.BudgetCheck;
    Console.WriteLine($"Environment: {check.Environment}");
    Console.WriteLine($"Result: {check.Result}");
    Console.WriteLine($"Total: {check.ActualCounts.Total}/{check.Config.MaxUnknownCount}");
    Console.WriteLine($"Config Hash: {check.ConfigHash}");
}

Verifying Configuration Hash

// Compute expected hash from current configuration
var currentConfig = new VerdictBudgetConfig(
    maxUnknownCount: 10,
    maxCumulativeUncertainty: 2.5,
    action: "warn");

var expectedHash = VerdictBudgetCheck.ComputeConfigHash(currentConfig);

// Compare with attested hash
if (predicate.BudgetCheck?.ConfigHash != expectedHash)
{
    Console.WriteLine("Warning: Budget configuration has changed since attestation");
}

Determinism

The config hash ensures reproducibility:

  1. Configuration is serialized to JSON with canonical ordering
  2. SHA-256 is computed over the UTF-8 bytes
  3. Hash is prefixed with sha256: algorithm identifier

This allows verification that the same budget configuration was used across runs.

Integration Points

VerdictPredicateBuilder

Budget check is added when building verdict predicates:

var budgetCheck = new VerdictBudgetCheck(
    environment: context.Environment,
    config: config,
    actualCounts: counts,
    result: budgetResult.Passed ? "pass" : budgetResult.Budget.Action.ToString(),
    configHash: VerdictBudgetCheck.ComputeConfigHash(config),
    evaluatedAt: DateTimeOffset.UtcNow,
    violations: violations);

var predicate = new VerdictPredicate(
    tenantId: trace.TenantId,
    policyId: trace.PolicyId,
    // ... other fields
    budgetCheck: budgetCheck);

UnknownBudgetService

The enhanced BudgetCheckResult includes all data needed for attestation:

var result = await budgetService.CheckBudget(environment, unknowns);

// result.Budget - the configuration applied
// result.CountsByReason - breakdown for attestation
// result.CumulativeUncertainty - total uncertainty score

Risk Budget Enforcement

Inventory and tenant ownership were verified against source commit 6a8e9f038d on 2026-10-07. Re-run PolicyEngineRiskBudgetInventoryTests, PostgresBudgetStoreTests and BudgetInventoryTests; the owner-path receipt is docs/implplan/_evidence/20261007-policy-budget-owner/pcd-451.json. Reason/history and atomic consumption were verified against d2a4147aee on 2026-10-07. Re-run PolicyEngineRiskBudgetReasonTests, BudgetReasonTests and the storage/CLI budget tests; the companion owner-path receipt is pcd-452.json in that evidence directory.

This section describes the risk budget enforcement system that tracks and controls release risk accumulation over time.

Overview

Risk budgets limit the cumulative risk accepted during a budget window (typically monthly). Release checks derive risk points from the service tier, change category, operational context, and mitigation factors. Consumption records explicitly supplied risk points against a release ID and fails when the remaining budget is insufficient.

Key Concepts

Service Tiers

Services are classified by criticality, which determines their risk budget allocation:

TierNameMonthly AllocationDescription
0Internal300 RPInternal-only, low business impact
1Customer-Facing Non-Critical200 RPCustomer-facing but non-critical
2Customer-Facing Critical120 RPCritical customer-facing services
3Safety-Critical80 RPSafety, financial, or data-critical

Budget Status Thresholds

Budget status transitions based on percentage consumed:

StatusThresholdBehavior
Green< 40% consumedNormal operations
Yellow40-69% consumedIncreased caution, warnings triggered
Red70-99% consumedHigh-risk diffs frozen, only low-risk allowed
Exhausted>= 100% consumedIncident and security fixes only

Budget Windows

API Endpoints

The release risk-budget family is mounted by Policy Engine at /api/v1/policy/budget and requires an authenticated tenant context for every route. Read operations require policy:read; consumption requires policy:operate; allocation adjustment requires policy:edit. The separate unknown-budget configuration/status family is mounted at /api/v1/policy/budgets and applies the same tenant-context endpoint filter. This is required for Router transport dispatch because it invokes the matched endpoint without Policy’s tenant middleware: the filter bridges the already verified signed-envelope identity into Policy’s request-local tenant accessor for the handler invocation. Tenant-sensitive reads then normalize that identity through the canonical tenant directory before data access: UUID claims pass through, active slugs such as default resolve to shared.tenants.id, and missing, inactive, or unknown identifiers return HTTP 400 without constructing the tenant-scoped repository. The same canonical UUID establishes the PostgreSQL RLS session; no synthetic or public tenant fallback is permitted. Both families use the Policy PostgreSQL schema, which is migrated automatically by Policy Engine at startup.

Check Budget Status

GET /api/v1/policy/budget/status/{serviceId}?window={yyyy-MM}

Response:

{
  "budgetId": "budget:11111111-1111-1111-1111-111111111111:my-service:2025-12",
  "serviceId": "my-service",
  "tier": "CustomerFacingNonCritical",
  "window": "2025-12",
  "allocated": 200,
  "consumed": 85,
  "remaining": 115,
  "percentageUsed": 42.5,
  "status": "yellow",
  "updatedAt": "2025-12-15T10:30:00Z"
}

Record Consumption

POST /api/v1/policy/budget/consume
Content-Type: application/json

{
  "serviceId": "my-service",
  "riskPoints": 25,
  "releaseId": "v1.2.3",
  "reason": "Release approved after the maintenance review"
}

Consumption preserves an optional reason of at most 512 UTF-16 code units in the tenant’s history entry. Omission stores SQL NULL. Operator text, including Unicode, is retained unchanged in PostgreSQL and exported as UTF-8 JSON. A successful consumption atomically deducts points and appends its entry; an entry failure rolls back the debit. Concurrent requests check headroom against the locked current row. Each request appends separately, including repeated release IDs; release ID is not an idempotency key. The owner consumption request requires positive risk points and nonblank service/release identifiers of at most 128 UTF-16 code units each. Invalid inputs return HTTP 400 without an entry or debit. Internal calculated zero-risk releases can retain a reasoned entry without changing the balance.

Adjust Allocation (Earned Capacity)

POST /api/v1/policy/budget/adjust
Content-Type: application/json

{
  "serviceId": "my-service",
  "adjustment": 40,
  "reason": "MTTR improvement over 2 months"
}

View History

GET /api/v1/policy/budget/history/{serviceId}?window={yyyy-MM}

Each entry includes entryId, releaseId, riskPoints, consumedAt and optional reason. The reason reflects the persisted consumption entry, including after a new owner host reads it. History is scoped to the admitted tenant and requested or active window, ordered by consumed time descending and entry ID as an ordinal tie-breaker.

Check a Proposed Release

POST /api/v1/policy/budget/check
Content-Type: application/json

List Budgets

GET /api/v1/policy/budget/list?status={status}&window={yyyy-MM}&limit={limit}

The inventory queries persisted budgets owned by the admitted tenant. status accepts green, yellow, red or exhausted (case-insensitive). An explicit window must be yyyy-MM; omission includes all persisted windows. limit defaults to 50 and accepts 1 through 1000. totalCount is the number of matching rows before applying the limit. Rows are ordered by update time descending, then service, window and budget ID.

A successful empty inventory means no persisted budgets match those tenant-bound filters. The read does not create default budgets. Invalid inputs return HTTP 400; unavailable PostgreSQL returns HTTP 503 with error_code: policy.budget.persistence_unavailable. An inactive or unresolved tenant returns HTTP 400 with policy.budget.tenant_invalid before budget data access. Missing tenant admission is rejected by the tenant filter.

Forward migration 021_budget_tenant_inventory.sql keys service/window budgets by tenant and scopes consumption history to the same owner. Existing history inherits ownership only from its actual ledger row. Unattributed historical rows remain stored and are excluded by the strict tenant policies; the migration assigns no fallback tenant.

CLI Commands

The budget command guide describes all options, scopes, owner response fields and failure behavior. These commands require an authenticated tenant and a reachable Policy Engine through the configured backend.

Check Status

stella budget status --service my-service --window 2026-10 --output json

Status retains the owner’s budget ID, named string tier, window, allocated/consumed/remaining points, percentage used, status and update timestamp.

Consume Budget

stella budget consume --service my-service --points 25 --release-id v1.2.3 --reason "Release v1.2.3"

--points becomes riskPoints in the owner request. The release ID is required. The optional reason is preserved in the tenant’s consumption entry, with a maximum of 512 UTF-16 code units.

Check a Proposed Release

stella budget check --service my-service --tier CustomerFacingNonCritical --diff-category ApiBackwardCompatible --rollback-plan --output json

Policy computes the risk points from category, operational context and mitigation inputs. Blocked decisions exit nonzero by default (--fail-on-block true); a check does not consume points.

View History

stella budget history --service my-service --window 2026-10 --limit 20 --output json

The owner returns the full window. --limit only limits text output; JSON retains all returned entries and their recorded reasons. History has neither server paging nor a total count.

List Budgets

stella budget list --status yellow --window 2026-10 --limit 50

The status filter accepts a single state. The CLI preserves authoritative empty inventory and exits zero. Persistence, transport and malformed-result failures exit nonzero without emitting a budget result.

Earned Capacity Replenishment

Services demonstrating improved reliability can earn additional budget capacity:

Eligibility Criteria

  1. MTTR Improvement: Mean Time to Remediate must improve for 2 consecutive windows
  2. CFR Improvement: Change Failure Rate must improve for 2 consecutive windows
  3. No Major Incidents: No P1 incidents in the evaluation period

Increase Calculation

Example

Service: payment-api (Tier 2, base 120 RP)
MTTR: 48h → 36h → 24h (50% improvement)
CFR:  15% → 12% → 8%  (47% improvement)

Earned capacity: +20% = 24 RP
New allocation: 144 RP for next window

Notifications

Budget threshold transitions trigger notifications:

Warning (Yellow)

Sent when budget reaches 40% consumption:

Subject: [Warning] Risk Budget at 40% for my-service

Your risk budget for my-service has reached the warning threshold.

Current: 80 / 200 RP (40%)
Status: Yellow

Consider pausing non-critical changes until the next budget window.

Critical (Red/Exhausted)

Sent when budget reaches 70% or 100%:

Subject: [Critical] Risk Budget Exhausted for my-service

Your risk budget for my-service has been exhausted.

Current: 200 / 200 RP (100%)
Status: Exhausted

Only security fixes and incident responses are allowed.
Contact the Platform team for emergency capacity.

Channels

Notifications are sent via:

Database Schema

Relevant owner columns and keys after startup convergence (additional audit fields omitted):

CREATE TABLE policy.budget_ledger (
    budget_id      VARCHAR(256) PRIMARY KEY,
    service_id     VARCHAR(128) NOT NULL,
    tenant_id      VARCHAR(64),
    tier           INTEGER NOT NULL,
    "window"       VARCHAR(16) NOT NULL,
    allocated      INTEGER NOT NULL,
    consumed       INTEGER NOT NULL DEFAULT 0,
    status         VARCHAR(16) NOT NULL DEFAULT 'green',
    created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE(tenant_id, service_id, "window")
);

CREATE TABLE policy.budget_entries (
    entry_id       VARCHAR(64) PRIMARY KEY,
    tenant_id      VARCHAR(64),
    service_id     VARCHAR(128) NOT NULL,
    "window"       VARCHAR(16) NOT NULL,
    release_id     VARCHAR(128) NOT NULL,
    risk_points    INTEGER NOT NULL,
    reason         VARCHAR(512),
    consumed_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    FOREIGN KEY (tenant_id, service_id, "window")
        REFERENCES policy.budget_ledger(tenant_id, service_id, "window") ON DELETE CASCADE
);

CREATE INDEX idx_budget_entries_tenant_service_window_consumed
    ON policy.budget_entries(tenant_id, service_id, "window", consumed_at DESC, entry_id);

Configuration

# etc/policy.yaml
policy:
  riskBudget:
    enabled: true
    windowCadence: monthly  # monthly | weekly | sprint
    carryOver: false
    defaultTier: 1

    tiers:
      0: { name: Internal, allocation: 300 }
      1: { name: CustomerFacingNonCritical, allocation: 200 }
      2: { name: CustomerFacingCritical, allocation: 120 }
      3: { name: SafetyCritical, allocation: 80 }

    thresholds:
      yellow: 40
      red: 70
      exhausted: 100

    notifications:
      enabled: true
      channels: [email, slack]
      aggregationWindow: 1h  # Debounce rapid transitions

    earnedCapacity:
      enabled: true
      requiredImprovementWindows: 2
      minIncreasePercent: 10
      maxIncreasePercent: 20