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:
| Scenario | Without Budgets | With Budgets |
|---|---|---|
| API rate limiting | Manual counting | ! {Net @limit=100} |
| Logging control | Global config | ! {IO @limit=5} |
| Resource exhaustion | Runtime OOM | Bounded failure |
| Testing | Mock everything | Limit side effects |
| Audit verification | Manual checking | ! {IO @min=1} |
| Cache bypass | Flags/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:
-- 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} { ... }
| Annotation | Meaning |
|---|---|
@limit=N | Maximum N operations allowed (budget exhausted if exceeded) |
@min=N | Minimum N operations required (budget underrun if not met) |
@min=M @limit=N | Between M and N operations (inclusive) |
Per-Invocation Semantics
Each function call gets a fresh budget. The budget is not shared across calls:
-- 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:
-- 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:
-- 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:
| Scenario | Why Minimum Matters |
|---|---|
| API verification | Ensure actual API call happened, not cached response |
| Audit logging | Verify logging actually occurred for compliance |
| Data fetching | Confirm fresh data was retrieved, not stale cache |
| Testing | Assert 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:
-- 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
--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:
- Composability: Functions can be called multiple times without interference
- Predictability: The same function behaves the same way each time
- Testing: Easy to test individual functions with known budgets
- 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:
| Concept | Purpose | Checked At |
|---|---|---|
| Capability Grant | Permission to use effect | Runtime (--caps) |
| Budget Limit | Maximum uses per call | Runtime (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} { ... }
Related Resources
- Effect System - Overview of AILANG's effect system
- Language Syntax - Complete syntax reference
- Limitations - Known limitations