Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d012699
fix: improve mobile navigation and layouts on docs site
michaelmcnees Mar 23, 2026
8100636
feat: replace mobile top nav with bottom navigation bar for better re…
michaelmcnees Mar 23, 2026
f401167
docs: add AI cost controls implementation plan
michaelmcnees Mar 24, 2026
851fc16
feat(budget): add ai_token_usage and team_budgets tables (migration 012)
michaelmcnees Mar 24, 2026
c9ea680
feat(budget): add BudgetConfig to engine configuration
michaelmcnees Mar 24, 2026
b5358ed
feat(budget): add token usage and team budget DB store
michaelmcnees Mar 24, 2026
9344def
feat(budget): add budget checker with period calculation and AND-gate…
michaelmcnees Mar 24, 2026
8d30122
feat(budget): add budget audit actions and Prometheus metrics
michaelmcnees Mar 24, 2026
6d6d12d
feat(budget): add token_budget field to Workflow struct
michaelmcnees Mar 24, 2026
67bd8c7
feat(budget): wire budget enforcement into engine step dispatch
michaelmcnees Mar 24, 2026
3ab45ea
fix(budget): decouple token recording from budget enforcement
michaelmcnees Mar 24, 2026
ef5c033
feat(budget): add mantle budget CLI commands (set, get, usage, delete)
michaelmcnees Mar 24, 2026
5f0fc5b
feat(budget): add team budget API endpoints (list, set, delete, usage)
michaelmcnees Mar 24, 2026
d891a6d
docs: add AI cost controls documentation with provider pricing links
michaelmcnees Mar 24, 2026
2b5115d
fix(budget): wire BudgetChecker into engine, add audit events, log re…
michaelmcnees Mar 24, 2026
ca52898
fix(budget): add DB constraints, validate ResetDay and TokenBudget, r…
michaelmcnees Mar 24, 2026
9f2b8ca
fix(budget): global limit is platform-wide, add wildcard provider fal…
michaelmcnees Mar 24, 2026
096bdce
merge: resolve conflicts with main after PR #6 merge
michaelmcnees Mar 24, 2026
10306ca
test(budget): add fail-open error behavior and ResetDay validation tests
michaelmcnees Mar 24, 2026
2b4b035
test(budget): add team hard block, provider usage error, and upper-bo…
michaelmcnees Mar 24, 2026
57e5c34
test(budget): address PR9 review feedback
michaelmcnees Mar 24, 2026
9c61a8b
test(budget): add rolling year-boundary test for CurrentPeriodStart
michaelmcnees Mar 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions internal/audit/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ const (
ActionCredentialDeleted Action = "credential.deleted"
ActionCredentialRotated Action = "credential.rotated"
ActionAuthFailed Action = "auth.failed"

// Budget operations.
ActionBudgetExceeded Action = "budget.exceeded"
ActionBudgetWarning Action = "budget.warning"
ActionBudgetUpdated Action = "budget.updated"
)

// Resource identifies the target of an audit event.
Expand Down
117 changes: 117 additions & 0 deletions internal/budget/budget.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package budget

import (
"context"
"fmt"
"time"
)

// CurrentPeriodStart returns the start date of the current budget period.
// For "calendar" mode: first day of the current month.
// For "rolling" mode: the most recent occurrence of resetDay (1-28).
func CurrentPeriodStart(now time.Time, mode string, resetDay int) time.Time {
now = now.UTC()
if mode == "rolling" && resetDay >= 1 && resetDay <= 28 {
if now.Day() >= resetDay {
return time.Date(now.Year(), now.Month(), resetDay, 0, 0, 0, 0, time.UTC)
}
prev := now.AddDate(0, -1, 0)
return time.Date(prev.Year(), prev.Month(), resetDay, 0, 0, 0, 0, time.UTC)
}
return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
Comment on lines +12 to +21

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CurrentPeriodStart builds a UTC period boundary but uses now.Day()/Month()/Year() from whatever timezone now is in. Callers pass time.Now() (local), so around month boundaries this can compute the wrong period for the “UTC” semantics described in docs. Consider normalizing now to UTC first (e.g., now = now.UTC()) before extracting year/month/day.

Copilot uses AI. Check for mistakes.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// CheckInput describes the context of a budget check.
type CheckInput struct {
TeamID string
Provider string
}

// CheckResult describes the outcome of a budget check.
type CheckResult struct {
Blocked bool
Warning bool
BlockedBy string // "global", "team", "workflow", or ""
Message string
}

// Checker evaluates budget limits before AI step dispatch.
// Uses function fields for DB access to allow easy testing with mocks.
type Checker struct {
GlobalMonthlyTokenLimit int64
DefaultTeamMonthlyTokenLimit int64
ResetMode string
ResetDay int

GetTotalUsage func(ctx context.Context, period time.Time) (int64, error)
GetProviderUsage func(ctx context.Context, teamID, provider string, period time.Time) (int64, error)
GetTeamBudget func(ctx context.Context, teamID, provider string) (*TeamBudget, error)
}

// Check evaluates all budget levels (global, team+provider) and returns the result.
// Budget levels compose as AND gates — all must pass.
func (c *Checker) Check(ctx context.Context, input CheckInput) CheckResult {
now := time.Now()
period := CurrentPeriodStart(now, c.ResetMode, c.ResetDay)

// 1. Global budget — hard block only
if c.GlobalMonthlyTokenLimit > 0 && c.GetTotalUsage != nil {
total, err := c.GetTotalUsage(ctx, period)
if err == nil && total >= c.GlobalMonthlyTokenLimit {
return CheckResult{
Comment on lines +58 to +61

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Do not fail open when budget reads error out.

If any of these lookups returns an error, the function falls through to CheckResult{} and dispatch proceeds. For hard budgets, that turns a transient store/read failure into an unlimited-spend window.

Also applies to: 70-79

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/budget/budget.go` around lines 57 - 60, The current logic treats
errors from usage lookups as a pass (fail-open); change it so any error from
c.GetTotalUsage (and other usage lookup calls in this file) causes an immediate
non-allowing CheckResult instead of falling through. Specifically, after calling
c.GetTotalUsage(ctx, input.TeamID, period) check if err != nil and return a
CheckResult that denies dispatch (set Allow to false or populate the CheckResult
error field) so transient read/store failures do not create unlimited-spend
windows; apply the same pattern for the other usage lookup blocks around the
GetTotalUsage / GlobalMonthlyTokenLimit checks.

Blocked: true,
BlockedBy: "global",
Message: fmt.Sprintf("global monthly token limit exceeded (%d/%d)", total, c.GlobalMonthlyTokenLimit),
}
}
}
Comment on lines +57 to +67

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Budget checks fail-open on database errors.

When GetTotalUsage, GetTeamBudget, or GetProviderUsage return errors, the code silently continues and eventually returns CheckResult{} (allow). This means transient database failures grant unlimited spend.

For hard budgets, consider returning a blocking result when lookups fail to avoid an unlimited-spend window during outages.

🛡️ Proposed approach to fail-closed on errors
 	// 1. Global budget — hard block only
 	if c.GlobalMonthlyTokenLimit > 0 && c.GetTotalUsage != nil {
 		total, err := c.GetTotalUsage(ctx, period)
+		if err != nil {
+			return CheckResult{
+				Blocked:   true,
+				BlockedBy: "global",
+				Message:   fmt.Sprintf("budget check failed: %v", err),
+			}
+		}
-		if err == nil && total >= c.GlobalMonthlyTokenLimit {
+		if total >= c.GlobalMonthlyTokenLimit {

Apply similar pattern to the team budget check.

Also applies to: 81-97

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/budget/budget.go` around lines 57 - 67,
GetTotalUsage/GetTeamBudget/GetProviderUsage errors are currently ignored
causing checks to fail-open; change each lookup in the global/team/provider
budget checks to fail-closed for hard budgets by returning a blocking
CheckResult when the corresponding call returns an error (i.e., if err != nil
return CheckResult{Blocked:true, BlockedBy:"global"|"team"|"provider", Message:
fmt.Sprintf("error checking <global|team|provider> budget: %v", err)}), and
apply the same pattern used for the global check to the team budget check
(GetTeamBudget) and provider usage check (GetProviderUsage) so transient DB
errors do not permit unlimited spend.


// 2. Team+provider budget — configurable enforcement
if c.GetTeamBudget != nil && c.GetProviderUsage != nil {
tb, err := c.GetTeamBudget(ctx, input.TeamID, input.Provider)
if err == nil && tb == nil {
tb, err = c.GetTeamBudget(ctx, input.TeamID, "*")
}
if err == nil && tb == nil && c.DefaultTeamMonthlyTokenLimit > 0 {
tb = &TeamBudget{
MonthlyTokenLimit: c.DefaultTeamMonthlyTokenLimit,
Enforcement: "hard",
}
}
if err == nil && tb != nil && tb.MonthlyTokenLimit > 0 {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
usage, err := c.GetProviderUsage(ctx, input.TeamID, input.Provider, period)
if err == nil && usage >= tb.MonthlyTokenLimit {
if tb.Enforcement == "warn" {
Comment on lines +69 to +84

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Team budgets support provider "*" (docs + CLI), but Checker.Check only queries GetTeamBudget(teamID, input.Provider) and only compares GetProviderUsage for that specific provider. As-is, wildcard budgets will never be applied. Add a fallback lookup for "*" (and ensure usage is computed across all providers for that case).

Copilot uses AI. Check for mistakes.
return CheckResult{
Warning: true,
BlockedBy: "team",
Message: fmt.Sprintf("team budget exceeded for provider %s (%d/%d tokens) — enforcement is warn-only", input.Provider, usage, tb.MonthlyTokenLimit),
}
}
return CheckResult{
Blocked: true,
BlockedBy: "team",
Message: fmt.Sprintf("team budget exceeded for provider %s (%d/%d tokens)", input.Provider, usage, tb.MonthlyTokenLimit),
}
}
}
}

return CheckResult{}
}

// CheckExecutionBudget checks if a workflow execution's cumulative token usage
// has exceeded the workflow-level token_budget. budgetLimit of 0 means unlimited.
func CheckExecutionBudget(budgetLimit int64, usedTokens int64) CheckResult {
if budgetLimit <= 0 {
return CheckResult{}
}
if usedTokens >= budgetLimit {
return CheckResult{
Blocked: true,
BlockedBy: "workflow",
Message: fmt.Sprintf("workflow token budget exceeded (%d/%d)", usedTokens, budgetLimit),
}
}
return CheckResult{}
}
216 changes: 216 additions & 0 deletions internal/budget/budget_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
package budget_test

import (
"context"
"fmt"
"testing"
"time"

"github.com/dvflw/mantle/internal/budget"
"github.com/stretchr/testify/assert"
)

func TestCurrentPeriodStart_Calendar(t *testing.T) {
now := time.Date(2026, 3, 23, 14, 30, 0, 0, time.UTC)
start := budget.CurrentPeriodStart(now, "calendar", 1)
assert.Equal(t, time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC), start)
}

func TestCurrentPeriodStart_Rolling(t *testing.T) {
now := time.Date(2026, 3, 10, 14, 30, 0, 0, time.UTC)
start := budget.CurrentPeriodStart(now, "rolling", 15)
assert.Equal(t, time.Date(2026, 2, 15, 0, 0, 0, 0, time.UTC), start)

now = time.Date(2026, 3, 20, 14, 30, 0, 0, time.UTC)
start = budget.CurrentPeriodStart(now, "rolling", 15)
assert.Equal(t, time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC), start)

now = time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
start = budget.CurrentPeriodStart(now, "rolling", 15)
assert.Equal(t, time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC), start)
}

func TestCurrentPeriodStart_RollingDay28_February(t *testing.T) {
now := time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC)
start := budget.CurrentPeriodStart(now, "rolling", 28)
assert.Equal(t, time.Date(2026, 2, 28, 0, 0, 0, 0, time.UTC), start)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func TestChecker_Check_GlobalHardBlock(t *testing.T) {
checker := &budget.Checker{
GlobalMonthlyTokenLimit: 1000,
ResetMode: "calendar",
ResetDay: 1,
GetTotalUsage: func(ctx context.Context, period time.Time) (int64, error) {
return 1001, nil
},
}

result := checker.Check(context.Background(), budget.CheckInput{
TeamID: "team-1",
Provider: "openai",
})

assert.True(t, result.Blocked)
assert.Equal(t, "global", result.BlockedBy)
assert.Contains(t, result.Message, "global monthly token limit")
}

func TestChecker_Check_TeamWarnOnly(t *testing.T) {
checker := &budget.Checker{
ResetMode: "calendar",
ResetDay: 1,
GetTotalUsage: func(ctx context.Context, period time.Time) (int64, error) {
return 0, nil
},
GetProviderUsage: func(ctx context.Context, teamID, provider string, period time.Time) (int64, error) {
return 5001, nil
},
GetTeamBudget: func(ctx context.Context, teamID, provider string) (*budget.TeamBudget, error) {
return &budget.TeamBudget{MonthlyTokenLimit: 5000, Enforcement: "warn"}, nil
},
}

result := checker.Check(context.Background(), budget.CheckInput{
TeamID: "team-1",
Provider: "openai",
})

assert.False(t, result.Blocked)
assert.True(t, result.Warning)
assert.Contains(t, result.Message, "team budget exceeded")
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func TestChecker_Check_AllPass(t *testing.T) {
checker := &budget.Checker{
GlobalMonthlyTokenLimit: 100000,
ResetMode: "calendar",
ResetDay: 1,
GetTotalUsage: func(ctx context.Context, period time.Time) (int64, error) {
return 500, nil
},
GetProviderUsage: func(ctx context.Context, teamID, provider string, period time.Time) (int64, error) {
return 200, nil
},
GetTeamBudget: func(ctx context.Context, teamID, provider string) (*budget.TeamBudget, error) {
return &budget.TeamBudget{MonthlyTokenLimit: 10000, Enforcement: "hard"}, nil
},
}

result := checker.Check(context.Background(), budget.CheckInput{
TeamID: "team-1",
Provider: "openai",
})

assert.False(t, result.Blocked)
assert.False(t, result.Warning)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func TestChecker_Check_FailOpenOnGetTotalUsageError(t *testing.T) {
checker := &budget.Checker{
GlobalMonthlyTokenLimit: 1000,
ResetMode: "calendar",
ResetDay: 1,
GetTotalUsage: func(ctx context.Context, period time.Time) (int64, error) {
return 0, fmt.Errorf("db connection lost")
},
}

result := checker.Check(context.Background(), budget.CheckInput{
TeamID: "team-1",
Provider: "openai",
})

// Intentional fail-open: DB errors should not block AI steps.
assert.False(t, result.Blocked)
assert.False(t, result.Warning)
}

func TestChecker_Check_FailOpenOnGetTeamBudgetError(t *testing.T) {
checker := &budget.Checker{
ResetMode: "calendar",
ResetDay: 1,
GetTotalUsage: func(ctx context.Context, period time.Time) (int64, error) {
return 0, nil
},
GetProviderUsage: func(ctx context.Context, teamID, provider string, period time.Time) (int64, error) {
return 0, nil
},
GetTeamBudget: func(ctx context.Context, teamID, provider string) (*budget.TeamBudget, error) {
return nil, fmt.Errorf("db timeout")
},
}

result := checker.Check(context.Background(), budget.CheckInput{
TeamID: "team-1",
Provider: "openai",
})

assert.False(t, result.Blocked)
assert.False(t, result.Warning)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func TestChecker_Check_FailOpenOnGetProviderUsageError(t *testing.T) {
checker := &budget.Checker{
ResetMode: "calendar",
ResetDay: 1,
GetTotalUsage: func(ctx context.Context, period time.Time) (int64, error) {
return 0, nil
},
GetProviderUsage: func(ctx context.Context, teamID, provider string, period time.Time) (int64, error) {
return 0, fmt.Errorf("provider usage query failed")
},
GetTeamBudget: func(ctx context.Context, teamID, provider string) (*budget.TeamBudget, error) {
return &budget.TeamBudget{MonthlyTokenLimit: 5000, Enforcement: "hard"}, nil
},
}

result := checker.Check(context.Background(), budget.CheckInput{
TeamID: "team-1",
Provider: "openai",
})

assert.False(t, result.Blocked)
assert.False(t, result.Warning)
}

func TestChecker_Check_TeamHardBlock(t *testing.T) {
checker := &budget.Checker{
ResetMode: "calendar",
ResetDay: 1,
GetTotalUsage: func(ctx context.Context, period time.Time) (int64, error) {
return 0, nil
},
GetProviderUsage: func(ctx context.Context, teamID, provider string, period time.Time) (int64, error) {
return 5001, nil
},
GetTeamBudget: func(ctx context.Context, teamID, provider string) (*budget.TeamBudget, error) {
return &budget.TeamBudget{MonthlyTokenLimit: 5000, Enforcement: "hard"}, nil
},
}

result := checker.Check(context.Background(), budget.CheckInput{
TeamID: "team-1",
Provider: "openai",
})

assert.True(t, result.Blocked)
assert.Equal(t, "team", result.BlockedBy)
assert.Contains(t, result.Message, "team budget exceeded")
}

func TestChecker_CheckExecutionBudget(t *testing.T) {
result := budget.CheckExecutionBudget(50000, 50001)
assert.True(t, result.Blocked)
assert.Equal(t, "workflow", result.BlockedBy)

result = budget.CheckExecutionBudget(50000, 50000)
assert.True(t, result.Blocked)
assert.Equal(t, "workflow", result.BlockedBy)

result = budget.CheckExecutionBudget(50000, 49999)
assert.False(t, result.Blocked)

result = budget.CheckExecutionBudget(0, 999999)
assert.False(t, result.Blocked)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Loading
Loading