Skip to main content

Capability Budgets

Capability budgets allow you to limit how many times a function can perform a particular effect. This provides fine-grained resource control, preventing runaway loops, rate-limiting API calls, and enforcing resource constraints at the function level.

Why Budgets?

Traditional capability systems are binary: either you can perform an effect or you can't. But in practice, you often want more nuanced control:

ScenarioWithout BudgetsWith Budgets
API rate limitingManual counting! {Net @limit=100}
Logging controlGlobal config! {IO @limit=5}
Resource exhaustionRuntime OOMBounded failure
TestingMock everythingLimit side effects
Audit verificationManual checking! {IO @min=1}
Cache bypassFlags/timestamps! {Net @min=1}

Capability budgets give you:

  • Compile-time documentation: See resource usage in function signatures
  • Runtime enforcement: Automatic failure when limits exceeded
  • Per-invocation isolation: Each function call gets a fresh budget
  • Composable limits: Nested calls respect their own budgets

Basic Syntax

Add @limit=N to set a maximum budget, or @min=N to require a minimum usage:

examples/reference/budget_basic.ail
-- Basic Capability Budget Example
-- Demonstrates the @limit=N syntax for limiting effect operations
--
-- Run with: ailang run --caps IO --entry main examples/reference/budget_basic.ail

module examples/reference/budget_basic

import std/io (println)

-- Function limited to 2 IO operations
-- The @limit=2 annotation limits println calls to 2 per invocation
export func limited() -> () ! {IO @limit=2} {
println("Call 1");
println("Call 2");
-- Uncommenting the next line would cause BudgetExhaustedError:
-- println("Call 3");
()
}

export func main() -> () ! {IO} {
println("Testing budget limit of 2...");
limited();
println("Success! Function stayed within budget.")
}

Run with:

ailang run --caps IO --entry main examples/reference/budget_basic.ail

Syntax Rules

-- Maximum limit: can use at most 5 IO operations
func f() -> () ! {IO @limit=5} { ... }

-- Minimum requirement: must use at least 1 IO operation
func g() -> () ! {IO @min=1} { ... }

-- Combined: between 1 and 5 IO operations (inclusive)
func h() -> () ! {IO @min=1 @limit=5} { ... }

-- Multiple effects with different budgets
func i() -> () ! {IO @limit=5, Net @min=1 @limit=3} { ... }

-- No budget = unlimited, no minimum
func unlimited() -> () ! {IO} { ... }
AnnotationMeaning
@limit=NMaximum N operations allowed (budget exhausted if exceeded)
@min=NMinimum N operations required (budget underrun if not met)
@min=M @limit=NBetween M and N operations (inclusive)

Per-Invocation Semantics

Each function call gets a fresh budget. The budget is not shared across calls:

examples/reference/budget_per_invocation.ail
-- Per-Invocation Budget Semantics
-- Each function call gets a fresh budget (not shared across calls)
--
-- Run with: ailang run --caps IO --entry main examples/reference/budget_per_invocation.ail

module examples/reference/budget_per_invocation

import std/io (println)

-- Function with budget of 2 IO operations per call
export func twoIOs() -> () ! {IO @limit=2} {
println(" A");
println(" B");
()
}

export func main() -> () ! {IO} {
println("First call:");
twoIOs();

println("Second call:");
twoIOs();

println("Third call:");
twoIOs();

println("All three calls succeeded - each got fresh budget of 2!")
}

Output:

First call:
A
B
Second call:
A
B
Third call:
A
B
All three calls succeeded - each got fresh budget of 2!

All three calls succeed because each gets its own budget of 2.

Budget Exhaustion

When a function exceeds its budget, it fails with a BudgetExhaustedError:

examples/reference/budget_exhausted.ail
-- Budget Exhaustion Example
-- Demonstrates what happens when a function exceeds its budget
--
-- Run with: ailang run --caps IO --entry main examples/reference/budget_exhausted.ail
-- Expected: BudgetExhaustedError after 2 println calls

module examples/reference/budget_exhausted

import std/io (println)

-- Function that will exceed its budget of 2
export func exceeds_budget() -> () ! {IO @limit=2} {
println("Call 1");
println("Call 2");
println("Call 3"); -- This fails: budget exhausted
()
}

export func main() -> () ! {IO} {
println("Attempting to exceed budget...");
exceeds_budget()
}

Output:

Attempting to exceed budget...
Call 1
Call 2
Error: effect 'IO' budget exhausted: limit=2, used=2
Hint: Increase the budget with @limit=N or refactor to use fewer IO operations

The error includes:

  • Effect name: Which effect was exhausted
  • Limit: The configured budget
  • Used: How many operations were performed
  • Hint: Suggestions for fixing the issue

Minimum Budgets

Sometimes you need to verify that an effect actually occurred rather than being skipped or cached. The @min=N annotation enforces that a function performs at least N operations:

examples/reference/budget_minimum.ail
-- Minimum Budget Example
--
-- Demonstrates @min annotation to require at least N effect operations.
-- Use cases: audit logging, cache bypass verification, API call confirmation.

module examples/reference/budget_minimum

import std/io (println)

-- This function MUST perform at least 1 IO operation.
-- If it returns without any println calls, it fails with BudgetUnderrunError.
func auditLog(action: string, success: bool) -> () ! {IO @min=1 @limit=2} {
-- This println satisfies @min=1 requirement
println("AUDIT: " ++ action);
-- Optionally log failure (still within @limit=2)
if not success then
println(" Status: FAILED")
else
()
}

-- Combined @min and @limit: between 1 and 3 operations
func verifiedFetch(resource: string) -> string ! {IO @min=1 @limit=3} {
-- At least 1 IO required (proves we actually did something)
println("Fetching: " ++ resource);
-- Can do up to 2 more (total 3)
println("Response received");
"data from " ++ resource
}

export func main() -> () ! {IO} {
println("=== Minimum Budget Demo ===");
println("");

println("1. Audit logging with @min=1 @limit=2:");
auditLog("user_login", true);
auditLog("payment_failed", false);
println("");

println("2. Verified fetch with @min=1 @limit=3:");
let data = verifiedFetch("api/users");
println("Got: " ++ data);
()
}

Use cases for @min:

ScenarioWhy Minimum Matters
API verificationEnsure actual API call happened, not cached response
Audit loggingVerify logging actually occurred for compliance
Data fetchingConfirm fresh data was retrieved, not stale cache
TestingAssert that effects were exercised, not mocked away

Budget Underrun

If a function with @min doesn't perform enough operations, it fails with a BudgetUnderrunError:

Error: effect 'IO' budget underrun: min=1, actual=0
Hint: Ensure the function actually performs at least 1 IO operation(s)

Combined Min and Limit

Use both annotations to specify a valid range:

-- Must call API at least once, but no more than 5 times
func fetchWithRetry(url: string) -> string ! {Net @min=1 @limit=5} {
-- At least 1 call required, up to 5 allowed for retries
...
}

This is particularly useful for:

  • Retry logic: Ensure at least one attempt, cap total retries
  • Batch operations: Require some work done, limit total operations
  • Validation: Verify minimum processing while preventing runaway loops

Complete Example

This example demonstrates unlimited functions, limited functions, and per-invocation semantics all together:

examples/reference/capability_budgets.ail
-- Capability Budget Example
--
-- This example demonstrates effect budget limiting using the @limit annotation.
-- Effect budgets allow you to restrict how many times a function can perform
-- a particular effect, providing fine-grained resource control.
--
-- Run with: ailang run --caps IO --entry main examples/reference/capability_budgets.ail

module examples/reference/capability_budgets

import std/io (println)

-- Function with an unlimited IO budget (no @limit annotation)
export func unlimited() -> () ! {IO} {
println("Unlimited call 1");
println("Unlimited call 2");
println("Unlimited call 3");
println("Unlimited call 4");
()
}

-- Function with a budget limit of 2 IO operations
-- After 2 IO operations, further IO calls will fail with BudgetExhaustedError
export func limited_two() -> () ! {IO @limit=2} {
println("Limited call 1");
println("Limited call 2");
-- If uncommented, this would fail: println("Limited call 3");
()
}

-- Function that demonstrates per-invocation budget semantics
-- Each call to this function gets a fresh budget of 3
export func per_call_budget() -> () ! {IO @limit=3} {
println("Per-call A");
println("Per-call B");
println("Per-call C");
()
}

-- Main entry point demonstrating budget behavior
export func main() -> () ! {IO} {
-- Unlimited function - can perform as many IO operations as needed
println("=== Testing unlimited IO ===");
unlimited();

-- Limited function - only 2 IO operations allowed
println("=== Testing limited IO (2 calls) ===");
limited_two();

-- Per-invocation semantics - each call gets fresh budget
println("=== Testing per-invocation semantics ===");
per_call_budget(); -- First call: fresh budget of 3
per_call_budget(); -- Second call: fresh budget of 3

println("=== All tests passed! ===")
}

CLI Integration

Default Behavior

Budget enforcement is enabled by default when effects have @limit annotations:

ailang run --caps IO --entry main program.ail

Bypassing Budgets

For debugging or development, use --no-budgets to disable enforcement:

# Normal mode - budget enforced
ailang run --caps IO --entry main program.ail
# Error on 3rd IO call if limit=2

# Debug mode - budgets bypassed
ailang run --caps IO --no-budgets --entry main program.ail
# All IO calls succeed regardless of limits
warning

--no-budgets should only be used for debugging. In production, budget enforcement helps catch resource leaks and infinite loops.

Design Philosophy

Why Per-Invocation?

Budgets reset on each function call for several reasons:

  1. Composability: Functions can be called multiple times without interference
  2. Predictability: The same function behaves the same way each time
  3. Testing: Easy to test individual functions with known budgets
  4. Reasoning: Local reasoning about resource usage

Why Not Global Budgets?

Global budgets (shared across all calls) are harder to reason about:

-- Hypothetical global budget (NOT how AILANG works)
func a() -> () ! {IO @global=5} { println("A"); () }
func b() -> () ! {IO @global=5} { println("B"); () }

-- This would be confusing:
a(); a(); a(); -- Uses 3 of 5 global budget
b(); b(); b(); -- Fails on 3rd call? Hard to predict!

AILANG's per-invocation semantics avoid this confusion.

Relationship to Capability Grants

Budgets are orthogonal to capability grants:

ConceptPurposeChecked At
Capability GrantPermission to use effectRuntime (--caps)
Budget LimitMaximum uses per callRuntime (effect ops)

You need both the capability AND sufficient budget:

# Needs IO capability AND respects @limit
ailang run --caps IO --entry main program.ail

Best Practices

1. Start Without Budgets

Add budgets when you identify specific resource concerns:

-- Start with unlimited
func process() -> () ! {IO} { ... }

-- Add budget when needed
func process() -> () ! {IO @limit=100} { ... }

2. Use Meaningful Limits

Choose limits based on actual requirements:

-- Too restrictive
func fetchAll() -> () ! {Net @limit=1} { ... } -- Only 1 request?

-- More reasonable
func fetchAll() -> () ! {Net @limit=50} { ... } -- Batch of 50

3. Document the Rationale

Add comments explaining why the limit exists:

-- Rate limited to respect API quota (100 requests/minute)
func apiCall() -> () ! {Net @limit=100} { ... }