From 1367a8900e459cdd976d833f219f7549f40a8666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 11:12:05 -0400 Subject: [PATCH] Q64: agent edits meet the same department limits as hiring Co-Authored-By: Claude Opus 5.5 --- src/main/services/org-governance-service.ts | 22 +++++++++++++ src/main/services/organization-service.ts | 1 + tests/unit/staffing-limits.test.ts | 36 +++++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 tests/unit/staffing-limits.test.ts diff --git a/src/main/services/org-governance-service.ts b/src/main/services/org-governance-service.ts index 65909955..6a8cd123 100644 --- a/src/main/services/org-governance-service.ts +++ b/src/main/services/org-governance-service.ts @@ -62,6 +62,28 @@ export class OrgGovernanceService { return { reviewRequired: input.budgetCents > policy.require_approval_above_cents, departmentName }; } + /** An existing agent moving department or changing budget meets the same limits a hire does. */ + enforceStaffingChange(input: { orgId: string; agentId: string; departmentName: string; budgetCents: number }): void { + const policy = this.db.prepare("select max_headcount, monthly_budget_limit_cents from hiring_policies where org_id = ? and department_name = ?") + .get(input.orgId, input.departmentName) as { max_headcount: number; monthly_budget_limit_cents: number } | undefined; + if (!policy) throw new Error("Hiring policy is required for department."); + const others = this.db.prepare(` + select count(*) as headcount, coalesce(sum(budget_cents), 0) as budget_cents + from agents where org_id = ? and department = ? and lifecycle = 'active' and id <> ? + `).get(input.orgId, input.departmentName, input.agentId) as { headcount: number; budget_cents: number }; + const current = this.db.prepare("select department, budget_cents from agents where id = ? and org_id = ?").get(input.agentId, input.orgId) as { department: string; budget_cents: number }; + const moving = current.department !== input.departmentName; + // Edits that neither move the agent nor raise its budget never trip a limit, even in a department already over it. + if (!moving && input.budgetCents <= current.budget_cents) return; + if (moving && others.headcount >= policy.max_headcount) { + throw new Error(`${input.departmentName} is at its headcount limit (${policy.max_headcount}).`); + } + const total = others.budget_cents + input.budgetCents; + if (total > policy.monthly_budget_limit_cents) { + throw new Error(`${input.departmentName} would budget $${(total / 100).toFixed(2)} a month, over its $${(policy.monthly_budget_limit_cents / 100).toFixed(2)} limit.`); + } + } + routeApproval(input: { orgId: string; approvalId: string; kind: string; risk: RiskLevel }): void { const rule = this.resolveRoutingRule(input.orgId, input.kind, input.risk); this.assertRouteTarget(input.orgId, rule.route_to_type, rule.route_to_id); diff --git a/src/main/services/organization-service.ts b/src/main/services/organization-service.ts index e67ec927..1b7765e9 100644 --- a/src/main/services/organization-service.ts +++ b/src/main/services/organization-service.ts @@ -185,6 +185,7 @@ export class OrganizationService { } const department = this.governance.requireCanonicalDepartment(input.orgId, input.department); const budgetCents = Math.max(0, Math.round(input.budgetCents)); + this.governance.enforceStaffingChange({ orgId: input.orgId, agentId: input.agentId, departmentName: department, budgetCents }); const avatarEmoji = input.avatarEmoji === undefined ? agent.avatar_emoji : (input.avatarEmoji.trim() || null); const avatarColor = input.avatarColor === undefined ? agent.avatar_color : normalizeAvatarColor(input.avatarColor); const avatarImage = input.avatarImage === undefined ? agent.avatar_image : normalizeAvatarImage(input.avatarImage); diff --git a/tests/unit/staffing-limits.test.ts b/tests/unit/staffing-limits.test.ts new file mode 100644 index 00000000..8f68d8d6 --- /dev/null +++ b/tests/unit/staffing-limits.test.ts @@ -0,0 +1,36 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; + +describe("staffing limits on edits", () => { + test("moving an agent or raising its budget obeys the same department limits as hiring", () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-staffing-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + let orgId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" }); + }); + connection.db.prepare("update hiring_policies set max_headcount = 1, monthly_budget_limit_cents = 5000 where org_id = ? and department_name = 'Design'").run(orgId); + connection.db.prepare("update hiring_policies set monthly_budget_limit_cents = 3000 where org_id = ? and department_name = 'Sales'").run(orgId); + const hire = (name: string, department: string, budgetCents: number) => organization.createAgent({ + orgId, name, role: "Staff", department, modelProviderId: null, modelId: null, budgetCents, managerAgentId: null, + }); + hire("Dee", "Design", 1000); + const sam = hire("Sam", "Sales", 1000); + const edit = (department: string, budgetCents: number) => organization.updateAgent({ + orgId, agentId: sam, role: "Staff", title: "Staff", roleLevel: "staff", department, budgetCents, modelProviderId: null, modelId: null, + }); + + expect(() => edit("Design", 1000)).toThrow("Design is at its headcount limit (1)."); + expect(() => edit("Sales", 3500)).toThrow("Sales would budget $35.00 a month, over its $30.00 limit."); + edit("Sales", 3000); + // A department already over its limit still allows edits that do not add to it. + connection.db.prepare("update hiring_policies set monthly_budget_limit_cents = 100 where org_id = ? and department_name = 'Sales'").run(orgId); + edit("Sales", 2000); + expect(connection.db.prepare("select department, budget_cents from agents where id = ?").get(sam)).toEqual({ department: "Sales", budget_cents: 2000 }); + connection.close(); + }); +});