fix(office): keep budget alerts honest when a policy changes mid-check (#3532)
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -8,6 +8,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/office/costs"
|
||||
"github.com/kandev/kandev/internal/office/models"
|
||||
@@ -54,13 +58,14 @@ func newIdempotencyTestPolicy(scopeID string, limit int64) *models.BudgetPolicy
|
||||
}
|
||||
}
|
||||
|
||||
// monthlyPeriodKey mirrors the RFC3339-UTC month-start rendering the
|
||||
// monthlyPeriodKeyAt mirrors the RFC3339-UTC month-start rendering the
|
||||
// service computes internally, for tests that assert on stored claim rows.
|
||||
func monthlyPeriodKey() string {
|
||||
now := time.Now().UTC()
|
||||
func monthlyPeriodKeyAt(now time.Time) string {
|
||||
return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func monthlyPeriodKey() string { return monthlyPeriodKeyAt(time.Now().UTC()) }
|
||||
|
||||
func TestCheckBudget_EvaluateTwice_AlertEmitsOnce(t *testing.T) {
|
||||
repo, _, execSQL := newBudgetTestRepo(t)
|
||||
ctx := context.Background()
|
||||
@@ -144,6 +149,10 @@ func TestCheckBudget_ExceededClaimsCompanionAlert(t *testing.T) {
|
||||
// own by a rollup query, only inferred via the companion claim.
|
||||
insertBudgetTestCostEvent(t, execSQL, "agent-companion", "task-1", 600)
|
||||
|
||||
// Captured before CheckBudget so this assertion can't compute a
|
||||
// different month boundary than the evaluation did if the two calls
|
||||
// straddle a UTC month rollover.
|
||||
periodKey := monthlyPeriodKey()
|
||||
results, err := svc.CheckBudget(ctx, "ws-1", "agent-companion", "proj-1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckBudget: %v", err)
|
||||
@@ -158,7 +167,7 @@ func TestCheckBudget_ExceededClaimsCompanionAlert(t *testing.T) {
|
||||
var count int
|
||||
if err := queryRow(
|
||||
`SELECT COUNT(*) FROM office_budget_claims WHERE policy_id = ? AND period_key = ? AND level = 'alert'`,
|
||||
policy.ID, monthlyPeriodKey(),
|
||||
policy.ID, periodKey,
|
||||
).Scan(&count); err != nil {
|
||||
t.Fatalf("count companion claim: %v", err)
|
||||
}
|
||||
@@ -167,13 +176,58 @@ func TestCheckBudget_ExceededClaimsCompanionAlert(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckBudget_ExceededClaimHeld_AlertBandEvaluationSuppressed covers
|
||||
// AC-OFFICE-COSTS-003.12 directly: while an exceeded-level claim is held
|
||||
// for a policy, period and revision, an evaluation whose spend reaches only
|
||||
// the alert level must not emit budget.alert for that period and revision.
|
||||
// The exceeded claim's companion already holds the alert-level slot, so
|
||||
// this follows from the ordinary primary-key conflict on a second
|
||||
// alert-level insert, with no separate read of the exceeded claim.
|
||||
func TestCheckBudget_ExceededClaimHeld_AlertBandEvaluationSuppressed(t *testing.T) {
|
||||
repo, _, execSQL := newBudgetTestRepo(t)
|
||||
ctx := context.Background()
|
||||
createBudgetTestAgent(t, repo, "ws-1", "agent-exceeded-then-alert")
|
||||
agents := &repoAgents{repo: repo}
|
||||
spy := &budgetActivitySpy{}
|
||||
svc := costs.NewCostService(repo, logger.Default(), spy, agents, agents)
|
||||
|
||||
policy := newIdempotencyTestPolicy("agent-exceeded-then-alert", 1000)
|
||||
if err := svc.CreateBudgetPolicy(ctx, policy); err != nil {
|
||||
t.Fatalf("create policy: %v", err)
|
||||
}
|
||||
|
||||
periodKey := monthlyPeriodKey()
|
||||
if claimed, err := repo.ClaimExceeded(ctx, policy.ID, periodKey, policy.Revision); err != nil || !claimed {
|
||||
t.Fatalf("seed exceeded+companion claim: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
|
||||
// Spend in the alert band only: >= 80% of the 1000 limit, below it.
|
||||
insertBudgetTestCostEvent(t, execSQL, "agent-exceeded-then-alert", "task-1", 900)
|
||||
|
||||
results, err := svc.CheckBudget(ctx, "ws-1", "agent-exceeded-then-alert", "proj-1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckBudget: %v", err)
|
||||
}
|
||||
if !results[0].AlertFired {
|
||||
t.Fatal("expected spend to reach the alert level")
|
||||
}
|
||||
if results[0].AlertSubmitted {
|
||||
t.Fatal("budget.alert must not be submitted while an exceeded-level claim is held for this policy, period and revision")
|
||||
}
|
||||
if got := spy.count("budget.alert"); got != 0 {
|
||||
t.Fatalf("budget.alert emissions = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckBudget_CompanionAlertClaimFault_StillSubmitsExceeded covers the
|
||||
// AC-OFFICE-COSTS-002.5 fault-injection carve-out: when the companion
|
||||
// alert-level claim errors, the evaluation still submits budget.exceeded
|
||||
// (already earned by the exceeded-level claim) and the failure is logged
|
||||
// and counted like any other claim-store error. The test deliberately does
|
||||
// NOT assert the companion claim was recorded, per the spec's own
|
||||
// instruction — that is the failure this test injects.
|
||||
// AC-OFFICE-COSTS-003.10 fault-injection case: when the companion
|
||||
// alert-level insert errors, the whole atomic pair rolls back — no exceeded
|
||||
// claim row survives either — but the evaluation still submits
|
||||
// budget.exceeded (already earned by the exceeded-level insert winning
|
||||
// before the rollback) and the pair failure is logged and counted exactly
|
||||
// once, not once per row. The test deliberately does NOT assert the
|
||||
// companion claim was recorded, per the spec's own instruction — that is
|
||||
// the failure this test injects.
|
||||
func TestCheckBudget_CompanionAlertClaimFault_StillSubmitsExceeded(t *testing.T) {
|
||||
repo, _, execSQL := newBudgetTestRepo(t)
|
||||
ctx := context.Background()
|
||||
@@ -191,6 +245,7 @@ func TestCheckBudget_CompanionAlertClaimFault_StillSubmitsExceeded(t *testing.T)
|
||||
}
|
||||
insertBudgetTestCostEvent(t, execSQL, "agent-companion-fault", "task-1", 600)
|
||||
|
||||
before := claimFailureCount(t)
|
||||
results, err := svc.CheckBudget(ctx, "ws-1", "agent-companion-fault", "proj-1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckBudget: %v", err)
|
||||
@@ -201,6 +256,9 @@ func TestCheckBudget_CompanionAlertClaimFault_StillSubmitsExceeded(t *testing.T)
|
||||
if got := spy.count("budget.exceeded"); got != 1 {
|
||||
t.Fatalf("budget.exceeded submissions = %d, want 1", got)
|
||||
}
|
||||
if delta := claimFailureCount(t) - before; delta != 1 {
|
||||
t.Fatalf("budget_claim_failures_total delta = %d, want 1 (the pair is one claim attempt)", delta)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckBudget_ClaimStoreFault_EmitsAndReportsSubmittedTrue covers
|
||||
@@ -246,12 +304,14 @@ func TestCheckBudget_ClaimStoreFault_EmitsAndReportsSubmittedTrue(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckBudget_ClaimStoreFault_ExceededPath_EmitsAndCountsTwicePerEval
|
||||
// TestCheckBudget_ClaimStoreFault_ExceededPath_EmitsAndCountsOncePerEval
|
||||
// extends TestCheckBudget_ClaimStoreFault_EmitsAndReportsSubmittedTrue to the
|
||||
// exceeded level: claimAndEmit's afterClaim hook claims the alert-level
|
||||
// companion right after the exceeded-level claim, so a broken store fails
|
||||
// both claim attempts once per evaluation (AC-OFFICE-COSTS-002.14).
|
||||
func TestCheckBudget_ClaimStoreFault_ExceededPath_EmitsAndCountsTwicePerEval(t *testing.T) {
|
||||
// exceeded level: AC-OFFICE-COSTS-003.10 makes the exceeded claim and its
|
||||
// alert-level companion one atomic attempt, so a broken store fails the
|
||||
// whole pair exactly once per evaluation — one log line, one counter
|
||||
// increment (AC-OFFICE-COSTS-003.14 amends AC-OFFICE-COSTS-002.14 to say
|
||||
// so), not once per row as it would if the two claims were independent.
|
||||
func TestCheckBudget_ClaimStoreFault_ExceededPath_EmitsAndCountsOncePerEval(t *testing.T) {
|
||||
repo, _, execSQL := newBudgetTestRepo(t)
|
||||
ctx := context.Background()
|
||||
createBudgetTestAgent(t, repo, "ws-1", "agent-fault-exc")
|
||||
@@ -283,9 +343,71 @@ func TestCheckBudget_ClaimStoreFault_ExceededPath_EmitsAndCountsTwicePerEval(t *
|
||||
if got := spy.count("budget.exceeded"); got != 2 {
|
||||
t.Fatalf("budget.exceeded = %d, want 2 (duplicates permitted under broken store)", got)
|
||||
}
|
||||
// exceeded claim + companion alert claim both fail => 2 increments per evaluation.
|
||||
if delta := claimFailureCount(t) - before; delta != 4 {
|
||||
t.Fatalf("budget_claim_failures_total delta = %d, want 4 (2 per exceeded eval)", delta)
|
||||
if delta := claimFailureCount(t) - before; delta != 2 {
|
||||
t.Fatalf("budget_claim_failures_total delta = %d, want 2 (1 per exceeded eval, the pair is one claim attempt)", delta)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckBudget_ClaimStoreFault_LogsLevelFieldMatchingEmission covers
|
||||
// AC-OFFICE-COSTS-003.14: the claim-store failure log's level field names
|
||||
// the level whose emission the failed claim governs — "alert" for a
|
||||
// standalone alert-band claim, "exceeded" for the atomic pair — never a
|
||||
// sentinel shared by both.
|
||||
func TestCheckBudget_ClaimStoreFault_LogsLevelFieldMatchingEmission(t *testing.T) {
|
||||
core, logs := observer.New(zapcore.ErrorLevel)
|
||||
log, err := logger.NewFromZap(zap.New(core))
|
||||
if err != nil {
|
||||
t.Fatalf("create observer logger: %v", err)
|
||||
}
|
||||
injected := errors.New("injected claim store failure")
|
||||
|
||||
repo, _, execSQL := newBudgetTestRepo(t)
|
||||
ctx := context.Background()
|
||||
agents := &repoAgents{repo: repo}
|
||||
spy := &budgetActivitySpy{}
|
||||
|
||||
createBudgetTestAgent(t, repo, "ws-1", "agent-log-alert")
|
||||
alertFaulty := &claimFaultRepo{Repository: repo, failLevels: map[string]error{"alert": injected}}
|
||||
alertSvc := costs.NewCostService(alertFaulty, log, spy, agents, agents)
|
||||
alertPolicy := newIdempotencyTestPolicy("agent-log-alert", 1000)
|
||||
if err := alertSvc.CreateBudgetPolicy(ctx, alertPolicy); err != nil {
|
||||
t.Fatalf("create alert policy: %v", err)
|
||||
}
|
||||
insertBudgetTestCostEvent(t, execSQL, "agent-log-alert", "task-1", 850)
|
||||
if _, err := alertSvc.CheckBudget(ctx, "ws-1", "agent-log-alert", "proj-1"); err != nil {
|
||||
t.Fatalf("CheckBudget (alert): %v", err)
|
||||
}
|
||||
|
||||
createBudgetTestAgent(t, repo, "ws-1", "agent-log-exceeded")
|
||||
exceededFaulty := &claimFaultRepo{Repository: repo, failLevels: map[string]error{"exceeded": injected}}
|
||||
exceededSvc := costs.NewCostService(exceededFaulty, log, spy, agents, agents)
|
||||
exceededPolicy := newIdempotencyTestPolicy("agent-log-exceeded", 500)
|
||||
if err := exceededSvc.CreateBudgetPolicy(ctx, exceededPolicy); err != nil {
|
||||
t.Fatalf("create exceeded policy: %v", err)
|
||||
}
|
||||
insertBudgetTestCostEvent(t, execSQL, "agent-log-exceeded", "task-1", 600)
|
||||
if _, err := exceededSvc.CheckBudget(ctx, "ws-1", "agent-log-exceeded", "proj-1"); err != nil {
|
||||
t.Fatalf("CheckBudget (exceeded): %v", err)
|
||||
}
|
||||
|
||||
entries := logs.FilterMessage("budget claim store failure").All()
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("claim-store-failure log entries = %d, want 2", len(entries))
|
||||
}
|
||||
var sawAlert, sawExceeded bool
|
||||
for _, e := range entries {
|
||||
switch e.ContextMap()["level"] {
|
||||
case "alert":
|
||||
sawAlert = true
|
||||
case "exceeded":
|
||||
sawExceeded = true
|
||||
}
|
||||
}
|
||||
if !sawAlert {
|
||||
t.Fatal(`expected a log entry with level="alert" for the standalone alert-band claim failure`)
|
||||
}
|
||||
if !sawExceeded {
|
||||
t.Fatal(`expected a log entry with level="exceeded" for the atomic pair's claim failure`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,34 +568,6 @@ func TestCheckBudget_ConcurrentEvaluation_EmitsOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckBudgetAndEvaluateProjectBudget_ShareAlertClaim covers the two
|
||||
// callers that can evaluate a project policy. They must use the same durable
|
||||
// claim so a cost event followed by task reassignment emits one alert.
|
||||
func TestCheckBudgetAndEvaluateProjectBudget_ShareAlertClaim(t *testing.T) {
|
||||
spy := &budgetActivitySpy{}
|
||||
svc, _, execSQL := newBudgetTestServiceWithActivity(t, spy)
|
||||
ctx := context.Background()
|
||||
|
||||
policy := newIdempotencyTestPolicy("proj-shared-claim", 1000)
|
||||
policy.ScopeType = "project"
|
||||
if err := svc.CreateBudgetPolicy(ctx, policy); err != nil {
|
||||
t.Fatalf("create policy: %v", err)
|
||||
}
|
||||
insertBudgetTestTask(t, execSQL, "task-shared-claim", "ws-1", "proj-shared-claim")
|
||||
insertBudgetTestCostEvent(t, execSQL, "agent-shared-claim", "task-shared-claim", 850)
|
||||
|
||||
if _, err := svc.CheckBudget(ctx, "ws-1", "agent-shared-claim", "proj-shared-claim"); err != nil {
|
||||
t.Fatalf("CheckBudget: %v", err)
|
||||
}
|
||||
if err := svc.EvaluateProjectBudget(ctx, "ws-1", "proj-shared-claim"); err != nil {
|
||||
t.Fatalf("EvaluateProjectBudget: %v", err)
|
||||
}
|
||||
|
||||
if got := spy.count("budget.alert"); got != 1 {
|
||||
t.Fatalf("budget.alert submissions across callers = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckBudget_PeriodKeyMatchesSpendBoundary covers AC-OFFICE-COSTS-002.6a:
|
||||
// the claim recorded for a monthly policy is keyed to the same window
|
||||
// boundary the spend rollup used, rendered as RFC3339 UTC.
|
||||
@@ -490,9 +584,14 @@ func TestCheckBudget_PeriodKeyMatchesSpendBoundary(t *testing.T) {
|
||||
}
|
||||
insertBudgetTestCostEvent(t, execSQL, "agent-period", "task-1", 850)
|
||||
|
||||
// The evaluation reads the clock once. Accept the month containing either
|
||||
// endpoint because a test can straddle a UTC month rollover while the
|
||||
// evaluation runs.
|
||||
before := time.Now().UTC()
|
||||
if _, err := svc.CheckBudget(ctx, "ws-1", "agent-period", "proj-1"); err != nil {
|
||||
t.Fatalf("CheckBudget: %v", err)
|
||||
}
|
||||
after := time.Now().UTC()
|
||||
|
||||
var gotKey string
|
||||
if err := queryRow(
|
||||
@@ -500,8 +599,12 @@ func TestCheckBudget_PeriodKeyMatchesSpendBoundary(t *testing.T) {
|
||||
).Scan(&gotKey); err != nil {
|
||||
t.Fatalf("query claim period_key: %v", err)
|
||||
}
|
||||
if gotKey != monthlyPeriodKey() {
|
||||
t.Fatalf("claim period_key = %q, want %q (must match the spend rollup's boundary)", gotKey, monthlyPeriodKey())
|
||||
wantKeys := map[string]struct{}{
|
||||
monthlyPeriodKeyAt(before): {},
|
||||
monthlyPeriodKeyAt(after): {},
|
||||
}
|
||||
if _, ok := wantKeys[gotKey]; !ok {
|
||||
t.Fatalf("claim period_key = %q, want one of %v (must match the spend rollup's boundary)", gotKey, wantKeys)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,7 +657,7 @@ func TestCheckBudget_SpendBelowLevelKeepsExistingClaim(t *testing.T) {
|
||||
}
|
||||
|
||||
periodKey := monthlyPeriodKey()
|
||||
if claimed, err := repo.Claim(ctx, policy.ID, periodKey, "alert"); err != nil || !claimed {
|
||||
if claimed, err := repo.Claim(ctx, policy.ID, periodKey, "alert", policy.Revision); err != nil || !claimed {
|
||||
t.Fatalf("seed claim: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
|
||||
@@ -564,7 +667,7 @@ func TestCheckBudget_SpendBelowLevelKeepsExistingClaim(t *testing.T) {
|
||||
t.Fatalf("CheckBudget: %v", err)
|
||||
}
|
||||
|
||||
claimed, err := repo.Claim(ctx, policy.ID, periodKey, "alert")
|
||||
claimed, err := repo.Claim(ctx, policy.ID, periodKey, "alert", policy.Revision)
|
||||
if err != nil {
|
||||
t.Fatalf("re-claim: %v", err)
|
||||
}
|
||||
|
||||
@@ -138,43 +138,27 @@ func (s *CostService) evaluatePolicy(
|
||||
if policy.ActionOnExceed == budgetActionPauseAgent && policy.ScopeType == scopeAgent {
|
||||
result.AgentPaused = s.pauseAgentForBudget(ctx, policy.ScopeID)
|
||||
}
|
||||
result.ExceededSubmitted = s.claimAndEmit(ctx, workspaceID, policy, spent, periodKey, claimLevelExceeded,
|
||||
// A spend that jumps straight from below the alert level to
|
||||
// above the limit must also hold the alert-level claim before
|
||||
// budget.exceeded is emitted, so a concurrent evaluation
|
||||
// landing in the alert band cannot win that claim in the gap
|
||||
// and emit a budget.alert describing a reduction in spend. The
|
||||
// outcome is discarded (claimed or already-claimed are equally
|
||||
// fine here); a claim-store error is still logged and counted
|
||||
// like any other.
|
||||
func() { s.claimCompanionAlert(ctx, policy, periodKey) })
|
||||
result.ExceededSubmitted = s.claimExceededAndEmit(ctx, workspaceID, policy, spent, periodKey)
|
||||
case spent >= threshold:
|
||||
result.AlertFired = true
|
||||
result.AlertSubmitted = s.claimAndEmit(ctx, workspaceID, policy, spent, periodKey, claimLevelAlert, nil)
|
||||
result.AlertSubmitted = s.claimAndEmit(ctx, workspaceID, policy, spent, periodKey, claimLevelAlert)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// claimAndEmit resolves the three outcomes of claiming (policy, periodKey,
|
||||
// level) and emits that level's activity row when the claim allows it.
|
||||
// Reports whether this evaluation submitted the row. afterClaim, when
|
||||
// non-nil, runs immediately after the claim and before the emission
|
||||
// decision, regardless of the claim's outcome — the seam the exceeded
|
||||
// level's alert-level companion claim uses so both claims land before
|
||||
// either emission is decided.
|
||||
// level) at the policy's current revision, and emits that level's activity
|
||||
// row when the claim allows it. Reports whether this evaluation submitted
|
||||
// the row.
|
||||
func (s *CostService) claimAndEmit(
|
||||
ctx context.Context,
|
||||
workspaceID string,
|
||||
policy *BudgetPolicy,
|
||||
spent int64,
|
||||
periodKey, level string,
|
||||
afterClaim func(),
|
||||
) bool {
|
||||
claimed, err := s.repo.Claim(ctx, policy.ID, periodKey, level)
|
||||
if afterClaim != nil {
|
||||
afterClaim()
|
||||
}
|
||||
claimed, err := s.repo.Claim(ctx, policy.ID, periodKey, level, policy.Revision)
|
||||
if err != nil {
|
||||
s.recordClaimFailure(policy.ID, level, err)
|
||||
s.emitLevel(ctx, workspaceID, policy, spent, level)
|
||||
@@ -187,15 +171,29 @@ func (s *CostService) claimAndEmit(
|
||||
return true
|
||||
}
|
||||
|
||||
// claimCompanionAlert records the alert-level claim alongside an
|
||||
// exceeded-level emission, so a spend that jumps straight past the limit
|
||||
// still de-escalates correctly. Its outcome never changes the
|
||||
// budget.exceeded emission; only a claim-store error is reported, exactly
|
||||
// like any other claim attempt.
|
||||
func (s *CostService) claimCompanionAlert(ctx context.Context, policy *BudgetPolicy, periodKey string) {
|
||||
if _, err := s.repo.Claim(ctx, policy.ID, periodKey, claimLevelAlert); err != nil {
|
||||
s.recordClaimFailure(policy.ID, claimLevelAlert, err)
|
||||
// claimExceededAndEmit resolves the atomic exceeded-plus-companion claim
|
||||
// pair at the policy's current revision and emits budget.exceeded when the
|
||||
// exceeded-level row was won. A claim-store error anywhere in the pair —
|
||||
// including on the companion insert — fails the whole pair open: the error
|
||||
// is logged and counted once, and budget.exceeded still emits.
|
||||
func (s *CostService) claimExceededAndEmit(
|
||||
ctx context.Context,
|
||||
workspaceID string,
|
||||
policy *BudgetPolicy,
|
||||
spent int64,
|
||||
periodKey string,
|
||||
) bool {
|
||||
claimed, err := s.repo.ClaimExceeded(ctx, policy.ID, periodKey, policy.Revision)
|
||||
if err != nil {
|
||||
s.recordClaimFailure(policy.ID, claimLevelExceeded, err)
|
||||
s.emitLevel(ctx, workspaceID, policy, spent, claimLevelExceeded)
|
||||
return true
|
||||
}
|
||||
if !claimed {
|
||||
return false
|
||||
}
|
||||
s.emitLevel(ctx, workspaceID, policy, spent, claimLevelExceeded)
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *CostService) emitLevel(
|
||||
|
||||
@@ -74,14 +74,34 @@ type claimFaultRepo struct {
|
||||
missLevels map[string]bool
|
||||
}
|
||||
|
||||
func (r *claimFaultRepo) Claim(ctx context.Context, policyID, periodKey, level string) (bool, error) {
|
||||
func (r *claimFaultRepo) Claim(ctx context.Context, policyID, periodKey, level string, revision int64) (bool, error) {
|
||||
if err, ok := r.failLevels[level]; ok {
|
||||
return false, err
|
||||
}
|
||||
if r.missLevels[level] {
|
||||
return false, nil
|
||||
}
|
||||
return r.Repository.Claim(ctx, policyID, periodKey, level)
|
||||
return r.Repository.Claim(ctx, policyID, periodKey, level, revision)
|
||||
}
|
||||
|
||||
// ClaimExceeded overrides the atomic pair the same way Claim does, keyed by
|
||||
// the level whose outcome is being faulted: "exceeded" fails or misses the
|
||||
// pair before either insert is attempted (mirroring a fenced exceeded
|
||||
// insert refused or store-faulted); "alert" fails or misses only the
|
||||
// companion half, after a real exceeded insert has already run for real,
|
||||
// mirroring AC-OFFICE-COSTS-003.10's atomic rollback of the whole pair on a
|
||||
// companion-side error.
|
||||
func (r *claimFaultRepo) ClaimExceeded(ctx context.Context, policyID, periodKey string, revision int64) (bool, error) {
|
||||
if err, ok := r.failLevels["exceeded"]; ok {
|
||||
return false, err
|
||||
}
|
||||
if r.missLevels["exceeded"] {
|
||||
return false, nil
|
||||
}
|
||||
if err, ok := r.failLevels["alert"]; ok {
|
||||
return false, err
|
||||
}
|
||||
return r.Repository.ClaimExceeded(ctx, policyID, periodKey, revision)
|
||||
}
|
||||
|
||||
// callOrderRecorder records events in the order they occur, for tests that
|
||||
@@ -113,12 +133,24 @@ type orderRecordingRepo struct {
|
||||
order *callOrderRecorder
|
||||
}
|
||||
|
||||
func (r *orderRecordingRepo) Claim(ctx context.Context, policyID, periodKey, level string) (bool, error) {
|
||||
claimed, err := r.Repository.Claim(ctx, policyID, periodKey, level)
|
||||
func (r *orderRecordingRepo) Claim(ctx context.Context, policyID, periodKey, level string, revision int64) (bool, error) {
|
||||
claimed, err := r.Repository.Claim(ctx, policyID, periodKey, level, revision)
|
||||
r.order.record("claim:" + level)
|
||||
return claimed, err
|
||||
}
|
||||
|
||||
// ClaimExceeded records both of the atomic pair's levels immediately after
|
||||
// the (single) real call returns, preserving the ordering property the test
|
||||
// cares about: both claim events precede the emission decision. It cannot
|
||||
// observe the two inserts individually from outside the transaction, but it
|
||||
// doesn't need to — only their position relative to the emission matters.
|
||||
func (r *orderRecordingRepo) ClaimExceeded(ctx context.Context, policyID, periodKey string, revision int64) (bool, error) {
|
||||
claimed, err := r.Repository.ClaimExceeded(ctx, policyID, periodKey, revision)
|
||||
r.order.record("claim:exceeded")
|
||||
r.order.record("claim:alert")
|
||||
return claimed, err
|
||||
}
|
||||
|
||||
// orderRecordingActivity implements shared.ActivityLogger and records each
|
||||
// submitted action into the same callOrderRecorder as orderRecordingRepo,
|
||||
// so a test can assert the interleaving of claims and emissions.
|
||||
@@ -260,7 +292,7 @@ func insertBudgetTestCostEvent(t *testing.T, execSQL func(string, ...interface{}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
execSQL(
|
||||
`INSERT INTO office_cost_events (id, agent_profile_id, task_id, cost_subcents, occurred_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
uuid.NewString(), agentID, taskID, costSubcents, now, now,
|
||||
)
|
||||
}
|
||||
@@ -283,13 +315,12 @@ func insertBudgetTestCostEventAt(
|
||||
|
||||
func TestCheckBudget_PeriodWindowsExcludeOlderSpend(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
period models.BudgetPeriod
|
||||
olderBy time.Duration
|
||||
wantLimit bool
|
||||
name string
|
||||
period models.BudgetPeriod
|
||||
older time.Duration
|
||||
}{
|
||||
{name: "daily", period: models.BudgetPeriodDaily, olderBy: 48 * time.Hour},
|
||||
{name: "yearly", period: models.BudgetPeriodYearly, olderBy: 400 * 24 * time.Hour},
|
||||
{name: "daily", period: models.BudgetPeriodDaily, older: 48 * time.Hour},
|
||||
{name: "yearly", period: models.BudgetPeriodYearly, older: 400 * 24 * time.Hour},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
@@ -311,7 +342,7 @@ func TestCheckBudget_PeriodWindowsExcludeOlderSpend(t *testing.T) {
|
||||
t.Fatalf("create policy: %v", err)
|
||||
}
|
||||
|
||||
insertBudgetTestCostEventAt(t, execSQL, "agent-1", "task-1", 600, time.Now().UTC().Add(-tc.olderBy))
|
||||
insertBudgetTestCostEventAt(t, execSQL, "agent-1", "task-1", 600, time.Now().UTC().Add(-tc.older))
|
||||
results, err := svc.CheckBudget(ctx, "ws-1", "agent-1", "project-1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckBudget: %v", err)
|
||||
@@ -319,8 +350,8 @@ func TestCheckBudget_PeriodWindowsExcludeOlderSpend(t *testing.T) {
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("results = %d, want 1", len(results))
|
||||
}
|
||||
if results[0].LimitExceed != tc.wantLimit {
|
||||
t.Errorf("LimitExceed = %t, want %t", results[0].LimitExceed, tc.wantLimit)
|
||||
if results[0].LimitExceed {
|
||||
t.Fatalf("%s spend outside the window must not exceed the limit", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -593,6 +624,34 @@ func TestEvaluateProjectBudget_AlertAtThreshold(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckBudgetAndEvaluateProjectBudget_ShareAlertClaim covers the two
|
||||
// callers that can evaluate a project policy. They must use the same durable
|
||||
// claim so a cost event followed by task reassignment emits one alert.
|
||||
func TestCheckBudgetAndEvaluateProjectBudget_ShareAlertClaim(t *testing.T) {
|
||||
spy := &budgetActivitySpy{}
|
||||
svc, _, execSQL := newBudgetTestServiceWithActivity(t, spy)
|
||||
ctx := context.Background()
|
||||
|
||||
policy := newIdempotencyTestPolicy("proj-shared-claim", 1000)
|
||||
policy.ScopeType = models.BudgetScopeProject
|
||||
if err := svc.CreateBudgetPolicy(ctx, policy); err != nil {
|
||||
t.Fatalf("create policy: %v", err)
|
||||
}
|
||||
insertBudgetTestTask(t, execSQL, "task-shared-claim", "ws-1", "proj-shared-claim")
|
||||
insertBudgetTestCostEvent(t, execSQL, "agent-shared-claim", "task-shared-claim", 850)
|
||||
|
||||
if _, err := svc.CheckBudget(ctx, "ws-1", "agent-shared-claim", "proj-shared-claim"); err != nil {
|
||||
t.Fatalf("CheckBudget: %v", err)
|
||||
}
|
||||
if err := svc.EvaluateProjectBudget(ctx, "ws-1", "proj-shared-claim"); err != nil {
|
||||
t.Fatalf("EvaluateProjectBudget: %v", err)
|
||||
}
|
||||
|
||||
if got := spy.count("budget.alert"); got != 1 {
|
||||
t.Fatalf("budget.alert submissions across callers = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluateProjectBudget_ExceededAtLimit covers the limit-exceeded branch.
|
||||
func TestEvaluateProjectBudget_ExceededAtLimit(t *testing.T) {
|
||||
spy := &budgetActivitySpy{}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package costs_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/kandev/kandev/internal/office/costs"
|
||||
)
|
||||
|
||||
// newBudgetTestRouter mounts the budget routes over a fresh CostService, for
|
||||
// tests that need to observe the HTTP-level wire contract rather than the
|
||||
// service API directly.
|
||||
func newBudgetTestRouter(t *testing.T) *gin.Engine {
|
||||
t.Helper()
|
||||
svc, _, _ := newBudgetTestService(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
costs.NewHandler(svc).RegisterRoutes(router.Group("/api/v1"))
|
||||
return router
|
||||
}
|
||||
|
||||
func doBudgetRequest(t *testing.T, router *gin.Engine, method, path, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// TestCreateBudget_ResponseCarriesServerAssignedRevision covers
|
||||
// AC-OFFICE-COSTS-003.2 and .4 on the wire: the 201 response's budget
|
||||
// carries "revision": 1, never the column default leaking through as 0,
|
||||
// and a client-presented revision in the create body has no effect (the
|
||||
// request DTO has no such field, so this is enforced structurally, but the
|
||||
// response must still report what the row actually holds).
|
||||
func TestCreateBudget_ResponseCarriesServerAssignedRevision(t *testing.T) {
|
||||
router := newBudgetTestRouter(t)
|
||||
|
||||
rec := doBudgetRequest(t, router, http.MethodPost, "/api/v1/workspaces/ws-1/budgets", `{
|
||||
"scope_type": "workspace",
|
||||
"limit_subcents": 1000,
|
||||
"period": "monthly",
|
||||
"alert_threshold_pct": 80,
|
||||
"action_on_exceed": "notify_only",
|
||||
"revision": 999
|
||||
}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201 (body: %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Budget struct {
|
||||
ID string `json:"id"`
|
||||
Revision int64 `json:"revision"`
|
||||
} `json:"budget"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode body %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
if body.Budget.Revision != 1 {
|
||||
t.Fatalf("create response revision = %d, want 1 (a presented 999 must not survive)", body.Budget.Revision)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"revision"`) {
|
||||
t.Fatalf("response body must serialize revision as a visible field, got %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateBudget_ClientRevisionFieldHasNoEffect covers
|
||||
// AC-OFFICE-COSTS-003.7: the update request body has no revision field, so
|
||||
// a client-supplied "revision" in the PATCH JSON is inert, and the response
|
||||
// reports the server's own bump (2, after the create's 1) regardless of
|
||||
// what the caller sent.
|
||||
func TestUpdateBudget_ClientRevisionFieldHasNoEffect(t *testing.T) {
|
||||
router := newBudgetTestRouter(t)
|
||||
|
||||
createRec := doBudgetRequest(t, router, http.MethodPost, "/api/v1/workspaces/ws-1/budgets", `{
|
||||
"scope_type": "workspace",
|
||||
"limit_subcents": 1000,
|
||||
"period": "monthly",
|
||||
"alert_threshold_pct": 80,
|
||||
"action_on_exceed": "notify_only"
|
||||
}`)
|
||||
var created struct {
|
||||
Budget struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"budget"`
|
||||
}
|
||||
if err := json.Unmarshal(createRec.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("decode create body %q: %v", createRec.Body.String(), err)
|
||||
}
|
||||
|
||||
updateRec := doBudgetRequest(t, router, http.MethodPatch, "/api/v1/budgets/"+created.Budget.ID, `{
|
||||
"limit_subcents": 2000,
|
||||
"revision": 999999
|
||||
}`)
|
||||
if updateRec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body: %s)", updateRec.Code, updateRec.Body.String())
|
||||
}
|
||||
|
||||
var updated struct {
|
||||
Budget struct {
|
||||
Revision int64 `json:"revision"`
|
||||
LimitSubcents int64 `json:"limit_subcents"`
|
||||
} `json:"budget"`
|
||||
}
|
||||
if err := json.Unmarshal(updateRec.Body.Bytes(), &updated); err != nil {
|
||||
t.Fatalf("decode update body %q: %v", updateRec.Body.String(), err)
|
||||
}
|
||||
if updated.Budget.Revision != 2 {
|
||||
t.Fatalf("updated revision = %d, want 2 (a presented 999999 must not survive)", updated.Budget.Revision)
|
||||
}
|
||||
if updated.Budget.LimitSubcents != 2000 {
|
||||
t.Fatalf("limit_subcents = %d, want 2000", updated.Budget.LimitSubcents)
|
||||
}
|
||||
}
|
||||
@@ -44,8 +44,13 @@ type Repository interface {
|
||||
GetWorkspaceBudgetDefault(ctx context.Context, workspaceID string) (limitSubcents int64, found bool, err error)
|
||||
SetWorkspaceBudgetDefault(ctx context.Context, workspaceID string, limitSubcents int64) error
|
||||
// Claim atomically records that (policyID, periodKey, level) may emit its
|
||||
// budget notification. See docs/specs/office/requirements/costs.md.
|
||||
Claim(ctx context.Context, policyID, periodKey, level string) (bool, error)
|
||||
// budget notification, fenced to revision. See
|
||||
// docs/specs/budget-claim-revision-fencing/spec.md.
|
||||
Claim(ctx context.Context, policyID, periodKey, level string, revision int64) (bool, error)
|
||||
// ClaimExceeded atomically records the exceeded-level claim and, only
|
||||
// when this call wins it, the alert-level companion claim, both fenced
|
||||
// to revision. See docs/specs/budget-claim-revision-fencing/spec.md.
|
||||
ClaimExceeded(ctx context.Context, policyID, periodKey string, revision int64) (bool, error)
|
||||
}
|
||||
|
||||
// CostService handles cost recording, summaries, and budget evaluation.
|
||||
|
||||
@@ -375,6 +375,10 @@ type BudgetPolicy struct {
|
||||
ActionOnExceed BudgetActionOnExceed `json:"action_on_exceed" db:"action_on_exceed"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
// Revision identifies which immutable set of the fields above a budget
|
||||
// claim was evaluated against (REQ-OFFICE-COSTS-003). Server-assigned:
|
||||
// starts at 1 and increases by exactly 1 on each successful update.
|
||||
Revision int64 `json:"revision" db:"revision"`
|
||||
}
|
||||
|
||||
// SpendWindow reports one scope's priced spend and degradation state for a
|
||||
|
||||
@@ -76,6 +76,24 @@ type Repository struct {
|
||||
// before the policy row update, so a test can prove the two are
|
||||
// transactional without a fault-injecting driver.
|
||||
failBudgetPolicyUpdateErr error
|
||||
|
||||
// failBudgetClaimsRecreateErr is a test-only failpoint: when set,
|
||||
// recreateBudgetClaimsForRevision returns it instead of touching the
|
||||
// database, so a test can prove initSchema surfaces the error rather
|
||||
// than swallowing it.
|
||||
failBudgetClaimsRecreateErr error
|
||||
|
||||
// failBudgetClaimsRecreateAfterDropErr is a test-only failpoint: when
|
||||
// set, recreateBudgetClaimsForRevision returns it after DROP TABLE has
|
||||
// run but before CREATE TABLE, inside the same transaction, so a test
|
||||
// can prove the two are atomic rather than two independent statements.
|
||||
failBudgetClaimsRecreateAfterDropErr error
|
||||
|
||||
// failBudgetExceededCompanionErr is a test-only failpoint: when set,
|
||||
// ClaimExceeded returns it after the exceeded-level insert has run but
|
||||
// before the companion alert-level insert, so a test can prove the
|
||||
// pair rolls back together.
|
||||
failBudgetExceededCompanionErr error
|
||||
}
|
||||
|
||||
// NewWithDB creates a new office repository with existing database connections.
|
||||
@@ -126,6 +144,12 @@ func (r *Repository) initSchema() error {
|
||||
if err := r.createCoreTables(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Runs after createCoreTables (which creates office_budget_claims) so
|
||||
// it probes the real table rather than racing its creation. Its error
|
||||
// is returned, not swallowed: see recreateBudgetClaimsForRevision.
|
||||
if err := r.recreateBudgetClaimsForRevision(); err != nil {
|
||||
return fmt.Errorf("recreate office_budget_claims: %w", err)
|
||||
}
|
||||
if err := r.createExtensionTables(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -338,7 +362,8 @@ func (r *Repository) createCostTables() error {
|
||||
alert_threshold_pct INTEGER DEFAULT 80,
|
||||
action_on_exceed TEXT DEFAULT 'notify_only',
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
-- Built-in default spend ceiling (REQ-OFFICE-BUDGET-003): a stable
|
||||
@@ -349,26 +374,34 @@ func (r *Repository) createCostTables() error {
|
||||
limit_subcents INTEGER NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
);
|
||||
` + budgetClaimsDDL)
|
||||
return err
|
||||
}
|
||||
|
||||
-- office_budget_claims must be created after office_budget_policies:
|
||||
-- its foreign key references that table, and PostgreSQL rejects a
|
||||
-- forward reference at CREATE TABLE time even though SQLite tolerates
|
||||
-- it. One row per (policy, evaluation period, level) that has already
|
||||
-- produced its budget.alert / budget.exceeded notification; the
|
||||
-- primary key is the concurrency control for a claim attempt.
|
||||
-- ON DELETE CASCADE removes a policy's claims on every deletion path
|
||||
-- without a matching code change at any of them.
|
||||
// budgetClaimsDDL is the final office_budget_claims shape: one row per
|
||||
// (policy, evaluation period, level, policy revision) that has already
|
||||
// produced its budget.alert / budget.exceeded notification. revision is
|
||||
// part of the primary key, not just a fence input, so a claim written
|
||||
// against a revision a concurrent update has already superseded can never
|
||||
// match a later evaluation's fenced insert. Used both for fresh-database
|
||||
// creation here and for the recreate in recreateBudgetClaimsForRevision, so
|
||||
// a fresh database and a migrated one converge. office_budget_claims must
|
||||
// be created after office_budget_policies: its foreign key references that
|
||||
// table, and PostgreSQL rejects a forward reference at CREATE TABLE time
|
||||
// even though SQLite tolerates it. ON DELETE CASCADE removes a policy's
|
||||
// claims on every deletion path without a matching code change at any of
|
||||
// them.
|
||||
const budgetClaimsDDL = `
|
||||
CREATE TABLE IF NOT EXISTS office_budget_claims (
|
||||
policy_id TEXT NOT NULL,
|
||||
period_key TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
claimed_at TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY (policy_id, period_key, level),
|
||||
PRIMARY KEY (policy_id, period_key, level, revision),
|
||||
FOREIGN KEY (policy_id) REFERENCES office_budget_policies(id) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
`
|
||||
|
||||
func (r *Repository) createRunTables() error {
|
||||
_, err := r.db.Exec(`
|
||||
|
||||
@@ -63,6 +63,7 @@ func (r *Repository) runMigrations() error {
|
||||
r.migrateWakeWaveColumns()
|
||||
r.migrate.Apply("task_workspace_groups.ownership_generation",
|
||||
`ALTER TABLE task_workspace_groups ADD COLUMN ownership_generation INTEGER NOT NULL DEFAULT 1`)
|
||||
r.migrateBudgetPolicyRevision()
|
||||
r.migrateWorkspacePauseSkipAttribution()
|
||||
r.migrateLoopLivenessCausationID()
|
||||
if err := r.migrate.Err(); err != nil {
|
||||
@@ -86,6 +87,17 @@ func (r *Repository) backfillRoutineTriggerTimezones() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateBudgetPolicyRevision adds office_budget_policies.revision for
|
||||
// databases created before REQ-OFFICE-COSTS-003. The DEFAULT 1 backfills
|
||||
// every existing row exactly like createCostTables' inline column, so a
|
||||
// fresh database and a migrated one converge. A boot replay is a no-op:
|
||||
// db.IsDuplicateColumnError is the only local classifier (ADR 0027), reused
|
||||
// via MigrateLogger.Apply rather than adding a new one.
|
||||
func (r *Repository) migrateBudgetPolicyRevision() {
|
||||
_ = r.migrate.Apply("office_budget_policies.revision",
|
||||
`ALTER TABLE office_budget_policies ADD COLUMN revision INTEGER NOT NULL DEFAULT 1`)
|
||||
}
|
||||
|
||||
// migrateWorkspacePauseSkipAttribution adds the columns a blocked routine
|
||||
// fire uses to record why it was skipped and which pause blocked it. The
|
||||
// partial unique index runs after both ADD COLUMNs so it never executes
|
||||
|
||||
@@ -16,7 +16,8 @@ import (
|
||||
// discard and the policy row update are one transaction
|
||||
// (AC-OFFICE-COSTS-002.8): when the row update fails after the discard has
|
||||
// already run, the whole transaction rolls back, so the claim survives
|
||||
// alongside the un-updated policy. failBudgetPolicyUpdateErr is a
|
||||
// alongside the un-updated policy, and the policy's revision does not
|
||||
// advance either (AC-OFFICE-COSTS-003.6). failBudgetPolicyUpdateErr is a
|
||||
// test-only failpoint (see base.go) standing in for a fault-injecting
|
||||
// driver.
|
||||
func TestUpdateBudgetPolicy_FailedUpdateRollsBackDiscard(t *testing.T) {
|
||||
@@ -53,7 +54,7 @@ func TestUpdateBudgetPolicy_FailedUpdateRollsBackDiscard(t *testing.T) {
|
||||
if err := repo.CreateBudgetPolicy(ctx, policy); err != nil {
|
||||
t.Fatalf("create policy: %v", err)
|
||||
}
|
||||
if _, err := repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert"); err != nil {
|
||||
if _, err := repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert", policy.Revision); err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
|
||||
@@ -80,4 +81,7 @@ func TestUpdateBudgetPolicy_FailedUpdateRollsBackDiscard(t *testing.T) {
|
||||
if stored.LimitSubcents != 1000 {
|
||||
t.Fatalf("limit_subcents after rolled-back update = %d, want unchanged 1000", stored.LimitSubcents)
|
||||
}
|
||||
if stored.Revision != 1 {
|
||||
t.Fatalf("revision after rolled-back update = %d, want unchanged 1 (a rolled-back update must not consume a revision number)", stored.Revision)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ func TestPostgresBudgetClaims(t *testing.T) {
|
||||
t.Fatalf("create budget policy: %v", err)
|
||||
}
|
||||
|
||||
claimed, err := repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert")
|
||||
claimed, err := repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert", policy.Revision)
|
||||
if err != nil {
|
||||
t.Fatalf("first claim: %v", err)
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func TestPostgresBudgetClaims(t *testing.T) {
|
||||
t.Fatal("first claim should win")
|
||||
}
|
||||
|
||||
claimed, err = repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert")
|
||||
claimed, err = repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert", policy.Revision)
|
||||
if err != nil {
|
||||
t.Fatalf("second claim: %v", err)
|
||||
}
|
||||
@@ -72,9 +72,9 @@ func TestPostgresBudgetClaims(t *testing.T) {
|
||||
t.Fatal("second claim on the same (policy, period, level) must not win")
|
||||
}
|
||||
|
||||
// A foreign-key violation (no such policy) must classify as an ordinary
|
||||
// miss, not a store error — db.IsForeignKeyViolation's PostgreSQL branch.
|
||||
claimed, err = repo.Claim(ctx, uuid.NewString(), "lifetime", "alert")
|
||||
// A claim for a nonexistent policy must be an ordinary fenced miss, not a
|
||||
// store error. The INSERT ... SELECT fence prevents an FK violation here.
|
||||
claimed, err = repo.Claim(ctx, uuid.NewString(), "lifetime", "alert", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("claim against a nonexistent policy must not be a store error, got: %v", err)
|
||||
}
|
||||
@@ -95,3 +95,190 @@ func TestPostgresBudgetClaims(t *testing.T) {
|
||||
t.Fatalf("claim count after policy delete = %d, want 0 (FK ON DELETE CASCADE)", claimCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostgresBudgetClaims_RevisionFencingAndFourColumnKey is the PostgreSQL
|
||||
// twin of the revision-fencing coverage in budget_revision_fencing_test.go:
|
||||
// under READ COMMITTED a claim's WHERE EXISTS fence check and the row it
|
||||
// inserts both need proving on a real PostgreSQL backend, not just SQLite's
|
||||
// single-writer connection. It covers both halves called out by the fence
|
||||
// design: the fence itself refuses an insert against a superseded revision
|
||||
// (a stale evaluation cannot emit an obsolete claim), and revision being
|
||||
// part of the primary key means a claim row already recorded for an old
|
||||
// revision does not collide with — and so cannot suppress — a fresh
|
||||
// evaluation's claim at the new revision. Skips unless
|
||||
// KANDEV_TEST_POSTGRES_DSN is set.
|
||||
func TestPostgresBudgetClaims_RevisionFencingAndFourColumnKey(t *testing.T) {
|
||||
db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := taskrepo.NewWithDB(db, db, nil); err != nil {
|
||||
t.Fatalf("init task repo: %v", err)
|
||||
}
|
||||
repo, err := sqlite.NewWithDB(db, db, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("boot: %v", err)
|
||||
}
|
||||
|
||||
policy := &models.BudgetPolicy{
|
||||
WorkspaceID: "pg-budget-fencing-ws",
|
||||
ScopeType: "workspace",
|
||||
ScopeID: "pg-budget-fencing-ws",
|
||||
LimitSubcents: 1000,
|
||||
Period: "monthly",
|
||||
AlertThresholdPct: 80,
|
||||
ActionOnExceed: "notify_only",
|
||||
}
|
||||
if err := repo.CreateBudgetPolicy(ctx, policy); err != nil {
|
||||
t.Fatalf("create budget policy: %v", err)
|
||||
}
|
||||
staleRevision := policy.Revision
|
||||
|
||||
// A claim recorded at the current revision, before any update.
|
||||
claimed, err := repo.Claim(ctx, policy.ID, "lifetime", "alert", staleRevision)
|
||||
if err != nil {
|
||||
t.Fatalf("claim at revision %d: %v", staleRevision, err)
|
||||
}
|
||||
if !claimed {
|
||||
t.Fatal("claim at the current revision should win")
|
||||
}
|
||||
|
||||
if err := repo.UpdateBudgetPolicy(ctx, policy); err != nil {
|
||||
t.Fatalf("update policy: %v", err)
|
||||
}
|
||||
if policy.Revision != staleRevision+1 {
|
||||
t.Fatalf("revision after update = %d, want %d", policy.Revision, staleRevision+1)
|
||||
}
|
||||
|
||||
// The fence: a claim presented with the now-superseded revision must be
|
||||
// refused, even though it targets a different period so it collides
|
||||
// with nothing already in the table.
|
||||
claimed, err = repo.Claim(ctx, policy.ID, "stale-period", "alert", staleRevision)
|
||||
if err != nil {
|
||||
t.Fatalf("stale-revision claim must be an ordinary miss, got error: %v", err)
|
||||
}
|
||||
if claimed {
|
||||
t.Fatal("a claim presented with a superseded revision must not win")
|
||||
}
|
||||
|
||||
// A surviving old-revision row must not suppress a fresh claim. The
|
||||
// direct insert models a claim that was already committed by an older
|
||||
// evaluation while the policy update moved the current revision.
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
INSERT INTO office_budget_claims (policy_id, period_key, level, revision, claimed_at)
|
||||
VALUES ($1, 'lifetime', 'alert', $2, NOW())
|
||||
`, policy.ID, staleRevision); err != nil {
|
||||
t.Fatalf("seed surviving old-revision claim: %v", err)
|
||||
}
|
||||
|
||||
// The four-column key lets the current revision claim the same period and
|
||||
// level without colliding with that old-revision row.
|
||||
claimed, err = repo.Claim(ctx, policy.ID, "lifetime", "alert", policy.Revision)
|
||||
if err != nil {
|
||||
t.Fatalf("claim at the new revision: %v", err)
|
||||
}
|
||||
if !claimed {
|
||||
t.Fatal("claim at the current (post-update) revision should win")
|
||||
}
|
||||
|
||||
var claimCount int
|
||||
if err := db.GetContext(ctx, &claimCount,
|
||||
`SELECT COUNT(*) FROM office_budget_claims WHERE policy_id = $1`, policy.ID,
|
||||
); err != nil {
|
||||
t.Fatalf("count claims: %v", err)
|
||||
}
|
||||
if claimCount != 2 {
|
||||
t.Fatalf("claim rows for policy = %d, want 2 (old and current revisions must coexist)", claimCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostgresBudgetClaims_LegacyTableRecreated verifies the destructive
|
||||
// legacy-table cutover on a real PostgreSQL backend. The old three-column
|
||||
// table is replaced in one transaction, and a later boot must replay without
|
||||
// replacing the new table again. Skips unless KANDEV_TEST_POSTGRES_DSN is set.
|
||||
func TestPostgresBudgetClaims_LegacyTableRecreated(t *testing.T) {
|
||||
db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := taskrepo.NewWithDB(db, db, nil); err != nil {
|
||||
t.Fatalf("init task repo: %v", err)
|
||||
}
|
||||
repo, err := sqlite.NewWithDB(db, db, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("initial boot: %v", err)
|
||||
}
|
||||
|
||||
policy := &models.BudgetPolicy{
|
||||
WorkspaceID: "pg-budget-legacy-ws",
|
||||
ScopeType: "workspace",
|
||||
ScopeID: "pg-budget-legacy-ws",
|
||||
LimitSubcents: 1000,
|
||||
Period: "monthly",
|
||||
AlertThresholdPct: 80,
|
||||
ActionOnExceed: "notify_only",
|
||||
}
|
||||
if err := repo.CreateBudgetPolicy(ctx, policy); err != nil {
|
||||
t.Fatalf("create budget policy: %v", err)
|
||||
}
|
||||
|
||||
// Replace the current table with the pre-revision three-column shape and
|
||||
// leave a row in it. The next office boot must take the recreate path.
|
||||
if _, err := db.ExecContext(ctx, `DROP TABLE office_budget_claims`); err != nil {
|
||||
t.Fatalf("drop current claims table: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
CREATE TABLE office_budget_claims (
|
||||
policy_id TEXT NOT NULL,
|
||||
period_key TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
claimed_at TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY (policy_id, period_key, level),
|
||||
FOREIGN KEY (policy_id) REFERENCES office_budget_policies(id) ON DELETE CASCADE
|
||||
)
|
||||
`); err != nil {
|
||||
t.Fatalf("create legacy claims table: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
INSERT INTO office_budget_claims (policy_id, period_key, level, claimed_at)
|
||||
VALUES ($1, 'legacy', 'alert', NOW())
|
||||
`, policy.ID); err != nil {
|
||||
t.Fatalf("seed legacy claim: %v", err)
|
||||
}
|
||||
|
||||
if _, err := sqlite.NewWithDB(db, db, nil); err != nil {
|
||||
t.Fatalf("boot that recreates legacy claims table: %v", err)
|
||||
}
|
||||
|
||||
var revisionColumns int
|
||||
if err := db.GetContext(ctx, &revisionColumns, `
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'office_budget_claims'
|
||||
AND column_name = 'revision'
|
||||
`); err != nil {
|
||||
t.Fatalf("probe recreated claims table: %v", err)
|
||||
}
|
||||
if revisionColumns != 1 {
|
||||
t.Fatalf("office_budget_claims.revision columns = %d, want 1", revisionColumns)
|
||||
}
|
||||
|
||||
claimed, err := repo.Claim(ctx, policy.ID, "replayed", "alert", policy.Revision)
|
||||
if err != nil {
|
||||
t.Fatalf("claim after legacy recreate: %v", err)
|
||||
}
|
||||
if !claimed {
|
||||
t.Fatal("claim after legacy recreate should win")
|
||||
}
|
||||
|
||||
if _, err := sqlite.NewWithDB(db, db, nil); err != nil {
|
||||
t.Fatalf("replay boot after recreate: %v", err)
|
||||
}
|
||||
var claimCount int
|
||||
if err := db.GetContext(ctx, &claimCount,
|
||||
`SELECT COUNT(*) FROM office_budget_claims WHERE policy_id = $1`, policy.ID,
|
||||
); err != nil {
|
||||
t.Fatalf("count claims after replay: %v", err)
|
||||
}
|
||||
if claimCount != 1 {
|
||||
t.Fatalf("claim rows after replay = %d, want 1", claimCount)
|
||||
}
|
||||
}
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
runssqlite "github.com/kandev/kandev/internal/runs/repository/sqlite"
|
||||
)
|
||||
|
||||
// TestRecreateBudgetClaimsForRevision_FailureIsVisible covers the other
|
||||
// half of AC-OFFICE-COSTS-003.3a: a failed recreate must reach the caller
|
||||
// that initializes the schema, not be logged and swallowed the way the
|
||||
// neighbouring runMigrations()/activateRunOutcome() calls are.
|
||||
// failBudgetClaimsRecreateErr is a test-only failpoint (see base.go)
|
||||
// standing in for a fault-injecting driver; the repo is built by hand
|
||||
// (mirroring NewWithDB) so the failpoint can be set before initSchema runs,
|
||||
// while the database still carries the pre-.3 three-column claims table the
|
||||
// probe needs to find revision absent in.
|
||||
func TestRecreateBudgetClaimsForRevision_FailureIsVisible(t *testing.T) {
|
||||
sqlDB, err := sqlx.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
if _, err := sqlDB.Exec(`
|
||||
CREATE TABLE office_budget_policies (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL,
|
||||
scope_type TEXT NOT NULL,
|
||||
scope_id TEXT NOT NULL,
|
||||
limit_subcents INTEGER NOT NULL,
|
||||
period TEXT NOT NULL,
|
||||
alert_threshold_pct INTEGER DEFAULT 80,
|
||||
action_on_exceed TEXT DEFAULT 'notify_only',
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
);
|
||||
CREATE TABLE office_budget_claims (
|
||||
policy_id TEXT NOT NULL,
|
||||
period_key TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
claimed_at TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY (policy_id, period_key, level),
|
||||
FOREIGN KEY (policy_id) REFERENCES office_budget_policies(id) ON DELETE CASCADE
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("seed legacy office_budget_claims: %v", err)
|
||||
}
|
||||
|
||||
repo := &Repository{
|
||||
Repository: runssqlite.NewWithDB(sqlDB, sqlDB),
|
||||
db: sqlDB,
|
||||
ro: sqlDB,
|
||||
migrate: db.NewRequiredMigrateLogger(sqlDB, nil),
|
||||
failBudgetClaimsRecreateErr: errors.New("injected recreate failure"),
|
||||
}
|
||||
|
||||
if err := repo.initSchema(); err == nil {
|
||||
t.Fatal("expected initSchema to surface the injected recreate failure, got nil error")
|
||||
}
|
||||
}
|
||||
|
||||
// budgetClaimsHasRevisionColumn reports whether office_budget_claims
|
||||
// currently declares a revision column, distinguishing the old three-column
|
||||
// shape from the final four-column one.
|
||||
func budgetClaimsHasRevisionColumn(t *testing.T, sqlDB *sqlx.DB) bool {
|
||||
t.Helper()
|
||||
var count int
|
||||
if err := sqlDB.Get(&count,
|
||||
`SELECT COUNT(*) FROM pragma_table_info('office_budget_claims') WHERE name = 'revision'`,
|
||||
); err != nil {
|
||||
t.Fatalf("probe office_budget_claims columns: %v", err)
|
||||
}
|
||||
return count == 1
|
||||
}
|
||||
|
||||
// TestRecreateBudgetClaimsForRevision_AtomicAcrossDropAndCreate covers the
|
||||
// crash-safety half of AC-OFFICE-COSTS-003.3a: DROP and CREATE must commit
|
||||
// or fail together, not as two independent statements. A boot interrupted
|
||||
// between them must not leave office_budget_claims absent, because a
|
||||
// missing table and an old-shape table both probe as "revision absent" and
|
||||
// an unconditional DROP TABLE (no IF EXISTS) on an already-missing table
|
||||
// would error forever on every later boot. failBudgetClaimsRecreateAfterDropErr
|
||||
// (see base.go) fires after DROP has run but before CREATE, inside the same
|
||||
// transaction, standing in for a process crash at that point.
|
||||
func TestRecreateBudgetClaimsForRevision_AtomicAcrossDropAndCreate(t *testing.T) {
|
||||
sqlDB, err := sqlx.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
if _, err := sqlDB.Exec(`
|
||||
CREATE TABLE office_budget_policies (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL,
|
||||
scope_type TEXT NOT NULL,
|
||||
scope_id TEXT NOT NULL,
|
||||
limit_subcents INTEGER NOT NULL,
|
||||
period TEXT NOT NULL,
|
||||
alert_threshold_pct INTEGER DEFAULT 80,
|
||||
action_on_exceed TEXT DEFAULT 'notify_only',
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
);
|
||||
CREATE TABLE office_budget_claims (
|
||||
policy_id TEXT NOT NULL,
|
||||
period_key TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
claimed_at TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY (policy_id, period_key, level),
|
||||
FOREIGN KEY (policy_id) REFERENCES office_budget_policies(id) ON DELETE CASCADE
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("seed legacy office_budget_claims: %v", err)
|
||||
}
|
||||
|
||||
repo := &Repository{
|
||||
Repository: runssqlite.NewWithDB(sqlDB, sqlDB),
|
||||
db: sqlDB,
|
||||
ro: sqlDB,
|
||||
migrate: db.NewRequiredMigrateLogger(sqlDB, nil),
|
||||
}
|
||||
|
||||
repo.failBudgetClaimsRecreateAfterDropErr = errors.New("injected crash between drop and create")
|
||||
if err := repo.initSchema(); err == nil {
|
||||
t.Fatal("expected initSchema to surface the injected failure, got nil error")
|
||||
}
|
||||
if budgetClaimsHasRevisionColumn(t, sqlDB) {
|
||||
t.Fatal("a failure between DROP and CREATE must not leave the new shape partially applied")
|
||||
}
|
||||
var count int
|
||||
if err := sqlDB.Get(&count,
|
||||
`SELECT COUNT(*) FROM pragma_table_info('office_budget_claims')`,
|
||||
); err != nil {
|
||||
t.Fatalf("count office_budget_claims columns: %v", err)
|
||||
}
|
||||
if count == 0 {
|
||||
t.Fatal("a failure between DROP and CREATE must not leave office_budget_claims absent; the DROP must roll back too")
|
||||
}
|
||||
|
||||
repo.failBudgetClaimsRecreateAfterDropErr = nil
|
||||
if err := repo.initSchema(); err != nil {
|
||||
t.Fatalf("retry boot after the injected failure must self-heal, got: %v", err)
|
||||
}
|
||||
if !budgetClaimsHasRevisionColumn(t, sqlDB) {
|
||||
t.Fatal("retry boot must complete the recreate and leave the revision column present")
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
settingsstore "github.com/kandev/kandev/internal/agent/settings/store"
|
||||
"github.com/kandev/kandev/internal/office/models"
|
||||
)
|
||||
|
||||
// TestClaimExceeded_CompanionFailure_RollsBackWholePair proves the
|
||||
// exceeded-level claim and its alert-level companion are one transaction
|
||||
// (AC-OFFICE-COSTS-003.10): when the companion insert fails after the
|
||||
// exceeded insert has already run, the whole transaction rolls back, so
|
||||
// neither row survives. failBudgetExceededCompanionErr is a test-only
|
||||
// failpoint (see base.go) standing in for a fault-injecting driver.
|
||||
func TestClaimExceeded_CompanionFailure_RollsBackWholePair(t *testing.T) {
|
||||
db, err := sqlx.Open("sqlite3", ":memory:?_foreign_keys=on")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil {
|
||||
t.Fatalf("pragma fk: %v", err)
|
||||
}
|
||||
if _, _, err := settingsstore.Provide(db, db, nil); err != nil {
|
||||
t.Fatalf("settings store init: %v", err)
|
||||
}
|
||||
repo, err := NewWithDB(db, db, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new repo: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
policy := &models.BudgetPolicy{
|
||||
WorkspaceID: "ws-companion-rollback",
|
||||
ScopeType: "workspace",
|
||||
ScopeID: "ws-companion-rollback",
|
||||
LimitSubcents: 1000,
|
||||
Period: "monthly",
|
||||
AlertThresholdPct: 80,
|
||||
ActionOnExceed: "notify_only",
|
||||
}
|
||||
if err := repo.CreateBudgetPolicy(ctx, policy); err != nil {
|
||||
t.Fatalf("create policy: %v", err)
|
||||
}
|
||||
|
||||
repo.failBudgetExceededCompanionErr = errors.New("injected companion failure")
|
||||
claimed, err := repo.ClaimExceeded(ctx, policy.ID, "lifetime", policy.Revision)
|
||||
if err == nil {
|
||||
t.Fatal("expected the injected companion failure to surface")
|
||||
}
|
||||
if claimed {
|
||||
t.Fatal("a failed pair must report claimed=false")
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := db.QueryRow(
|
||||
`SELECT COUNT(*) FROM office_budget_claims WHERE policy_id = ?`, policy.ID,
|
||||
).Scan(&count); err != nil {
|
||||
t.Fatalf("count claims: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("claim rows after a rolled-back pair = %d, want 0 (neither row must survive)", count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
package sqlite_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"github.com/kandev/kandev/internal/office/repository/sqlite"
|
||||
)
|
||||
|
||||
// TestBudgetPolicyRevisionMigration_LegacyRowsBackfillToOne covers
|
||||
// AC-OFFICE-COSTS-003.1: a database created before the revision column
|
||||
// exists gets it added as NOT NULL DEFAULT 1, an existing row is backfilled
|
||||
// to 1, and a boot replay is a no-op (no new local error classifier is
|
||||
// exercised beyond db.IsDuplicateColumnError, ADR 0027).
|
||||
func TestBudgetPolicyRevisionMigration_LegacyRowsBackfillToOne(t *testing.T) {
|
||||
db, err := sqlx.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
// Seed the pre-.3 office_budget_policies shape (no revision column) and
|
||||
// a legacy row, before the repo (and its migration) ever runs.
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE office_budget_policies (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL,
|
||||
scope_type TEXT NOT NULL,
|
||||
scope_id TEXT NOT NULL,
|
||||
limit_subcents INTEGER NOT NULL,
|
||||
period TEXT NOT NULL,
|
||||
alert_threshold_pct INTEGER DEFAULT 80,
|
||||
action_on_exceed TEXT DEFAULT 'notify_only',
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
)`); err != nil {
|
||||
t.Fatalf("seed legacy office_budget_policies: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO office_budget_policies (
|
||||
id, workspace_id, scope_type, scope_id, limit_subcents, period, created_at, updated_at
|
||||
) VALUES ('legacy-1', 'ws-legacy', 'workspace', 'ws-legacy', 1000, 'monthly', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`); err != nil {
|
||||
t.Fatalf("seed legacy row: %v", err)
|
||||
}
|
||||
|
||||
if _, err := sqlite.NewWithDB(db, db, nil); err != nil {
|
||||
t.Fatalf("first boot: %v", err)
|
||||
}
|
||||
|
||||
var revision int64
|
||||
if err := db.QueryRow(`SELECT revision FROM office_budget_policies WHERE id = 'legacy-1'`).Scan(&revision); err != nil {
|
||||
t.Fatalf("select revision on legacy row: %v", err)
|
||||
}
|
||||
if revision != 1 {
|
||||
t.Fatalf("legacy row revision = %d, want 1", revision)
|
||||
}
|
||||
|
||||
// Boot replay must be a no-op, not an error.
|
||||
if _, err := sqlite.NewWithDB(db, db, nil); err != nil {
|
||||
t.Fatalf("second boot (replay): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// legacyBudgetClaimsFixture builds an in-memory database still carrying
|
||||
// PR #3287's three-column office_budget_claims shape (no revision, no FK
|
||||
// dependency needed for these assertions), so the AC-OFFICE-COSTS-003.3a
|
||||
// recreate actually has to run rather than being short-circuited by a fresh
|
||||
// database's inline final-shape DDL.
|
||||
func legacyBudgetClaimsFixture(t *testing.T) *sqlx.DB {
|
||||
t.Helper()
|
||||
db, err := sqlx.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE office_budget_policies (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL,
|
||||
scope_type TEXT NOT NULL,
|
||||
scope_id TEXT NOT NULL,
|
||||
limit_subcents INTEGER NOT NULL,
|
||||
period TEXT NOT NULL,
|
||||
alert_threshold_pct INTEGER DEFAULT 80,
|
||||
action_on_exceed TEXT DEFAULT 'notify_only',
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
);
|
||||
CREATE TABLE office_budget_claims (
|
||||
policy_id TEXT NOT NULL,
|
||||
period_key TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
claimed_at TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY (policy_id, period_key, level),
|
||||
FOREIGN KEY (policy_id) REFERENCES office_budget_policies(id) ON DELETE CASCADE
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("seed legacy office_budget_claims: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// TestRecreateBudgetClaims_OldShapeDatabase_GuardedAcrossBoots covers
|
||||
// AC-OFFICE-COSTS-003.3a: booting against a database still carrying the
|
||||
// pre-.3 three-column claim table runs the recreate (destroying whatever
|
||||
// claims existed before that boot, which AC-OFFICE-COSTS-003.3 expressly
|
||||
// permits); a claim written *after* that boot survives a second boot, which
|
||||
// must probe, find revision present, and do nothing.
|
||||
func TestRecreateBudgetClaims_OldShapeDatabase_GuardedAcrossBoots(t *testing.T) {
|
||||
db := legacyBudgetClaimsFixture(t)
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO office_budget_policies (
|
||||
id, workspace_id, scope_type, scope_id, limit_subcents, period, created_at, updated_at
|
||||
) VALUES ('p1', 'ws-1', 'workspace', 'ws-1', 1000, 'monthly', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`); err != nil {
|
||||
t.Fatalf("seed policy: %v", err)
|
||||
}
|
||||
// A claim held before the cutover boot: the recreate is permitted to
|
||||
// destroy it (AC-OFFICE-COSTS-003.3's accepted first-run behavior).
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO office_budget_claims (policy_id, period_key, level, claimed_at)
|
||||
VALUES ('p1', 'lifetime', 'alert', CURRENT_TIMESTAMP)
|
||||
`); err != nil {
|
||||
t.Fatalf("seed pre-cutover claim: %v", err)
|
||||
}
|
||||
|
||||
repo, err := sqlite.NewWithDB(db, db, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("first boot (cutover): %v", err)
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM office_budget_claims`).Scan(&count); err != nil {
|
||||
t.Fatalf("count claims after cutover: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("claims after cutover = %d, want 0 (pre-cutover claims are destroyed by the recreate)", count)
|
||||
}
|
||||
|
||||
var revisionColExists int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('office_budget_claims') WHERE name = 'revision'`).
|
||||
Scan(&revisionColExists); err != nil {
|
||||
t.Fatalf("probe revision column: %v", err)
|
||||
}
|
||||
if revisionColExists != 1 {
|
||||
t.Fatal("office_budget_claims.revision must exist after the cutover boot")
|
||||
}
|
||||
|
||||
// A claim written after the cutover boot must survive a second boot.
|
||||
ctx := context.Background()
|
||||
if claimed, err := repo.Claim(ctx, "p1", "lifetime", "alert", 1); err != nil || !claimed {
|
||||
t.Fatalf("post-cutover claim: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
|
||||
if _, err := sqlite.NewWithDB(db, db, nil); err != nil {
|
||||
t.Fatalf("second boot (replay): %v", err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM office_budget_claims`).Scan(&count); err != nil {
|
||||
t.Fatalf("count claims after replay: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("claims after replay boot = %d, want 1 (the probe must no-op on a second boot)", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateBudgetPolicy_AssignsRevisionOne_IgnoringPresentedValue covers
|
||||
// AC-OFFICE-COSTS-003.4: revision is always 1 on create, regardless of any
|
||||
// value already set on the policy struct passed in, and the assigned value
|
||||
// is written back so the caller sees what the row actually holds.
|
||||
func TestCreateBudgetPolicy_AssignsRevisionOne_IgnoringPresentedValue(t *testing.T) {
|
||||
repo, db := newBudgetClaimsRepoWithFK(t)
|
||||
ctx := context.Background()
|
||||
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-revision-create")
|
||||
policy.Revision = 777 // presented before a hypothetical re-create; must not survive.
|
||||
policy.ID = "" // force a fresh insert
|
||||
if err := repo.CreateBudgetPolicy(ctx, policy); err != nil {
|
||||
t.Fatalf("create policy: %v", err)
|
||||
}
|
||||
if policy.Revision != 1 {
|
||||
t.Fatalf("revision returned to caller = %d, want 1", policy.Revision)
|
||||
}
|
||||
|
||||
var stored int64
|
||||
if err := db.QueryRow(`SELECT revision FROM office_budget_policies WHERE id = ?`, policy.ID).Scan(&stored); err != nil {
|
||||
t.Fatalf("select stored revision: %v", err)
|
||||
}
|
||||
if stored != 1 {
|
||||
t.Fatalf("stored revision = %d, want 1", stored)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateBudgetPolicy_NonexistentPolicy_ReturnsErrorNoCommit covers the
|
||||
// AC-OFFICE-COSTS-003.5 tightening: updating a policy id that no longer
|
||||
// exists rolls the transaction back and reports an error, a behavior change
|
||||
// from #3287 where the update ignored its affected-row count and reported
|
||||
// success.
|
||||
func TestUpdateBudgetPolicy_NonexistentPolicy_ReturnsErrorNoCommit(t *testing.T) {
|
||||
repo, _ := newBudgetClaimsRepoWithFK(t)
|
||||
ctx := context.Background()
|
||||
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-revision-missing")
|
||||
if err := repo.DeleteBudgetPolicy(ctx, policy.ID); err != nil {
|
||||
t.Fatalf("delete policy: %v", err)
|
||||
}
|
||||
|
||||
policy.LimitSubcents = 9999
|
||||
if err := repo.UpdateBudgetPolicy(ctx, policy); err == nil {
|
||||
t.Fatal("expected an error updating a policy id that no longer exists")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateBudgetPolicy_ConcurrentUpdates_RevisionAdvancesByTwo covers
|
||||
// concurrency case 6 of the forced-to-invent pass: two concurrent updates
|
||||
// each bump by 1, and the final revision is r+2 with no increment lost —
|
||||
// serialized by SQLite's single-writer connection, exactly as production
|
||||
// runs (internal/db/pool.go).
|
||||
func TestUpdateBudgetPolicy_ConcurrentUpdates_RevisionAdvancesByTwo(t *testing.T) {
|
||||
repo, db := newBudgetClaimsRepoWithFK(t)
|
||||
ctx := context.Background()
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-revision-concurrent")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
for i := 0; i < 2; i++ {
|
||||
go func(limit int64) {
|
||||
defer wg.Done()
|
||||
p, err := repo.GetBudgetPolicy(ctx, policy.ID)
|
||||
if err != nil {
|
||||
t.Errorf("get policy: %v", err)
|
||||
return
|
||||
}
|
||||
p.LimitSubcents = limit
|
||||
if err := repo.UpdateBudgetPolicy(ctx, p); err != nil {
|
||||
t.Errorf("update policy: %v", err)
|
||||
}
|
||||
}(int64(2000 + i))
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var revision int64
|
||||
if err := db.QueryRow(`SELECT revision FROM office_budget_policies WHERE id = ?`, policy.ID).Scan(&revision); err != nil {
|
||||
t.Fatalf("select final revision: %v", err)
|
||||
}
|
||||
if revision != 3 {
|
||||
t.Fatalf("final revision = %d, want 3 (started at 1, two updates each +1)", revision)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaim_RaceA_StaleRevisionRefused_FreshRevisionSucceeds covers
|
||||
// AC-OFFICE-COSTS-003.8/.9/.15 (the verification.md "Race A regression"
|
||||
// bullet): a claim presenting a superseded revision is refused as an
|
||||
// ordinary miss (no error), and the slot for the new revision stays open so
|
||||
// a claim at the current revision still wins.
|
||||
func TestClaim_RaceA_StaleRevisionRefused_FreshRevisionSucceeds(t *testing.T) {
|
||||
repo, _ := newBudgetClaimsRepoWithFK(t)
|
||||
ctx := context.Background()
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-race-a")
|
||||
staleRevision := policy.Revision // 1
|
||||
|
||||
// Concurrent UpdateBudgetPolicy commits, bumping the stored revision to 2.
|
||||
policy.LimitSubcents = 2000
|
||||
if err := repo.UpdateBudgetPolicy(ctx, policy); err != nil {
|
||||
t.Fatalf("update policy: %v", err)
|
||||
}
|
||||
if policy.Revision != staleRevision+1 {
|
||||
t.Fatalf("revision after update = %d, want %d", policy.Revision, staleRevision+1)
|
||||
}
|
||||
|
||||
// The stale evaluation's claim, fenced to the superseded revision, must
|
||||
// be refused with no error.
|
||||
claimed, err := repo.Claim(ctx, policy.ID, "lifetime", "alert", staleRevision)
|
||||
if err != nil {
|
||||
t.Fatalf("stale claim must not be a store error, got: %v", err)
|
||||
}
|
||||
if claimed {
|
||||
t.Fatal("a claim fenced to a superseded revision must not win")
|
||||
}
|
||||
|
||||
// The slot for the new revision must still be open.
|
||||
claimed, err = repo.Claim(ctx, policy.ID, "lifetime", "alert", policy.Revision)
|
||||
if err != nil {
|
||||
t.Fatalf("fresh-revision claim: %v", err)
|
||||
}
|
||||
if !claimed {
|
||||
t.Fatal("a claim fenced to the current revision must win even after a stale claim was refused")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaim_InvalidInput_FailsOpenNotSilently covers AC-OFFICE-COSTS-003.13:
|
||||
// each of the four primary-key components, when invalid, is a claim-store
|
||||
// error (never the silent miss AC-OFFICE-COSTS-003.9 reserves for a refused
|
||||
// fence), so the guard must run before the fenced statement rather than as
|
||||
// part of it.
|
||||
func TestClaim_InvalidInput_FailsOpenNotSilently(t *testing.T) {
|
||||
repo, _ := newBudgetClaimsRepoWithFK(t)
|
||||
ctx := context.Background()
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-invalid-input")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
policyID, period string
|
||||
level string
|
||||
revision int64
|
||||
}{
|
||||
{"zero revision", policy.ID, "lifetime", "alert", 0},
|
||||
{"negative revision", policy.ID, "lifetime", "alert", -1},
|
||||
{"empty policy id", "", "lifetime", "alert", policy.Revision},
|
||||
{"empty period key", policy.ID, "", "alert", policy.Revision},
|
||||
{"empty level", policy.ID, "lifetime", "", policy.Revision},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
claimed, err := repo.Claim(ctx, tc.policyID, tc.period, tc.level, tc.revision)
|
||||
if err == nil {
|
||||
t.Fatalf("expected a claim-store error for %s, got claimed=%v err=nil", tc.name, claimed)
|
||||
}
|
||||
if claimed {
|
||||
t.Fatalf("an errored claim attempt must report claimed=false, got true for %s", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaimExceeded_HappyPath_BothRowsExistAtSameRevision covers
|
||||
// AC-OFFICE-COSTS-003.10/.11 on a healthy store: a successful ClaimExceeded
|
||||
// call commits both the exceeded-level and alert-level companion rows at
|
||||
// the same policy, period and revision.
|
||||
func TestClaimExceeded_HappyPath_BothRowsExistAtSameRevision(t *testing.T) {
|
||||
repo, db := newBudgetClaimsRepoWithFK(t)
|
||||
ctx := context.Background()
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-exceeded-happy")
|
||||
|
||||
claimed, err := repo.ClaimExceeded(ctx, policy.ID, "lifetime", policy.Revision)
|
||||
if err != nil {
|
||||
t.Fatalf("claim exceeded: %v", err)
|
||||
}
|
||||
if !claimed {
|
||||
t.Fatal("first exceeded claim should win")
|
||||
}
|
||||
|
||||
for _, level := range []string{"exceeded", "alert"} {
|
||||
var count int
|
||||
if err := db.QueryRow(
|
||||
`SELECT COUNT(*) FROM office_budget_claims WHERE policy_id = ? AND period_key = 'lifetime' AND level = ? AND revision = ?`,
|
||||
policy.ID, level, policy.Revision,
|
||||
).Scan(&count); err != nil {
|
||||
t.Fatalf("count %s claim: %v", level, err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("%s claim rows = %d, want 1", level, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaimExceeded_CompanionConflictIsPairSuccess covers the first
|
||||
// verification.md "pair outcomes that are successes, not failures" case:
|
||||
// with an alert-level claim already held, an over-limit evaluation's
|
||||
// companion insert conflicts, but the pair still commits and the exceeded
|
||||
// claim is won.
|
||||
func TestClaimExceeded_CompanionConflictIsPairSuccess(t *testing.T) {
|
||||
repo, _ := newBudgetClaimsRepoWithFK(t)
|
||||
ctx := context.Background()
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-companion-conflict")
|
||||
|
||||
if claimed, err := repo.Claim(ctx, policy.ID, "lifetime", "alert", policy.Revision); err != nil || !claimed {
|
||||
t.Fatalf("seed alert claim: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
|
||||
claimed, err := repo.ClaimExceeded(ctx, policy.ID, "lifetime", policy.Revision)
|
||||
if err != nil {
|
||||
t.Fatalf("claim exceeded: %v", err)
|
||||
}
|
||||
if !claimed {
|
||||
t.Fatal("exceeded claim must still win despite the companion insert conflicting")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaimExceeded_AlreadyHeld_NoCompanionAttempted covers the second
|
||||
// verification.md "pair outcomes" case: with an exceeded-level claim
|
||||
// already held, a second over-limit evaluation's exceeded insert writes
|
||||
// nothing, and no companion insert is attempted.
|
||||
func TestClaimExceeded_AlreadyHeld_NoCompanionAttempted(t *testing.T) {
|
||||
repo, db := newBudgetClaimsRepoWithFK(t)
|
||||
ctx := context.Background()
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-exceeded-already-held")
|
||||
|
||||
if claimed, err := repo.ClaimExceeded(ctx, policy.ID, "lifetime", policy.Revision); err != nil || !claimed {
|
||||
t.Fatalf("first exceeded claim: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
// Remove the companion so a second, incorrectly-attempted companion
|
||||
// insert would be observable.
|
||||
if _, err := db.Exec(
|
||||
`DELETE FROM office_budget_claims WHERE policy_id = ? AND level = 'alert'`, policy.ID,
|
||||
); err != nil {
|
||||
t.Fatalf("remove companion: %v", err)
|
||||
}
|
||||
|
||||
claimed, err := repo.ClaimExceeded(ctx, policy.ID, "lifetime", policy.Revision)
|
||||
if err != nil {
|
||||
t.Fatalf("second exceeded claim: %v", err)
|
||||
}
|
||||
if claimed {
|
||||
t.Fatal("a second exceeded claim at an already-held (policy, period, revision) must not win")
|
||||
}
|
||||
|
||||
var companionCount int
|
||||
if err := db.QueryRow(
|
||||
`SELECT COUNT(*) FROM office_budget_claims WHERE policy_id = ? AND level = 'alert'`, policy.ID,
|
||||
).Scan(&companionCount); err != nil {
|
||||
t.Fatalf("count companion rows: %v", err)
|
||||
}
|
||||
if companionCount != 0 {
|
||||
t.Fatal("no companion insert must be attempted when the exceeded insert itself writes no row")
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,11 @@ import (
|
||||
"github.com/kandev/kandev/internal/office/models"
|
||||
)
|
||||
|
||||
// CreateBudgetPolicy creates a new budget policy.
|
||||
// CreateBudgetPolicy creates a new budget policy. Its revision is always
|
||||
// assigned 1, regardless of any value already set on policy — including a
|
||||
// nonzero one presented for creation. The assigned value is written back
|
||||
// onto policy so the caller (the create response) reports what the row
|
||||
// actually holds rather than leaning on the column's DEFAULT 1.
|
||||
func (r *Repository) CreateBudgetPolicy(ctx context.Context, policy *models.BudgetPolicy) error {
|
||||
if policy.ID == "" {
|
||||
policy.ID = uuid.New().String()
|
||||
@@ -21,15 +25,16 @@ func (r *Repository) CreateBudgetPolicy(ctx context.Context, policy *models.Budg
|
||||
now := time.Now().UTC()
|
||||
policy.CreatedAt = now
|
||||
policy.UpdatedAt = now
|
||||
policy.Revision = 1
|
||||
|
||||
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
||||
INSERT INTO office_budget_policies (
|
||||
id, workspace_id, scope_type, scope_id, limit_subcents, period,
|
||||
alert_threshold_pct, action_on_exceed, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
alert_threshold_pct, action_on_exceed, created_at, updated_at, revision
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`), policy.ID, policy.WorkspaceID, policy.ScopeType, policy.ScopeID,
|
||||
policy.LimitSubcents, policy.Period, policy.AlertThresholdPct,
|
||||
policy.ActionOnExceed, policy.CreatedAt, policy.UpdatedAt)
|
||||
policy.ActionOnExceed, policy.CreatedAt, policy.UpdatedAt, policy.Revision)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -78,6 +83,15 @@ func (r *Repository) UpdateBudgetPolicy(ctx context.Context, policy *models.Budg
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// updateBudgetPolicyTx discards the policy's claims, bumps its revision by
|
||||
// exactly 1 in-SQL, and reads the new revision back onto policy, all in the
|
||||
// caller's transaction. The bump is expressed as revision = revision + 1
|
||||
// rather than a value computed from a prior read, so two concurrent updates
|
||||
// can never write the same revision. If no row matches id — the policy was
|
||||
// deleted between the caller's read and this write — the RETURNING
|
||||
// read-back yields sql.ErrNoRows, which propagates as an error so the
|
||||
// caller rolls the whole transaction back rather than committing an empty
|
||||
// update.
|
||||
func (r *Repository) updateBudgetPolicyTx(ctx context.Context, tx *sqlx.Tx, policy *models.BudgetPolicy) error {
|
||||
if _, err := tx.ExecContext(ctx, tx.Rebind(
|
||||
`DELETE FROM office_budget_claims WHERE policy_id = ?`), policy.ID); err != nil {
|
||||
@@ -86,14 +100,20 @@ func (r *Repository) updateBudgetPolicyTx(ctx context.Context, tx *sqlx.Tx, poli
|
||||
if r.failBudgetPolicyUpdateErr != nil {
|
||||
return r.failBudgetPolicyUpdateErr
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, tx.Rebind(`
|
||||
err := tx.QueryRowContext(ctx, tx.Rebind(`
|
||||
UPDATE office_budget_policies SET
|
||||
scope_type = ?, scope_id = ?, limit_subcents = ?, period = ?,
|
||||
alert_threshold_pct = ?, action_on_exceed = ?, updated_at = ?
|
||||
alert_threshold_pct = ?, action_on_exceed = ?, updated_at = ?,
|
||||
revision = revision + 1
|
||||
WHERE id = ?
|
||||
RETURNING revision
|
||||
`), policy.ScopeType, policy.ScopeID, policy.LimitSubcents, policy.Period,
|
||||
policy.AlertThresholdPct, policy.ActionOnExceed, policy.UpdatedAt, policy.ID)
|
||||
policy.AlertThresholdPct, policy.ActionOnExceed, policy.UpdatedAt, policy.ID,
|
||||
).Scan(&policy.Revision)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return fmt.Errorf("update policy: not found: %s", policy.ID)
|
||||
}
|
||||
return fmt.Errorf("update policy: %w", err)
|
||||
}
|
||||
return nil
|
||||
@@ -107,27 +127,207 @@ func (r *Repository) DeleteBudgetPolicy(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Claim atomically records that (policyID, periodKey, level) may emit its
|
||||
// budget notification. claimed=true means this call won and the caller
|
||||
// should emit; claimed=false with a nil error means an earlier evaluation
|
||||
// already holds the claim, or the referenced policy no longer exists — a
|
||||
// foreign-key violation is deliberately reported as an ordinary miss, not a
|
||||
// store failure.
|
||||
func (r *Repository) Claim(ctx context.Context, policyID, periodKey, level string) (bool, error) {
|
||||
res, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
||||
INSERT INTO office_budget_claims (policy_id, period_key, level, claimed_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(policy_id, period_key, level) DO NOTHING
|
||||
`), policyID, periodKey, level, time.Now().UTC())
|
||||
// budgetClaimLevelExceeded and budgetClaimLevelAlert are the claim-level
|
||||
// literals ClaimExceeded writes for its atomic pair. Level is otherwise
|
||||
// caller-supplied data, not a package concept, but ClaimExceeded's exported
|
||||
// signature does not take a level parameter, so its two writes need names
|
||||
// of their own.
|
||||
const (
|
||||
budgetClaimLevelExceeded = "exceeded"
|
||||
budgetClaimLevelAlert = "alert"
|
||||
)
|
||||
|
||||
// execRebinder is the subset of *sqlx.DB / *sqlx.Tx a fenced claim insert
|
||||
// needs, so the same statement can run standalone or inside ClaimExceeded's
|
||||
// transaction.
|
||||
type execRebinder interface {
|
||||
ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
|
||||
Rebind(string) string
|
||||
}
|
||||
|
||||
// validateClaimInput reports whether a claim attempt's primary-key
|
||||
// components are all well-formed. Every failure here is a programming
|
||||
// error, not a superseded snapshot, so the caller treats it as a
|
||||
// claim-store error and fails open rather than as the silent miss a
|
||||
// refused fence produces.
|
||||
func validateClaimInput(policyID, periodKey, level string, revision int64) error {
|
||||
if revision <= 0 {
|
||||
return fmt.Errorf("claim: revision must be positive, got %d", revision)
|
||||
}
|
||||
if policyID == "" {
|
||||
return fmt.Errorf("claim: policy id must not be empty")
|
||||
}
|
||||
if periodKey == "" {
|
||||
return fmt.Errorf("claim: period key must not be empty")
|
||||
}
|
||||
if level == "" {
|
||||
return fmt.Errorf("claim: level must not be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// claimInsertFenced inserts a claim row only if policyID still holds
|
||||
// exactly revision at the moment the statement runs, checked as part of the
|
||||
// same statement so no evaluation can observe a matching revision and then
|
||||
// insert against a superseded one. A policy that no longer exists at all
|
||||
// fails the same EXISTS check, which is how a deleted policy's claim
|
||||
// attempt is refused without ever reaching the foreign-key constraint. The
|
||||
// FK violation classifier is kept as a defensive fallback only: dropping it
|
||||
// would let a genuine foreign-key error read as an ordinary miss.
|
||||
func claimInsertFenced(ctx context.Context, ex execRebinder, policyID, periodKey, level string, revision int64) (bool, error) {
|
||||
res, err := ex.ExecContext(ctx, ex.Rebind(`
|
||||
INSERT INTO office_budget_claims (policy_id, period_key, level, revision, claimed_at)
|
||||
SELECT ?, ?, ?, ?, ?
|
||||
WHERE EXISTS (SELECT 1 FROM office_budget_policies WHERE id = ? AND revision = ?)
|
||||
ON CONFLICT(policy_id, period_key, level, revision) DO NOTHING
|
||||
`), policyID, periodKey, level, revision, time.Now().UTC(), policyID, revision)
|
||||
if err != nil {
|
||||
if db.IsForeignKeyViolation(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return rowsAffectedOne(res)
|
||||
}
|
||||
|
||||
// claimInsertCompanion inserts the alert-level companion row for an
|
||||
// already-won exceeded claim, keyed to the same revision with no fence of
|
||||
// its own — its key already names the revision, so a row written against a
|
||||
// revision that has since moved is inert rather than wrong. Any error,
|
||||
// including a foreign-key violation from a policy deleted mid-transaction,
|
||||
// is returned unmodified so ClaimExceeded rolls the whole pair back rather
|
||||
// than committing a lone exceeded claim.
|
||||
func claimInsertCompanion(ctx context.Context, tx *sqlx.Tx, policyID, periodKey string, revision int64) (bool, error) {
|
||||
res, err := tx.ExecContext(ctx, tx.Rebind(`
|
||||
INSERT INTO office_budget_claims (policy_id, period_key, level, revision, claimed_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(policy_id, period_key, level, revision) DO NOTHING
|
||||
`), policyID, periodKey, budgetClaimLevelAlert, revision, time.Now().UTC())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return rowsAffectedOne(res)
|
||||
}
|
||||
|
||||
func rowsAffectedOne(res sql.Result) (bool, error) {
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return affected == 1, nil
|
||||
}
|
||||
|
||||
// Claim atomically records that (policyID, periodKey, level) may emit its
|
||||
// budget notification, fenced to revision: the claim is recorded only while
|
||||
// revision is still the policy's stored revision. claimed=true means this
|
||||
// call won and the caller should emit; claimed=false with a nil error means
|
||||
// an earlier evaluation already holds the claim, the referenced policy no
|
||||
// longer exists, or the presented revision has been superseded — all three
|
||||
// are the same ordinary miss.
|
||||
func (r *Repository) Claim(ctx context.Context, policyID, periodKey, level string, revision int64) (bool, error) {
|
||||
if err := validateClaimInput(policyID, periodKey, level, revision); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return claimInsertFenced(ctx, r.db, policyID, periodKey, level, revision)
|
||||
}
|
||||
|
||||
// ClaimExceeded atomically records the exceeded-level claim and, only when
|
||||
// that claim is won by this call, its alert-level companion claim, in one
|
||||
// transaction: once it commits, either both rows exist for that policy,
|
||||
// period and revision, or this call wrote neither. The fence is evaluated
|
||||
// once, on the exceeded insert; the companion insert carries no fence of
|
||||
// its own. Returns whether the exceeded-level row was inserted by this
|
||||
// call — the companion's own outcome (won, or an ordinary conflict with an
|
||||
// alert-level claim an earlier evaluation already holds) never changes it.
|
||||
func (r *Repository) ClaimExceeded(ctx context.Context, policyID, periodKey string, revision int64) (bool, error) {
|
||||
if err := validateClaimInput(policyID, periodKey, budgetClaimLevelExceeded, revision); err != nil {
|
||||
return false, err
|
||||
}
|
||||
tx, err := r.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
claimed, err := r.claimExceededTx(ctx, tx, policyID, periodKey, revision)
|
||||
if err != nil {
|
||||
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||
return false, fmt.Errorf("%w; rollback: %v", err, rollbackErr)
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
if !claimed {
|
||||
return false, tx.Commit()
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *Repository) claimExceededTx(
|
||||
ctx context.Context, tx *sqlx.Tx, policyID, periodKey string, revision int64,
|
||||
) (bool, error) {
|
||||
claimed, err := claimInsertFenced(ctx, tx, policyID, periodKey, budgetClaimLevelExceeded, revision)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !claimed {
|
||||
// Refused by the fence, or conflicting with an exceeded claim
|
||||
// already held: no emission to protect, so the companion insert
|
||||
// must not be attempted.
|
||||
return false, nil
|
||||
}
|
||||
if r.failBudgetExceededCompanionErr != nil {
|
||||
return false, r.failBudgetExceededCompanionErr
|
||||
}
|
||||
if _, err := claimInsertCompanion(ctx, tx, policyID, periodKey, revision); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// recreateBudgetClaimsForRevision takes effect at most once per database:
|
||||
// it probes whether office_budget_claims already declares revision and does
|
||||
// nothing when it does, which is every boot after the first on a fresh
|
||||
// database (createCostTables already declares the final shape there) and
|
||||
// every boot after the first successful recreate on an upgraded one. On a
|
||||
// database still carrying the pre-revision three-column table, it drops and
|
||||
// recreates office_budget_claims with the four-column primary key the
|
||||
// current schema requires, as one transaction: DROP and CREATE either both
|
||||
// commit or neither does, so a boot interrupted between them leaves the
|
||||
// table exactly as it was rather than absent, and a retried boot always
|
||||
// finds the table in a state the probe can classify. It is not expressed as
|
||||
// an r.migrate.Apply statement: that runner re-executes its SQL on every
|
||||
// boot and is idempotent only because an already-exists error is
|
||||
// swallowed, which would destroy claim state on every boot here. It
|
||||
// returns its error to its caller rather than swallowing it, so a failed
|
||||
// recreate is visible rather than silently leaving the old three-column
|
||||
// key in place.
|
||||
func (r *Repository) recreateBudgetClaimsForRevision() error {
|
||||
exists, err := db.ColumnExists(r.db, "office_budget_claims", "revision")
|
||||
if err != nil {
|
||||
return fmt.Errorf("probe office_budget_claims.revision: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
if r.failBudgetClaimsRecreateErr != nil {
|
||||
return r.failBudgetClaimsRecreateErr
|
||||
}
|
||||
tx, err := r.db.Beginx()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin office_budget_claims recreate: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`DROP TABLE IF EXISTS office_budget_claims`); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("drop office_budget_claims: %w", err)
|
||||
}
|
||||
if r.failBudgetClaimsRecreateAfterDropErr != nil {
|
||||
_ = tx.Rollback()
|
||||
return r.failBudgetClaimsRecreateAfterDropErr
|
||||
}
|
||||
if _, err := tx.Exec(budgetClaimsDDL); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("recreate office_budget_claims: %w", err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -37,10 +37,16 @@ func newBudgetClaimsRepoWithFK(t *testing.T) (*sqlite.Repository, *sqlx.DB) {
|
||||
}
|
||||
// office_task_tree_holds and friends carry an FK to the task-package
|
||||
// owned "tasks" table; with foreign_keys=ON that table must exist (even
|
||||
// empty) for a workspace-wide DELETE to validate the constraint.
|
||||
// empty) for a workspace-wide DELETE to validate the constraint. title/
|
||||
// description/identifier are included so migrateTaskFTS's backfill (which
|
||||
// selects them unconditionally once a "tasks" table exists) doesn't fail
|
||||
// schema init on this minimal fixture.
|
||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL DEFAULT ''
|
||||
workspace_id TEXT NOT NULL DEFAULT '',
|
||||
title TEXT DEFAULT '',
|
||||
description TEXT DEFAULT '',
|
||||
identifier TEXT DEFAULT ''
|
||||
)`); err != nil {
|
||||
t.Fatalf("create tasks: %v", err)
|
||||
}
|
||||
@@ -84,7 +90,7 @@ func TestClaim_FirstCallWinsSecondCallDoesNotReclaim(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-claim")
|
||||
|
||||
claimed, err := repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert")
|
||||
claimed, err := repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert", policy.Revision)
|
||||
if err != nil {
|
||||
t.Fatalf("first claim: %v", err)
|
||||
}
|
||||
@@ -92,7 +98,7 @@ func TestClaim_FirstCallWinsSecondCallDoesNotReclaim(t *testing.T) {
|
||||
t.Fatal("first claim should win")
|
||||
}
|
||||
|
||||
claimed, err = repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert")
|
||||
claimed, err = repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert", policy.Revision)
|
||||
if err != nil {
|
||||
t.Fatalf("second claim: %v", err)
|
||||
}
|
||||
@@ -106,15 +112,15 @@ func TestClaim_DifferentPeriodOrLevelIsIndependent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-claim-2")
|
||||
|
||||
if claimed, err := repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert"); err != nil || !claimed {
|
||||
if claimed, err := repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert", policy.Revision); err != nil || !claimed {
|
||||
t.Fatalf("claim september alert: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
// A different period (new window) is unclaimed (AC-OFFICE-COSTS-002.7).
|
||||
if claimed, err := repo.Claim(ctx, policy.ID, "2026-10-01T00:00:00Z", "alert"); err != nil || !claimed {
|
||||
if claimed, err := repo.Claim(ctx, policy.ID, "2026-10-01T00:00:00Z", "alert", policy.Revision); err != nil || !claimed {
|
||||
t.Fatalf("claim october alert: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
// A different level in the same period is unclaimed.
|
||||
if claimed, err := repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "exceeded"); err != nil || !claimed {
|
||||
if claimed, err := repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "exceeded", policy.Revision); err != nil || !claimed {
|
||||
t.Fatalf("claim september exceeded: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
}
|
||||
@@ -123,7 +129,7 @@ func TestClaim_ForeignKeyViolationReturnsFalseWithNoError(t *testing.T) {
|
||||
repo, _ := newBudgetClaimsRepoWithFK(t)
|
||||
ctx := context.Background()
|
||||
|
||||
claimed, err := repo.Claim(ctx, uuid.NewString(), "lifetime", "alert")
|
||||
claimed, err := repo.Claim(ctx, uuid.NewString(), "lifetime", "alert", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("claim against a nonexistent policy must not be a store error, got: %v", err)
|
||||
}
|
||||
@@ -137,7 +143,7 @@ func TestDeleteBudgetPolicy_CascadesClaims(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-cascade-1")
|
||||
|
||||
if _, err := repo.Claim(ctx, policy.ID, "lifetime", "alert"); err != nil {
|
||||
if _, err := repo.Claim(ctx, policy.ID, "lifetime", "alert", policy.Revision); err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
if got := countBudgetClaims(t, db, policy.ID); got != 1 {
|
||||
@@ -157,7 +163,7 @@ func TestDeleteWorkspaceData_CascadesBudgetClaims(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-cascade-2")
|
||||
|
||||
if _, err := repo.Claim(ctx, policy.ID, "lifetime", "alert"); err != nil {
|
||||
if _, err := repo.Claim(ctx, policy.ID, "lifetime", "alert", policy.Revision); err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
|
||||
@@ -174,7 +180,7 @@ func TestDeleteBudgetPoliciesForRemovedScopes_CascadesClaims(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-cascade-3")
|
||||
|
||||
if _, err := repo.Claim(ctx, policy.ID, "lifetime", "alert"); err != nil {
|
||||
if _, err := repo.Claim(ctx, policy.ID, "lifetime", "alert", policy.Revision); err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
|
||||
@@ -193,7 +199,7 @@ func TestUpdateBudgetPolicy_DiscardsClaims(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-update-1")
|
||||
|
||||
if _, err := repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert"); err != nil {
|
||||
if _, err := repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert", policy.Revision); err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
if got := countBudgetClaims(t, db, policy.ID); got != 1 {
|
||||
@@ -204,6 +210,9 @@ func TestUpdateBudgetPolicy_DiscardsClaims(t *testing.T) {
|
||||
if err := repo.UpdateBudgetPolicy(ctx, policy); err != nil {
|
||||
t.Fatalf("update budget policy: %v", err)
|
||||
}
|
||||
if policy.Revision != 2 {
|
||||
t.Fatalf("revision returned to caller = %d, want 2", policy.Revision)
|
||||
}
|
||||
if got := countBudgetClaims(t, db, policy.ID); got != 0 {
|
||||
t.Fatalf("claim count after update = %d, want 0 (AC-OFFICE-COSTS-002.8)", got)
|
||||
}
|
||||
@@ -215,6 +224,9 @@ func TestUpdateBudgetPolicy_DiscardsClaims(t *testing.T) {
|
||||
if updated.LimitSubcents != 2000 {
|
||||
t.Fatalf("limit_subcents = %d, want 2000", updated.LimitSubcents)
|
||||
}
|
||||
if updated.Revision != 2 {
|
||||
t.Fatalf("stored revision = %d, want 2", updated.Revision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListBudgetPolicies_OrdersByCreatedAtThenID(t *testing.T) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
"github.com/kandev/kandev/internal/persistence"
|
||||
)
|
||||
|
||||
@@ -37,7 +38,7 @@ func (r *Repository) activateLoopLiveness() {
|
||||
{"agent_wakeup_requests", causationIDColumn},
|
||||
{"runs", causationIDColumn},
|
||||
} {
|
||||
exists, err := columnExists(r.db, probe.table, probe.column)
|
||||
exists, err := db.ColumnExists(r.db, probe.table, probe.column)
|
||||
if err != nil {
|
||||
r.logActivationWarn("loop liveness schema probe failed", err)
|
||||
return
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/db/dialect"
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
"github.com/kandev/kandev/internal/persistence"
|
||||
)
|
||||
|
||||
@@ -35,7 +33,7 @@ func (r *Repository) activateRunOutcome() {
|
||||
r.logActivationWarn("ensure kandev_meta table failed", err)
|
||||
return
|
||||
}
|
||||
exists, err := columnExists(r.db, "runs", "outcome")
|
||||
exists, err := db.ColumnExists(r.db, "runs", "outcome")
|
||||
if err != nil {
|
||||
r.logActivationWarn("runs.outcome schema probe failed", err)
|
||||
return
|
||||
@@ -55,44 +53,3 @@ func (r *Repository) logActivationWarn(msg string, err error) {
|
||||
r.log.Warn(msg, zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// columnExists reports whether table declares column, on either dialect.
|
||||
// SQLite has no information_schema, so it falls back to PRAGMA table_info;
|
||||
// Postgres is queried through the standard catalog view scoped to the
|
||||
// current schema so callers never see another schema's same-named table.
|
||||
func columnExists(db interface {
|
||||
DriverName() string
|
||||
Query(string, ...interface{}) (*sql.Rows, error)
|
||||
QueryRow(string, ...interface{}) *sql.Row
|
||||
Rebind(string) string
|
||||
}, table, column string) (bool, error) {
|
||||
if dialect.IsPostgres(db.DriverName()) {
|
||||
var exists bool
|
||||
err := db.QueryRow(db.Rebind(`
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = ?
|
||||
AND column_name = ?
|
||||
)
|
||||
`), table, column).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var cid, notnull, pk int
|
||||
var name, ctype string
|
||||
var dflt sql.NullString
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if name == column {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
}
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"engine": "sqlite3",
|
||||
"file": "sqlite.sql",
|
||||
"sha256": "e1b490607af347314fce11bb3352bf91599eab4c651a1521a60142e73a3b2d5d",
|
||||
"sha256": "b7c763f6c3f1f6000e8a8f90a123ee3b114582bdd3a0aabf162eedae18cf57a3",
|
||||
"sentinels": {
|
||||
"fixture.sentinel": "v0.93.0-sqlite-sentinel"
|
||||
},
|
||||
@@ -37,7 +37,7 @@
|
||||
{
|
||||
"engine": "pgx",
|
||||
"file": "postgres.sql",
|
||||
"sha256": "f4f26d8d67610ef94a2d5cc308392be7a353efdd3e36e8469fb5ed11d774d165",
|
||||
"sha256": "8dd108b73d1daaf9cd35ee2255f06f2800f90a7101ba3337c9ee4b0951a51de6",
|
||||
"sentinels": {
|
||||
"fixture.sentinel": "v0.93.0-postgres-sentinel"
|
||||
},
|
||||
|
||||
+12
-2
@@ -455,8 +455,6 @@ CREATE TABLE office_budget_policies (
|
||||
updated_at timestamp without time zone NOT NULL
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE office_channels (
|
||||
id text NOT NULL,
|
||||
workspace_id text NOT NULL,
|
||||
@@ -2010,6 +2008,18 @@ ALTER TABLE ONLY office_budget_policies
|
||||
|
||||
|
||||
|
||||
CREATE TABLE office_budget_claims (
|
||||
policy_id text NOT NULL,
|
||||
period_key text NOT NULL,
|
||||
level text NOT NULL,
|
||||
claimed_at timestamp without time zone NOT NULL,
|
||||
CONSTRAINT office_budget_claims_pkey PRIMARY KEY (policy_id, period_key, level),
|
||||
CONSTRAINT office_budget_claims_policy_id_fkey FOREIGN KEY (policy_id)
|
||||
REFERENCES office_budget_policies(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
|
||||
ALTER TABLE ONLY office_channels
|
||||
ADD CONSTRAINT office_channels_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
+8
@@ -367,6 +367,14 @@ CREATE TABLE office_budget_policies (
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
);
|
||||
CREATE TABLE office_budget_claims (
|
||||
policy_id TEXT NOT NULL,
|
||||
period_key TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
claimed_at TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY (policy_id, period_key, level),
|
||||
FOREIGN KEY (policy_id) REFERENCES office_budget_policies(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE office_channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
created: 2026-09-13
|
||||
status: done
|
||||
requirements:
|
||||
- REQ-OFFICE-COSTS-002
|
||||
system_design:
|
||||
- ../../specs/office/system-design/costs-03.md
|
||||
---
|
||||
|
||||
# Implementation Plan: Budget Claim Revision Fencing
|
||||
|
||||
This plan records the revision fence and atomic companion claim changes for
|
||||
budget notifications. The work keeps the existing Office budget notification
|
||||
contract and its idempotent claim store.
|
||||
|
||||
## Work order
|
||||
|
||||
- [Task 01: Fence budget claims to policy revisions](task-01-fence-budget-claims.md)
|
||||
|
||||
## Outcome
|
||||
|
||||
Budget claims carry the policy revision that earned them. Policy updates bump
|
||||
the revision and discard old claims, stale evaluations cannot claim a newer
|
||||
revision, and exceeded evaluations claim their alert companion in one
|
||||
transaction.
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
id: "01-fence-budget-claims"
|
||||
title: "Fence budget claims to policy revisions"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-OFFICE-COSTS-002
|
||||
acceptance_criteria:
|
||||
- AC-OFFICE-COSTS-002.1
|
||||
- AC-OFFICE-COSTS-002.2
|
||||
- AC-OFFICE-COSTS-002.3
|
||||
- AC-OFFICE-COSTS-002.5
|
||||
- AC-OFFICE-COSTS-002.8
|
||||
- AC-OFFICE-COSTS-002.10
|
||||
- AC-OFFICE-COSTS-002.11
|
||||
- AC-OFFICE-COSTS-002.13
|
||||
- AC-OFFICE-COSTS-002.14
|
||||
system_design:
|
||||
- ../../specs/office/system-design/costs-03.md
|
||||
---
|
||||
|
||||
# Task 01: Fence Budget Claims to Policy Revisions
|
||||
|
||||
## Scope
|
||||
|
||||
- Add a server-owned revision counter to each budget policy.
|
||||
- Include the evaluated revision in the durable claim key and fence inserts to
|
||||
the current policy row.
|
||||
- Recreate the legacy three-column claim table once when an upgraded database
|
||||
needs the four-column key.
|
||||
- Claim the exceeded level and its alert companion atomically.
|
||||
- Return the stored revision from create and update operations and cover the
|
||||
SQLite and PostgreSQL persistence paths.
|
||||
|
||||
## Verification
|
||||
|
||||
- Office cost, repository, and store conformance tests.
|
||||
- PostgreSQL-gated revision and migration tests when a PostgreSQL DSN is
|
||||
available.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Budget claim fencing: amendments to REQ-OFFICE-COSTS-002
|
||||
|
||||
Companion to [spec.md](spec.md) (`REQ-OFFICE-COSTS-003`), alongside
|
||||
[verification.md](verification.md), [prior-art.md](prior-art.md) and
|
||||
[input-inventory.md](input-inventory.md). Split out for the specification linter's
|
||||
per-file size ceiling; the five files are one spec.
|
||||
|
||||
Every entry below amends a criterion of `REQ-OFFICE-COSTS-002`. Nothing here is a new
|
||||
acceptance criterion of `REQ-OFFICE-COSTS-003`; those are all in
|
||||
[spec.md](spec.md#acceptance-criteria).
|
||||
|
||||
This spec is a delta on a contract that is itself unmerged. Each amendment is named so
|
||||
Spec Review can check it against #3287 rather than re-deriving it.
|
||||
|
||||
- **`AC-OFFICE-COSTS-002.8`** — its stale-evaluation carve-out ("An evaluation already in
|
||||
flight when the discard commits ... may therefore insert a claim keyed to the pre-update
|
||||
period and level, suppressing the first post-update notification; that outcome is
|
||||
permitted rather than prevented") is **superseded**. `AC-OFFICE-COSTS-003.8`/`.9`/`.15`
|
||||
now prevent it. The criterion's atomicity requirement is unchanged and now also covers
|
||||
the revision bump. The rejection of a lock spanning evaluation and update stands.
|
||||
- **`AC-OFFICE-COSTS-002.5`** — the companion alert claim is no longer a separate,
|
||||
independently-failing write. Its "conditional on the claim store accepting that companion
|
||||
write" clause and the consequence it describes ("the de-escalation protection lapses for
|
||||
that one period") are **removed**: under `AC-OFFICE-COSTS-003.10` the pair is atomic, so
|
||||
a companion failure is a pair failure and lands on the ordinary
|
||||
`AC-OFFICE-COSTS-002.14` fail-open path. Its precedence rule is unchanged. A
|
||||
fault-injection test still shall not assert the companion claim was recorded.
|
||||
- **`AC-OFFICE-COSTS-002.10`** — unchanged in force. Its scope widens: "exactly one
|
||||
submission" now also holds for the previously unaddressed alert-band-versus-over-limit
|
||||
pair, in the direction stated by `AC-OFFICE-COSTS-003.12`.
|
||||
- **`AC-OFFICE-COSTS-002.13`** — unchanged in meaning; one case joins its enumeration: an
|
||||
evaluation whose claim was refused on a superseded revision reports `submitted` false,
|
||||
because no row went out. Its existing rule already settles this.
|
||||
- **`AC-OFFICE-COSTS-002.14a`** — unchanged in outcome; the mechanism moves from the
|
||||
foreign-key classifier to the fence's existence check, which subsumes it.
|
||||
- **`AC-OFFICE-COSTS-002.14`** — unchanged in force; its scope widens twice. The invalid
|
||||
claim inputs of `AC-OFFICE-COSTS-003.13` become claim-store errors it governs, and the
|
||||
atomic pair of `AC-OFFICE-COSTS-003.10` is a single claim attempt under it, so a pair
|
||||
failure is one fail-open emission, one log and one counter increment rather than two;
|
||||
`AC-OFFICE-COSTS-003.14` fixes which level that log names. Its exclusion of a failed
|
||||
claim *discard* is unchanged and now also covers a failed revision bump, which rides the
|
||||
same transaction and is reported to the caller by `AC-OFFICE-COSTS-002.8`.
|
||||
- **`AC-OFFICE-COSTS-002.15`** — not amended. A claim still survives a spend that falls
|
||||
back below the level it was taken at; revision changes nothing there, because only an
|
||||
update or a period rollover releases a claim and both already did.
|
||||
- **Not amended:** `.1`, `.2`, `.2a`, `.3`, `.4`, `.4a`, `.6`, `.6a`, `.7`, `.9`, `.11`,
|
||||
`.12`, `.16`, `.17`, and every entry in `REQ-OFFICE-COSTS-002`'s `## Out of scope`.
|
||||
- **Documentation follow-up, not this card:** once #3287 merges, fold these criteria into
|
||||
`docs/specs/office/requirements/costs.md` as `REQ-OFFICE-COSTS-003` with a matching
|
||||
system-design part and retire this slug directory. Editing that file now would conflict
|
||||
with the unmerged PR this spec depends on.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Budget claim fencing: input inventory
|
||||
|
||||
Companion to [spec.md](spec.md) (`REQ-OFFICE-COSTS-003`), alongside
|
||||
[verification.md](verification.md), [prior-art.md](prior-art.md) and
|
||||
[amendments.md](amendments.md). Split out for the specification linter's per-file size
|
||||
ceiling; the five files are one spec.
|
||||
|
||||
Every shape below was read out of the code on #3287's head, not inferred. It is the
|
||||
sampled state Build starts from — reference, not acceptance criteria.
|
||||
|
||||
|
||||
Sampled by reading the code, not inferred.
|
||||
|
||||
**`office_budget_policies`** (`repository/sqlite/base.go`) — columns `id, workspace_id,
|
||||
scope_type, scope_id, limit_subcents, period, alert_threshold_pct, action_on_exceed,
|
||||
created_at, updated_at`. **No revision or version column.** `GetBudgetPolicy` and
|
||||
`ListBudgetPolicies` both use `SELECT *` with `sqlx` `StructScan`/`SelectContext` into
|
||||
`models.BudgetPolicy`, so adding a column **requires** a matching struct field — `sqlx`
|
||||
fails with `missing destination name <column>` otherwise. This is a hard coupling, not a
|
||||
style choice.
|
||||
|
||||
**`office_budget_claims`** (added by #3287) — `policy_id, period_key, level, claimed_at`,
|
||||
`PRIMARY KEY (policy_id, period_key, level)`, `FOREIGN KEY (policy_id) REFERENCES
|
||||
office_budget_policies(id) ON DELETE CASCADE`. Created by `createCostTables()` with
|
||||
`CREATE TABLE IF NOT EXISTS`, **after** `office_budget_policies` (Postgres rejects the
|
||||
forward reference). `Repository.Claim` is `INSERT ... ON CONFLICT(policy_id, period_key,
|
||||
level) DO NOTHING`; `claimed` is `RowsAffected() == 1`; `db.IsForeignKeyViolation(err)`
|
||||
maps to `(false, nil)`.
|
||||
|
||||
**`Repository.UpdateBudgetPolicy`** (#3287) — `BeginTxx`; `DELETE FROM
|
||||
office_budget_claims WHERE policy_id = ?`; `UPDATE office_budget_policies SET ... WHERE id
|
||||
= ?`; `Commit`. A test-only failpoint `failBudgetPolicyUpdateErr` sits between the two.
|
||||
|
||||
**`evaluatePolicy`** (#3287) — reads the clock once, derives `boundary` once, derives
|
||||
`periodKey` once, then `switch { case spent >= limit: ...; case spent >= threshold: ... }`.
|
||||
The exceeded branch calls `claimAndEmit(..., claimLevelExceeded, func() {
|
||||
s.claimCompanionAlert(...) })`; the `afterClaim` callback runs **between** the exceeded
|
||||
claim and the emission decision. This callback seam is the mechanism Race B exploits.
|
||||
|
||||
**`office_activity_log`** — `id TEXT PRIMARY KEY` (a random UUID), `created_at TIMESTAMP`,
|
||||
index `(workspace_id, created_at DESC)`. Every reader orders `ORDER BY created_at DESC`
|
||||
with **no tiebreak column**. `CreateActivityEntry` sets `created_at = time.Now().UTC()` at
|
||||
write time, only when the caller left it zero. `ActivityLogger.LogActivity` returns
|
||||
nothing.
|
||||
|
||||
**Budget surfaces on current `main`** — `dashboard/service_inbox.go:170`
|
||||
`listBudgetInboxEntries` lists `budget.alert` **and** `budget.exceeded` as two separate
|
||||
`ListActivityEntriesByAction` calls concatenated (`append(alerts, exceeded...)`), each
|
||||
already sorted internally — **not** interleaved by timestamp. That arrived with PR #3276
|
||||
(`677e01204`), which merged after #3287 branched, so #3287's statement that "only
|
||||
`budget.alert` feeds the Office inbox list" is **stale against `main`**.
|
||||
`apps/web/.../activity/activity-feed.tsx` renders `res.activity` in server order with no
|
||||
client-side sort, so the **activity feed** is the one surface where the two rows interleave
|
||||
and `created_at` alone decides.
|
||||
|
||||
**`updateBudget` handler** (`costs/handler.go:168`) — `GetBudgetPolicy` →
|
||||
`applyBudgetPatch` → `UpdateBudgetPolicy`, an unfenced read-modify-write that returns the
|
||||
in-memory `policy` as the response body. `models.BudgetPolicy` is mapped by hand to
|
||||
`apps/web/lib/state/slices/office/types.ts:138` in camelCase, which ignores unrecognised
|
||||
JSON fields.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Budget claim fencing: prior art
|
||||
|
||||
Companion to [spec.md](spec.md) (`REQ-OFFICE-COSTS-003`), alongside
|
||||
[verification.md](verification.md), [input-inventory.md](input-inventory.md) and
|
||||
[amendments.md](amendments.md). Split out for the specification linter's per-file size
|
||||
ceiling; the five files are one spec. This file
|
||||
is the research record behind [Design decisions](spec.md#design-decisions) — it is
|
||||
provenance, not contract, and nothing in it is an acceptance criterion.
|
||||
|
||||
|
||||
Three legs, each opening with a receipt.
|
||||
|
||||
### Our own prior reasoning (wiki) — searched, unavailable on this runner
|
||||
|
||||
Receipt: `wiki-query` could not be run. `ls -d ~/.claude/skills/*wiki*` → no match;
|
||||
`~/.obsidian-wiki/` does not exist; `OBSIDIAN_VAULT_PATH` is unset; `qmd` is not on
|
||||
`PATH`. This card runs on the **neo** SSH executor, whose home is `/Users/neo`; neither the
|
||||
vault nor the `qmd` MCP server is provisioned here. Intended query: idempotent alerting,
|
||||
fencing tokens, crossing-versus-level notification semantics. Nothing retrieved and nothing
|
||||
claimed — the forks below are **not** established as unopposed by prior positions; re-run
|
||||
with vault access before treating them as settled. #3287's own `costs-03.md` recorded the
|
||||
same negative result from a different machine for a different cause, so two independent
|
||||
runs have now failed this leg.
|
||||
|
||||
### What other products shipped (saas-kb) — unavailable
|
||||
|
||||
Receipt: the `saas-kb` MCP server and its `search_fsm_docs` tool are absent from this
|
||||
session's tool list; no tool discovery is exposed. Intended query: `category: "ai_sdlc"`,
|
||||
budget/spend-limit alerting and its concurrency handling in agent platforms (Devin,
|
||||
OpenHands, Warp, Factory.ai, Augment). Nothing retrieved; not substituted.
|
||||
|
||||
### In-repo prior art — found, and it decides the mechanism
|
||||
|
||||
Receipt: `grep -rn "revision\|generation" apps/backend/internal --include='*.go'`, plus
|
||||
reading each hit's write path. Three implementations of this fencing pattern already
|
||||
exist, and they agree on all four details:
|
||||
|
||||
| Site | Column | Bump | Fence |
|
||||
| --- | --- | --- | --- |
|
||||
| `office/repository/sqlite/workspace_groups.go:233,267` | `ownership_generation INTEGER NOT NULL DEFAULT 1` | `task/repository/sqlite/task.go:1555` `= ownership_generation + 1` | `WHERE id = ? AND ownership_generation = ?`, `(bool, error)` from `RowsAffected() == 1` |
|
||||
| `gitlab/store_config.go:57` | `revision` | `= gitlab_configs.revision + 1` | `RETURNING revision` reads the post-bump value back |
|
||||
| `user/store/sqlite.go:342` | `settings_revision` | `= settings_revision + 1` | `WHERE id = ? AND settings_revision = ?` + `RETURNING` — a full CAS |
|
||||
|
||||
`ClaimWorkspaceGroupCleanup`'s comment states the governing principle: *"The active-member
|
||||
predicate is part of the same write so a caller cannot race a membership admission between
|
||||
its read and this claim."* That is Race A, already solved, one package away. It is on
|
||||
`main` but not on #3287's head, which branched earlier — read it there, not on the build
|
||||
base.
|
||||
|
||||
A fourth precedent decides the *migration* shape rather than the fence.
|
||||
`office/repository/sqlite/run_outcome_activation.go` guards a schema-dependent action
|
||||
behind a `columnExists` probe and states why in its own comment: `MigrateLogger.Apply`
|
||||
"swallows failures at WARN, so this probe is the only thing standing between a failed
|
||||
migration and a consumer wrongly believing the mechanism is live." That helper sits in the
|
||||
same package as `office_budget_claims`; it and its reasoning carry
|
||||
`AC-OFFICE-COSTS-003.3a`.
|
||||
|
||||
**Researched, then cut from the contract — provenance for deferred work.** Serializing the
|
||||
recreate so two initializers cannot both perform it was researched here and then excluded;
|
||||
it lives in [spec.md's Out of scope](spec.md#out-of-scope), and this paragraph is the record
|
||||
that seeds it. `task/repository/sqlite/worktree_ownership_migration.go` is the repository's
|
||||
one-time destructive cutover. It probes for a legacy artifact with `tableExists` and returns
|
||||
early when it is gone, takes a fixed advisory lock whose comment states the rule —
|
||||
*"All instances must derive the same bigint so a second boot waits for the first to finish
|
||||
the cutover instead of racing it"* — and, in its own words, "never uses the best-effort
|
||||
MigrateLogger path, whose contract swallows unexpected failures." It is the only advisory-lock
|
||||
precedent in play: `run_outcome_activation.go` above takes no lock. A follow-up should read it
|
||||
there and choose a new lock id rather than reusing `taskWorktreeCutoverLockID`.
|
||||
|
||||
**What this spec takes:** the column shape, the in-SQL `+ 1` bump (never
|
||||
read-modify-write in Go), the predicate-inside-the-write fence, `(claimed bool, err error)`
|
||||
read from `RowsAffected`, and `RETURNING revision` for the read-back. **What it departs
|
||||
from:** `users.settings_revision` fences a *user-facing* write against a client-supplied
|
||||
expected revision. This spec's revision is **server-assigned and never accepted from a
|
||||
client** — it fences a background evaluation against a user-facing write, the opposite
|
||||
direction. Client-supplied would make it optimistic concurrency for the budget PATCH
|
||||
endpoint, excluded under [spec.md's Out of scope](spec.md#out-of-scope).
|
||||
@@ -0,0 +1,456 @@
|
||||
# Budget claim fencing: a claim must belong to the policy revision that earned it
|
||||
|
||||
**Status:** spec
|
||||
**Area:** backend — `internal/office/costs` (evaluation), `internal/office/repository/sqlite`
|
||||
(claim store, policy store), `internal/office/models` (policy struct)
|
||||
**Slug:** `budget-claim-revision-fencing`
|
||||
**Requirement id:** `REQ-OFFICE-COSTS-003`
|
||||
**Depends on:** PR #3287 (`REQ-OFFICE-COSTS-002`) — **open, unmerged**; see
|
||||
[Dependency and sequencing](#dependency-and-sequencing)
|
||||
**Amends:** `AC-OFFICE-COSTS-002.5`, `.8`, `.10`, `.13`, `.14a` as introduced by PR #3287 —
|
||||
see [Amendments](#amendments-to-req-office-costs-002)
|
||||
|
||||
---
|
||||
|
||||
## Why
|
||||
|
||||
`REQ-OFFICE-COSTS-002` (PR #3287) makes `budget.alert` / `budget.exceeded` idempotent with
|
||||
a durable claim table keyed `(policy_id, period_key, level)`. Two reviewers found the same
|
||||
structural gap from opposite ends: **the claim key carries no evidence of which policy
|
||||
revision earned it, nor of what else has already been claimed for that period.** #3287
|
||||
deliberately accepted both (`AC-OFFICE-COSTS-002.8`'s stale-evaluation carve-out). This
|
||||
spec closes them.
|
||||
|
||||
**Race A — stale-snapshot claim poisoning**
|
||||
([coderabbitai](https://github.com/kdlbs/kandev/pull/3287#discussion_r3914827075)).
|
||||
`CheckBudget` calls `ListBudgetPolicies` once, then hands each already-read
|
||||
`*BudgetPolicy` to `evaluatePolicy`, so an evaluation works from a snapshot. If
|
||||
`UpdateBudgetPolicy` discards that policy's claims and commits new values mid-evaluation,
|
||||
the evaluation's `Claim` insert lands **after** the discard and writes the old threshold's
|
||||
outcome into the new revision's claim slot. Two user-visible harms:
|
||||
|
||||
- **(A1) obsolete emission** — a `budget.alert` row whose `limit_subcents` describes the
|
||||
policy the user just replaced;
|
||||
- **(A2) suppressed legitimate crossing** — the next evaluation that genuinely reaches a
|
||||
level under the *new* policy finds the slot taken and stays silent until the next update
|
||||
or period rollover.
|
||||
|
||||
**Race B — `budget.alert` submitted after `budget.exceeded`**
|
||||
([cubic-dev-ai](https://github.com/kdlbs/kandev/pull/3287#discussion_r3914856011)).
|
||||
`claimAndEmit`'s `afterClaim` seam runs `claimCompanionAlert` *after* the exceeded claim,
|
||||
not atomically with it. An alert-band evaluation racing an over-limit one can win the alert
|
||||
claim in that gap and emit `budget.alert` describing a below-limit spend after
|
||||
`budget.exceeded` has gone out. Both rows land once each; the defect is that a period
|
||||
already over its limit still produces an alert-band notification at all.
|
||||
|
||||
## Dependency and sequencing
|
||||
|
||||
**This work cannot be built on `origin/main`.** Verified 2026-09-07 at `origin/main` =
|
||||
`cd7823631`: `office_budget_claims`, `Repository.Claim`, `periodKeyFor` and
|
||||
`BudgetCheckResult.AlertSubmitted` do not exist there, and `gh pr view 3287` reports
|
||||
`"state":"OPEN"`, `"mergedAt":null`. Every symbol this spec modifies is introduced by
|
||||
#3287 (head `feature/budget-alerts-should-c2t`).
|
||||
|
||||
Build shall branch from #3287's head, not `main`, and shall record which commit in the task
|
||||
plan. If #3287 has merged by then, branch from `main` as normal. This is a sequencing fact,
|
||||
not an open question: no contract below turns on it except `AC-OFFICE-COSTS-003.3`,
|
||||
where it selects only which file the claim-table DDL is edited in — never what schema the
|
||||
running database ends up with, which `AC-OFFICE-COSTS-003.3a` settles on every boot.
|
||||
|
||||
## Prior art
|
||||
|
||||
Three legs, each opening with a receipt, in [prior-art.md](prior-art.md): the wiki leg and
|
||||
the saas-kb leg both came back **unavailable on this runner** and are recorded there with
|
||||
their receipts and intended queries, so neither fork below is established as unopposed by
|
||||
prior positions. The in-repo leg **found four precedents and decided both mechanisms** —
|
||||
three agreeing fencing implementations (`ownership_generation`, `gitlab_configs.revision`,
|
||||
`users.settings_revision`) that fix the column shape, the in-SQL `+ 1` bump, the
|
||||
predicate-inside-the-write fence and the `RETURNING` read-back, and
|
||||
`run_outcome_activation.go`'s `columnExists` probe, which fixes the migration shape behind
|
||||
`AC-OFFICE-COSTS-003.3a`. Where this spec departs from a precedent is stated there too.
|
||||
|
||||
## Input inventory
|
||||
|
||||
Sampled by reading the code on #3287's head, not inferred, and recorded in
|
||||
[input-inventory.md](input-inventory.md): the two tables and their exact columns, the
|
||||
`SELECT *` / `StructScan` coupling that makes `AC-OFFICE-COSTS-003.2` mandatory rather than
|
||||
cosmetic, `UpdateBudgetPolicy`'s transaction shape and its test-only failpoint,
|
||||
`evaluatePolicy`'s snapshot-then-claim ordering and the `afterClaim` seam Race B exploits,
|
||||
`office_activity_log`'s missing tiebreak column, the two budget surfaces on `main`
|
||||
(including the PR #3276 change that makes #3287's inbox claim stale), and the `updateBudget`
|
||||
handler's unfenced read-modify-write.
|
||||
|
||||
## Design decisions
|
||||
|
||||
**Fork 1 — fence, or serialize?** coderabbitai offered both. **Fence.** #3287's
|
||||
`AC-OFFICE-COSTS-002.8` already rejected serialization with a reason this spec accepts
|
||||
verbatim: a lock spanning the evaluation and the update *"would put a user-facing write
|
||||
behind a background evaluation."* A revision fence costs one column and one predicate and
|
||||
holds no lock across the evaluation.
|
||||
|
||||
**Fork 2 — reuse `updated_at`, or add a column?** **Add `revision`.** `updated_at` is
|
||||
already bumped on every update, so it looks free, but two updates inside one clock tick
|
||||
produce the same value, timestamp round-tripping through the driver is not guaranteed to
|
||||
survive exact-equality comparison, and a backwards clock step breaks monotonicity. An
|
||||
`INTEGER` counter has none of those failure modes, and all three in-repo precedents chose
|
||||
one.
|
||||
|
||||
**Fork 3 — is `revision` a fence only, or also part of the claim's key? Both, and both are
|
||||
load-bearing.** The fence alone is sufficient on SQLite, whose single writer connection
|
||||
(`internal/db/pool.go`) serializes the claim insert against the update transaction. It is
|
||||
**not** sufficient on PostgreSQL under `READ COMMITTED`: a claim insert whose `WHERE
|
||||
EXISTS` reads the pre-update revision before the update commits will insert a row that the
|
||||
update's already-executed `DELETE` cannot remove, leaving a stale claim behind. Putting
|
||||
`revision` in the claim's primary key neutralises that survivor — it is keyed to a
|
||||
superseded revision and can never match a later evaluation. So: **the fence prevents the
|
||||
obsolete emission (A1); the revision in the key prevents the suppression (A2).** Removing
|
||||
either reopens one half on one dialect.
|
||||
|
||||
This does not contradict #3287's exclusion of `workspace_id` from the claim row. That
|
||||
exclusion was about *duplicating a fact the policy already owns*, which could drift.
|
||||
`revision` on a claim is not a copy of the policy's current revision — it is the identity
|
||||
of *which* revision earned this claim, which is a different fact and cannot drift.
|
||||
|
||||
**Fork 4 — how to close Race B.** cubic proposed making the exceeded and companion claims
|
||||
atomic, *or* making alert claims reject an already-claimed exceeded level. **Atomicity is
|
||||
necessary; the rejection rule is not, once atomicity holds.** Atomicity is also
|
||||
*insufficient* as a fix for the ordering symptom — worth stating, because that is the
|
||||
obvious reading of the comment: if the alert-band evaluation wins the alert claim *first*,
|
||||
atomicity of the exceeded pair changes nothing and both rows still go out unordered. What
|
||||
atomicity buys is the invariant **"an exceeded claim implies an alert claim at the same
|
||||
revision"**; given it, an alert-band evaluation arriving after an exceeded claim finds the
|
||||
alert row present and suppresses via the ordinary primary-key conflict. The rejection rule
|
||||
would be a second, divergent answer to a question the key already answers — the pattern
|
||||
#3287 explicitly refuses.
|
||||
|
||||
**Fork 5 — the residual write-order inversion is excluded, deliberately.** Rationale and
|
||||
bound in [Out of scope](#out-of-scope).
|
||||
|
||||
## Contract
|
||||
|
||||
### REQ-OFFICE-COSTS-003: Budget claims are fenced to the policy revision that earned them
|
||||
|
||||
**Intent:** A budget claim shall be valid only for the policy revision the evaluation
|
||||
actually read, and an evaluation whose snapshot has been superseded shall neither emit nor
|
||||
suppress.
|
||||
|
||||
**User story:** As a workspace owner who has just raised a budget limit, I want the next
|
||||
real crossing of the *new* limit to notify me, and I do not want a notification quoting
|
||||
the limit I replaced.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
Terminology (`evaluation period`, `alert level`, `limit level`, `reaches a level`, `claim`,
|
||||
`emit`, `submit`) is inherited unchanged from `REQ-OFFICE-COSTS-002`'s `## Terminology`.
|
||||
**Policy revision** is added: a per-policy integer that identifies one immutable set of
|
||||
policy field values, assigned by the server, starting at 1 and increasing by exactly 1 on
|
||||
each successful update.
|
||||
|
||||
- **AC-OFFICE-COSTS-003.1:** `office_budget_policies` shall carry a `revision` column,
|
||||
`INTEGER NOT NULL DEFAULT 1`, added by a replayable `ALTER TABLE ... ADD COLUMN`
|
||||
migration registered through the existing `r.migrate.Apply(name, sql)` mechanism, so
|
||||
that existing rows are backfilled to 1 and a boot replay is a no-op. No new local
|
||||
error-string classifier shall be introduced; `db.IsDuplicateColumnError` is the only
|
||||
one (ADR 0027).
|
||||
- **AC-OFFICE-COSTS-003.2:** `models.BudgetPolicy` shall carry the matching field, so
|
||||
that the existing `SELECT *` reads in `GetBudgetPolicy` and `ListBudgetPolicies` keep
|
||||
scanning. The field shall be serialized in the policy's JSON representation as a
|
||||
read-only diagnostic under the JSON key `revision`; the frontend `BudgetPolicy` type
|
||||
need not change, and the field shall not be suppressed from JSON.
|
||||
- **AC-OFFICE-COSTS-003.3:** `office_budget_claims` shall carry a `revision INTEGER NOT
|
||||
NULL` column, and its primary key shall be `(policy_id, period_key, level, revision)`.
|
||||
`createCostTables` shall declare that shape, **and** the recreate of
|
||||
`AC-OFFICE-COSTS-003.3a` — dropping `office_budget_claims` and recreating it with that
|
||||
same four-column primary key — shall run on every boot, whatever #3287's state. Declaring the
|
||||
final shape is not sufficient on its own, and an implementation that relies on it alone
|
||||
does not satisfy this criterion: `createCostTables` issues `CREATE TABLE IF NOT EXISTS`,
|
||||
which is a no-op against a database that has already executed #3287's three-column
|
||||
`CREATE TABLE`. Such a database would keep the three-column table and its three-column
|
||||
primary key, so every fenced insert would fail on the missing column and every
|
||||
evaluation would take the `AC-OFFICE-COSTS-002.14` fail-open path from then on —
|
||||
emitting a duplicate notification, an error log and a counter increment on each pass,
|
||||
violating `AC-OFFICE-COSTS-002.11`, and leaving this requirement's fence permanently
|
||||
inert — while a suite that only ever builds a fresh database stays green.
|
||||
`AC-OFFICE-COSTS-003.3a`'s probe is what closes that, and it costs nothing where the old
|
||||
shape never existed: it finds `revision` present and does nothing, which is exactly its
|
||||
second-boot behavior. Whether #3287 has merged — `gh pr view 3287 --json state`, the
|
||||
only check Build shall consult, deployment state being neither observable from the
|
||||
repository nor to be consulted — therefore selects only **where the final-shape DDL is
|
||||
edited**: #3287's own `CREATE TABLE` statement while it is open, the same statement on
|
||||
`main` once it has merged. It does not decide whether the recreate runs. Deciding that
|
||||
from a probe of the live schema rather than from the fork is the point: the fork is
|
||||
answered when Build authors the change, whereas the shape of the database this code
|
||||
meets is settled later, when it deploys. No `r.migrate.Apply` migration shall be added
|
||||
for this table; `AC-OFFICE-COSTS-003.3a` is expressly not one. Claims are pure
|
||||
suppression state, and losing them costs at most one additional notification per policy
|
||||
per period, which
|
||||
`AC-OFFICE-COSTS-002.17` already describes as the accepted first-run behavior.
|
||||
- **AC-OFFICE-COSTS-003.3a:** The recreate path shall take effect at most once per
|
||||
database. It shall first probe whether `office_budget_claims` already declares
|
||||
`revision` — the office repository's existing `columnExists` helper answers this on both
|
||||
dialects — and shall do nothing when it does. It shall **not** be expressed as an
|
||||
`r.migrate.Apply` statement: that runner re-executes its SQL on every boot and is
|
||||
idempotent only because an already-exists error is swallowed, so an unguarded
|
||||
`DROP TABLE` / `CREATE TABLE` pair succeeds on every boot and destroys claim state each
|
||||
time, violating `AC-OFFICE-COSTS-002.11`. It shall return its error to its caller rather
|
||||
than swallowing it, so a failed recreate is visible rather than silently leaving the old
|
||||
three-column key in place. It shall run after `office_budget_claims` exists, so it probes
|
||||
the real table rather than racing its creation. A claim attempt arriving from an
|
||||
already-running backend while the recreate is in flight is **not** required to succeed:
|
||||
it is an ordinary claim-store error and takes the `AC-OFFICE-COSTS-002.14` fail-open path,
|
||||
costing one further notification per evaluation that attempts a claim inside the recreate
|
||||
window, beyond the one `AC-OFFICE-COSTS-003.3` already accepts for discarding the claims
|
||||
themselves: that path emits without recording a claim, so it does not suppress the next
|
||||
healthy evaluation. This criterion therefore does not require the recreate to lock
|
||||
concurrent claim traffic out, and an implementation that does lock it out satisfies it
|
||||
equally.
|
||||
- **AC-OFFICE-COSTS-003.4:** When a budget policy is created, the system shall assign it
|
||||
revision 1, regardless of any revision value presented for creation — zero, or any other.
|
||||
The system shall make that assigned value available to the caller on the created policy,
|
||||
for the same reason `AC-OFFICE-COSTS-003.5` requires the post-update value to be read back:
|
||||
the create response carries the policy, and `AC-OFFICE-COSTS-003.2` makes `revision` a
|
||||
field of it, so a create that stores 1 while handing its caller a zero would report a
|
||||
revision the database does not hold. Satisfying this criterion by the column's `DEFAULT 1`
|
||||
alone therefore does not satisfy it.
|
||||
- **AC-OFFICE-COSTS-003.5:** When a budget policy is updated, the system shall increase
|
||||
that policy's revision by exactly 1, in the same transaction as the row update and the
|
||||
claim discard that `AC-OFFICE-COSTS-002.8` requires. The increase shall be expressed as
|
||||
an in-SQL `revision = revision + 1`, never as a value computed in application code from
|
||||
a prior read, so that two concurrent updates cannot both write the same revision. The
|
||||
system shall read the post-update revision back within that transaction and shall make
|
||||
it available to the caller, so that the value returned to whoever performed the update
|
||||
is the value now stored. If the update matches no policy row — the policy was deleted
|
||||
between the caller's read and this write — the read-back returns nothing, and the system
|
||||
shall roll the transaction back and return an error to the caller rather than committing
|
||||
an empty update. This tightens the pre-`REQ-OFFICE-COSTS-003` behavior, where the update
|
||||
ignored its affected-row count and reported success. The obligation is at the repository
|
||||
boundary only: the `updateBudget` handler already maps every update error to HTTP 500,
|
||||
and giving this particular case a 404 instead is a separate user-facing change, excluded
|
||||
under [Out of scope](#out-of-scope).
|
||||
- **AC-OFFICE-COSTS-003.6:** If an update transaction is rolled back for any reason, then
|
||||
the policy's revision shall be unchanged. A rolled-back update shall not consume a
|
||||
revision number.
|
||||
- **AC-OFFICE-COSTS-003.7:** The system shall not accept a revision from an API client.
|
||||
The budget-policy update request body shall have no revision field, and supplying one
|
||||
shall have no effect on the stored revision.
|
||||
- **AC-OFFICE-COSTS-003.8:** When an evaluation attempts a claim **whose outcome decides
|
||||
an emission**, it shall present the revision from the same policy snapshot it computed
|
||||
spend and levels from, and the claim shall be recorded only while that revision is still
|
||||
the policy's stored revision. The revision check and the insert shall be a single
|
||||
statement, so no evaluation can observe a matching revision and then insert against a
|
||||
superseded one. That single-statement rule governs the fence check only. It does not
|
||||
forbid the input guard `AC-OFFICE-COSTS-003.13` requires, which shall run **before** the
|
||||
statement is issued: once the statement has run, a zero-row result carries no distinction
|
||||
between a refused fence, a policy that no longer exists and an already-held claim, all
|
||||
three being the silent miss of `AC-OFFICE-COSTS-003.9`, so any outcome that must be told
|
||||
apart from them has to be settled before the statement rather than read out of it. A
|
||||
claim that decides no emission — the companion of `AC-OFFICE-COSTS-003.10` — is exempt;
|
||||
see there.
|
||||
- **AC-OFFICE-COSTS-003.9:** When a claim is refused because the presented revision is no
|
||||
longer the policy's stored revision, the system shall treat it exactly as
|
||||
`AC-OFFICE-COSTS-002.3` treats an already-held claim: no activity row, no error log, no
|
||||
counter increment, and the evaluation shall report `submitted` false for that level and
|
||||
shall still return its result to its caller without error. Enforcement
|
||||
(`AC-OFFICE-COSTS-002.12`) is unaffected: `pause_agent` and the pre-execution denial
|
||||
still run off the level check.
|
||||
- **AC-OFFICE-COSTS-003.10:** When an evaluation reaches the limit level, the system shall
|
||||
record the exceeded-level claim and the companion alert-level claim of
|
||||
`AC-OFFICE-COSTS-002.5` **atomically**: once the transaction commits either both rows
|
||||
exist for that policy, period and revision, or this evaluation wrote neither. The
|
||||
`budget.exceeded` emission shall be decided by
|
||||
whether the exceeded-level row was inserted by this evaluation; the companion row's
|
||||
outcome shall not change it. The fence shall be evaluated **once for the pair**, on the
|
||||
exceeded-level insert; the companion insert shall carry no fence of its own, because its
|
||||
key already names the revision, so a companion row written against a revision that has
|
||||
since moved is inert rather than wrong. Re-checking the fence on the companion insert is
|
||||
prohibited: it could refuse there after succeeding on the exceeded insert, breaking
|
||||
`AC-OFFICE-COSTS-003.11`. Two outcomes of the pair are named here so that neither is left
|
||||
to inference. **A companion insert that conflicts with an alert-level claim already held
|
||||
for that policy, period and revision is a success for the pair,** not a failure: that row
|
||||
is the ordinary trace of an earlier alert-band evaluation, `AC-OFFICE-COSTS-003.11`'s
|
||||
invariant is satisfied by its presence however it arrived, and the transaction shall
|
||||
commit and the `budget.exceeded` row shall go out. Reading the companion's zero affected
|
||||
rows as a pair failure is prohibited — it would withhold a `budget.exceeded` that the
|
||||
exceeded insert had already earned, which `AC-OFFICE-COSTS-002.5` forbids. **When the
|
||||
exceeded insert itself writes no row** — refused by the fence, or conflicting with an
|
||||
exceeded claim already held — the companion insert shall not be attempted and the pair
|
||||
shall commit having written nothing: there is no emission to protect, and
|
||||
`AC-OFFICE-COSTS-003.11` is not engaged.
|
||||
- **AC-OFFICE-COSTS-003.11:** The system shall maintain the invariant: **if an
|
||||
exceeded-level claim exists for a given policy, period and revision, an alert-level
|
||||
claim exists for that same policy, period and revision.** No operation shall create an
|
||||
exceeded-level claim without it.
|
||||
- **AC-OFFICE-COSTS-003.12:** While an exceeded-level claim is held for a policy, period
|
||||
and revision, an evaluation of that policy whose spend reaches the alert level shall not
|
||||
emit `budget.alert` for that period and revision. This shall follow from
|
||||
`AC-OFFICE-COSTS-003.11` and the claim's primary key, and shall not be implemented as a
|
||||
separate read of the exceeded claim before emitting.
|
||||
- **AC-OFFICE-COSTS-003.13:** If a claim attempt presents a revision less than or equal to
|
||||
zero, an empty policy id, an empty period key, or an empty level, then the system shall
|
||||
report a claim-store error, which `AC-OFFICE-COSTS-002.14` then handles by emitting,
|
||||
logging and incrementing the counter. Those four are exactly the components of the claim's
|
||||
primary key, and the guard covers all four rather than a subset: such input is a
|
||||
programming error rather than a superseded snapshot, and shall degrade toward an extra
|
||||
notification, never toward silence. An empty level leaves
|
||||
`AC-OFFICE-COSTS-003.14`'s log unambiguous — that field names the level whose emission the
|
||||
failed claim governs, which is fixed by the calling branch rather than by the rejected
|
||||
value.
|
||||
- **AC-OFFICE-COSTS-003.14:** The claim-store failure counter shall keep its single label
|
||||
value `op=claim`. The accompanying structured log's `level` field shall carry the level
|
||||
whose *emission* the failed claim governs — `exceeded` for the atomic pair of
|
||||
`AC-OFFICE-COSTS-003.10`, `alert` for an alert-band claim — so the field never carries a
|
||||
sentinel or two meanings.
|
||||
- **AC-OFFICE-COSTS-003.15:** A claim row whose revision is no longer the policy's stored
|
||||
revision shall never suppress an emission and shall not be read for any decision. The
|
||||
claim discard of `AC-OFFICE-COSTS-002.8` shall remove **all** of a policy's claims
|
||||
regardless of revision, and shall not be narrowed to the current revision, so such rows
|
||||
are reclaimed by the next update or by the policy's deletion cascade. No retention job
|
||||
shall be added.
|
||||
|
||||
## Forced-to-invent pass
|
||||
|
||||
**Ordering.** Policy evaluation order is unchanged: `created_at ASC, id ASC`
|
||||
(`AC-OFFICE-COSTS-002.16`). Within the update transaction: discard claims, update the row
|
||||
(including the revision bump), read the revision back, commit — discard first, as #3287
|
||||
specifies. Within `AC-OFFICE-COSTS-003.10`'s atomic pair the exceeded row is inserted
|
||||
first, because its insertion decides the emission; the companion alert row follows in the
|
||||
same transaction. The relative order of the two resulting `office_activity_log` rows is
|
||||
**not** specified — see [Out of scope](#out-of-scope).
|
||||
|
||||
**Idempotency and retry.** Nothing here is retried. A refused claim (already held,
|
||||
superseded revision, deleted policy) is terminal for that evaluation; the next trigger
|
||||
re-evaluates from a fresh snapshot. Re-running an identical evaluation after a successful
|
||||
claim produces no second row. An update with unchanged field values still bumps the
|
||||
revision and still discards claims — the revision counts *update operations*, not *value
|
||||
changes*, matching `AC-OFFICE-COSTS-002.8`'s refusal to compare old and new values.
|
||||
|
||||
**Concurrency — two callers, same row.** Enumerated exhaustively:
|
||||
|
||||
1. *Two evaluations, same policy, period, level, same revision, healthy store.* The
|
||||
primary key admits one. Exactly one submission (`AC-OFFICE-COSTS-002.10` preserved).
|
||||
2. *Evaluation vs. `UpdateBudgetPolicy`, evaluation claims after the update commits.* The
|
||||
fence refuses. No obsolete row; the claim slot for the new revision stays open, so the
|
||||
next evaluation that reaches a level emits. **Race A closed.**
|
||||
3. *Evaluation vs. `UpdateBudgetPolicy`, evaluation claims before the update commits.* The
|
||||
claim is accepted and the row emitted — correctly, since the policy still held those
|
||||
values at that instant. The update's discard then removes the claim (SQLite), or it
|
||||
survives keyed to the superseded revision and is inert (PostgreSQL `READ COMMITTED`).
|
||||
Either way no later evaluation is suppressed.
|
||||
4. *Alert-band evaluation vs. over-limit evaluation, exceeded claims first.* The
|
||||
alert-band evaluation finds the companion alert row present and suppresses. Only
|
||||
`budget.exceeded` is emitted. **Race B's semantic half closed.**
|
||||
5. *Alert-band evaluation vs. over-limit evaluation, alert claims first.* Both emit, once
|
||||
each. Their activity-log order is unconstrained — the named residual.
|
||||
6. *Two concurrent `UpdateBudgetPolicy` calls.* Serialized by the single writer (SQLite)
|
||||
or the row lock (PostgreSQL). Each bumps by 1; the final revision is `r+2`; no
|
||||
increment is lost. Which caller's *field values* win is the pre-existing unfenced
|
||||
read-modify-write in `updateBudget`, excluded below.
|
||||
7. *Evaluation vs. `DeleteBudgetPolicy`.* The fence's existence check fails, so the claim
|
||||
is refused with no row, no log and no counter — `AC-OFFICE-COSTS-002.14a` preserved,
|
||||
now reached through the fence rather than through the foreign key. The foreign-key
|
||||
classifier is retained because the constraint still carries `AC-OFFICE-COSTS-002.9`'s
|
||||
cascade, and dropping the classifier would let a genuine foreign-key error read as a
|
||||
store fault.
|
||||
|
||||
**Nil / empty / error.** Revision `<= 0`, empty policy id, empty period key, empty level:
|
||||
claim-store error, fail open (`AC-OFFICE-COSTS-003.13`). Claim-store error during the
|
||||
atomic pair: the transaction rolls back, no claim is held, the evaluation emits
|
||||
`budget.exceeded` and records the failure — `AC-OFFICE-COSTS-002.14` continues to take precedence over
|
||||
`AC-OFFICE-COSTS-002.5` and `.10`. Activity write failure after a successful claim: one
|
||||
notification lost, not retried, not reported (`AC-OFFICE-COSTS-002.2a`, unchanged).
|
||||
Discard failure during an update: whole update rolls back, revision unchanged, error
|
||||
returned to the caller (`AC-OFFICE-COSTS-002.8`, extended by
|
||||
`AC-OFFICE-COSTS-003.6`).
|
||||
|
||||
**Defaults and boundaries.** Revision starts at 1, is `int64`, never resets and is never
|
||||
reused for a policy id; a period rollover, a backend restart and a claim discard all leave
|
||||
it alone. Existing rows migrate to 1. A `period` change still side-steps this whole class
|
||||
of races by moving `period_key`. Row growth stays bounded by `policies × periods × 2` in
|
||||
steady state, plus case 3's inert survivors, which the next update or deletion reclaims —
|
||||
no retention job (`AC-OFFICE-COSTS-003.15`).
|
||||
|
||||
## Amendments to REQ-OFFICE-COSTS-002
|
||||
|
||||
Which `REQ-OFFICE-COSTS-002` criteria this spec supersedes, widens, or leaves untouched —
|
||||
all 21 of them, named individually — is in [amendments.md](amendments.md). No acceptance
|
||||
criterion of `REQ-OFFICE-COSTS-003` lives there.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Serializing `AC-OFFICE-COSTS-003.3a`'s recreate across two initializers.** `.3a` requires
|
||||
the recreate to take effect at most once per database, guarded by its `columnExists` probe;
|
||||
it does **not** require two backends initializing the same database to be prevented from
|
||||
both performing it. **Window:** two backends cold-starting simultaneously against one
|
||||
PostgreSQL database still carrying the pre-`.3` three-column `office_budget_claims` — not
|
||||
reachable on SQLite, whose writer pool is `MaxOpenConns(1)` (`internal/db/pool.go`), and not
|
||||
reachable at all once any boot has completed the recreate. **Residual:** both probe, both
|
||||
find `revision` absent, both drop and recreate; the table still ends in the four-column
|
||||
shape `AC-OFFICE-COSTS-003.3` mandates. Worst case one initializer's `CREATE` races the
|
||||
other's `DROP` and errors, which `.3a` already requires be returned to its caller, so that
|
||||
backend fails to boot, restarts, probes, finds `revision` present and does nothing. Claim
|
||||
loss across the window is already permitted by `AC-OFFICE-COSTS-003.3` via
|
||||
`AC-OFFICE-COSTS-002.17`. **Mechanism for a follow-up:** a shared advisory lock, as in
|
||||
`task/repository/sqlite/worktree_ownership_migration.go:216`, which takes
|
||||
`pg_advisory_xact_lock` on a fixed bigint so every instance derives the same value and a
|
||||
second boot waits for the first; a follow-up must choose a **new** lock id, not reuse that
|
||||
file's `taskWorktreeCutoverLockID`, which would cross-block an unrelated migration.
|
||||
**Why not here:** no in-repo precedent for this mechanism carries a concurrency test, so a
|
||||
follow-up has to decide whether to set that bar rather than assume it exists.
|
||||
- **The residual `budget.alert`-after-`budget.exceeded` write-order inversion.** When an
|
||||
alert-band evaluation wins the alert claim strictly before a concurrent over-limit
|
||||
evaluation claims `exceeded` (concurrency case 5), both rows are submitted and the order
|
||||
in which they reach `office_activity_log` is unconstrained, so the activity feed can
|
||||
render them exceeded-then-alert. **Bound:** at most one such pair per policy, period and
|
||||
revision, under genuine concurrency only. **Harm:** display only — both rows are
|
||||
individually truthful about the spend they observed, neither is duplicated, neither is
|
||||
lost, and the Office inbox is unaffected because `listBudgetInboxEntries` concatenates
|
||||
the two actions as separate already-sorted lists rather than interleaving them. **Why not
|
||||
fixed:** closing it needs either a lock spanning claim and emit — the mechanism
|
||||
`AC-OFFICE-COSTS-002.8` rejected and Fork 1 upholds — or a monotonic ordering key on
|
||||
`office_activity_log`, a table shared by every Office surface whose only ordering today
|
||||
is `created_at DESC` with no tiebreak, so two rows written in one clock tick already have
|
||||
undefined relative order. Promising an order for this one pair would be a new table-wide
|
||||
guarantee, not a fix; a follow-up wanting it should specify the guarantee for the whole
|
||||
activity log first.
|
||||
- **Optimistic concurrency for the budget-policy HTTP API.** `updateBudget` does an
|
||||
unfenced `GetBudgetPolicy` → patch → `UpdateBudgetPolicy`, so two concurrent PATCHes can
|
||||
lose one caller's field values. The revision column added here would make that fixable —
|
||||
`AC-OFFICE-COSTS-003.7` deliberately declines — but an expected-revision request field, a
|
||||
conflict status code, and the frontend threading are a separate user-facing change. The
|
||||
same exclusion covers the status code for `AC-OFFICE-COSTS-003.5`'s deleted-policy case:
|
||||
the handler's existing "any update error is a 500" mapping stands, and turning that one
|
||||
case into a 404 belongs with the rest of this endpoint's error contract, not here.
|
||||
- **Everything `REQ-OFFICE-COSTS-002` already excluded**, inherited unchanged rather than
|
||||
restated: unifying the three disagreeing spend-window implementations
|
||||
(`costs.periodCutoff`, `backendapp.budgetEvaluator.spendForPolicy`,
|
||||
`cron.periodStartFor`, and `BudgetPeriod.Valid()` accepting a wider `period` set than
|
||||
`periodCutoff` honours), changing when a policy reaches a level, the cron budget-alert
|
||||
trigger, resolving or expiring inbox items, `budget.exceeded` visibility, and a
|
||||
user-facing notification-frequency setting. Nothing here depends on any of them.
|
||||
- **Correcting #3287's stale claim that only `budget.alert` feeds the Office inbox.** PR
|
||||
#3276 changed that on `main` after #3287 branched; it is recorded in
|
||||
[Input inventory](#input-inventory) so Build does not rely on it. Editing #3287's own
|
||||
design documents is not this card's work.
|
||||
|
||||
## Verification
|
||||
|
||||
Test strategy, per criterion, is in [verification.md](verification.md); research
|
||||
provenance is in [prior-art.md](prior-art.md); sampled code shapes are in
|
||||
[input-inventory.md](input-inventory.md); the `REQ-OFFICE-COSTS-002` deltas are in
|
||||
[amendments.md](amendments.md). They are separate files because this document sits against
|
||||
the specification linter's 32,768-byte ceiling for its kind, the same reason
|
||||
`costs-03.md` / `costs-04.md` are split. The five files are one spec.
|
||||
|
||||
## User-visible surfaces (E2E decision input)
|
||||
|
||||
Backend-only: no new HTTP route, no request-body change, no required frontend change, no
|
||||
new user-facing string. The two surfaces that read these rows — the Office inbox
|
||||
(`/office/inbox`) and the workspace activity feed (`/office/workspace/.../activity`) — are
|
||||
unchanged in code and see strictly fewer and more accurate rows. `revision` appears as an
|
||||
additional read-only field in the budget-policy JSON, which the hand-written frontend type
|
||||
ignores.
|
||||
|
||||
**Recommendation: no new E2E coverage.** The behavior is a concurrency property between two
|
||||
backend goroutines and a database transaction, observable at the repository and evaluation
|
||||
boundaries and not reproducible through the UI. Go integration tests plus the PostgreSQL
|
||||
twin are the right level.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Budget claim fencing: verification
|
||||
|
||||
Companion to [spec.md](spec.md) (`REQ-OFFICE-COSTS-003`), alongside
|
||||
[prior-art.md](prior-art.md), [input-inventory.md](input-inventory.md) and
|
||||
[amendments.md](amendments.md). Criterion ids below are spec.md's. Split out for the
|
||||
specification linter's per-file size ceiling; the five files are one spec.
|
||||
|
||||
## Test strategy
|
||||
|
||||
Every criterion is observable at the repository or evaluation boundary. Assert submissions
|
||||
on the activity **logger**, never with a `SELECT` against `office_activity_log`
|
||||
(`AC-OFFICE-COSTS-002.2a`).
|
||||
|
||||
- **Race A regression (`.8`, `.9`, `.15`).** Read a policy snapshot at revision `r`; run
|
||||
`UpdateBudgetPolicy` to completion; attempt the snapshot's claim. Assert no activity row
|
||||
submitted, no error log, no counter increment. Then evaluate at revision `r+1` with spend
|
||||
reaching a level under the new values and assert exactly one row **is** submitted. That
|
||||
second half is the point — a test asserting only the first half also passes against an
|
||||
implementation that has merely broken emission.
|
||||
- **Race B regression (`.10`, `.11`, `.12`).** With an exceeded-level claim already held
|
||||
for `(policy, period, revision)`, evaluate the same policy with spend in the alert band.
|
||||
Assert zero `budget.alert` submissions. Separately assert the invariant directly: after
|
||||
any successful exceeded claim, both rows exist at that revision.
|
||||
- **Atomicity of the pair (`.10`).** Inject a failure between the two inserts using the
|
||||
same style of failpoint #3287 added for the discard (`failBudgetPolicyUpdateErr`,
|
||||
`budget_atomicity_internal_test.go`), and assert neither row exists and the evaluation
|
||||
fails open per `AC-OFFICE-COSTS-002.14`.
|
||||
- **Revision lifecycle (`.4`, `.5`, `.6`).** Create → 1, asserted on what the create path
|
||||
hands back to its caller and not only on the stored row — the two disagree if the
|
||||
implementation leans on the column's `DEFAULT 1` — and asserted again after presenting a
|
||||
nonzero revision for creation, which must not survive. Update → 2, and the value returned
|
||||
to the updater is 2. Rolled-back update → unchanged. Two concurrent updates → `r+2`.
|
||||
Update naming a policy id that does not exist → the transaction rolls back and the caller
|
||||
gets an error, asserted as a behavior change from #3287, where that update committed and
|
||||
reported success.
|
||||
- **Pair outcomes that are successes, not failures (`.10`, `.11`).** Two cases, both
|
||||
asserting a `budget.exceeded` row IS submitted. With an alert-level claim already held at
|
||||
`(policy, period, revision)`, run an over-limit evaluation: the companion insert conflicts,
|
||||
the pair still commits, `budget.exceeded` goes out, and both rows exist. Then, with an
|
||||
exceeded-level claim already held, run another over-limit evaluation: the exceeded insert
|
||||
writes nothing, no companion insert is attempted, nothing is emitted, and no claim-store
|
||||
failure is logged or counted. A test that only proves the happy pair misses both.
|
||||
- **Invalid claim input is not a silent miss (`.13`, and `.8`'s guard ordering).** Call the
|
||||
claim path with revision `0`, then with an empty policy id, then with an empty period key,
|
||||
then with an empty level. Each shall report a claim-store error and fail open — the
|
||||
notification IS emitted, the
|
||||
counter increments, the error is logged — which is the opposite of `.9`'s silent refusal.
|
||||
This is the criterion that fails if the guard is implemented as a `WHERE` predicate
|
||||
instead of running before the fenced statement.
|
||||
- **Which level the failure log names (`.14`).** With the claim store faulted, assert the
|
||||
structured log's `level` field is `exceeded` for the atomic pair of `.10` and `alert` for
|
||||
a standalone alert-band claim, and that a pair failure produces exactly one log line and
|
||||
one counter increment rather than two. The counter's label stays `op=claim` throughout.
|
||||
- **Revision is server-owned on the wire (`.2`, `.7`).** Assert `revision` appears in the
|
||||
budget-policy JSON under that key and is not suppressed. Then PATCH a budget policy with
|
||||
a `revision` field in the request body and assert the stored revision is the server's
|
||||
own bump, unaffected by the value supplied.
|
||||
- **Migration replay (`.1`, ADR 0027).** Fresh boot and boot replay on one connection,
|
||||
asserting the column exists once and replay is a no-op, with no new classifier.
|
||||
- **The recreate is guarded (`.3a`).** Boot once against a database still carrying the
|
||||
pre-`.3` three-column claim table — so the cutover actually runs rather than being
|
||||
short-circuited by the probe — **then** write claims, **then** boot a second time and
|
||||
assert those claims are still there. The regression this guards is
|
||||
`AC-OFFICE-COSTS-002.11`, and an unguarded `DROP`/`CREATE` pair passes a single-boot test
|
||||
and fails this one. That ordering is load-bearing rather than incidental: claims written
|
||||
*before* the first boot are destroyed by the cutover itself, which
|
||||
`AC-OFFICE-COSTS-003.3` expressly permits, so a test that holds claims across the first
|
||||
boot asserts something a correct implementation does not promise. Assert the probe finds
|
||||
`revision` present on the second boot and does nothing. This bullet applies on every
|
||||
build: `AC-OFFICE-COSTS-003.3` runs the recreate whatever #3287's state, so there is no
|
||||
longer a merge precondition to record.
|
||||
- **A failed recreate is visible, not swallowed (`.3a`).** Inject a failure into the
|
||||
recreate — the `failBudgetPolicyUpdateErr` failpoint style #3287 already uses in
|
||||
`budget_atomicity_internal_test.go`, or the shape of `TestCutover_RollbackAtEveryFailpoint`
|
||||
in `worktree_ownership_migration_test.go` — and assert the error reaches the caller that
|
||||
initializes the schema rather than being logged and dropped. This is the bullet that
|
||||
separates a correct implementation from the most likely wrong one, which is why it is
|
||||
worth its own case: the two calls sitting immediately beside the recreate's slot in
|
||||
`initSchema`, `r.runMigrations()` and `r.activateRunOutcome()`, both return nothing and
|
||||
are invoked bare, and the migration runner they use "swallows failures at WARN". A
|
||||
recreate wired to match its neighbours passes every other bullet here while violating
|
||||
`.3a`'s requirement that a failed recreate be visible rather than silently leaving the
|
||||
old three-column key in place.
|
||||
- **PostgreSQL twin (`.3`, `.8`, Fork 3).** Extend #3287's `TestPostgresBudgetClaims`
|
||||
(`KANDEV_TEST_POSTGRES_DSN`) to cover the fenced insert and the four-column key. Fork 3's
|
||||
argument is dialect-specific, so a SQLite-only proof does not establish it. If the DSN is
|
||||
unavailable on the build runner, record that in the task plan with the skip output as the
|
||||
receipt — do not silently omit it.
|
||||
- **Suppression boundary (`AC-OFFICE-COSTS-002.12`).** A refused claim on a superseded
|
||||
revision must still pause an unpaused in-scope agent and still deny a pre-execution
|
||||
check.
|
||||
Reference in New Issue
Block a user