diff --git a/apps/backend/internal/office/agents/dto.go b/apps/backend/internal/office/agents/dto.go
index 2ee7e21a82..93823b587a 100644
--- a/apps/backend/internal/office/agents/dto.go
+++ b/apps/backend/internal/office/agents/dto.go
@@ -50,14 +50,53 @@ type UpdateAgentStatusRequest struct {
PauseReason string `json:"pause_reason"`
}
+// agentResponseBody wraps a models.AgentInstance for API responses,
+// shadowing the shared struct's "model" key so it can be omitted for
+// Office identities (role != ""). Embedding rather than hand-mirroring
+// the 43 JSON-tagged fields on the shared struct keeps this DTO in
+// sync automatically as that struct grows; a *string shadow field
+// (not string) is required because the embedded struct's Model field
+// has no `omitempty` — a plain string would drop the "model" key
+// entirely for role=="" rows too, which AC-13 requires unchanged.
+type agentResponseBody struct {
+ *models.AgentInstance
+ Model *string `json:"model,omitempty"`
+}
+
+// newAgentResponseBody wraps agent for an API response. Model is set
+// (and so emitted) only for role=="" rows (execution profiles); nil —
+// omitted from JSON — for Office identities. A nil agent stays nil so
+// callers serialise "agent": null exactly as before, never a
+// zero-valued {} DTO.
+func newAgentResponseBody(agent *models.AgentInstance) *agentResponseBody {
+ if agent == nil {
+ return nil
+ }
+ body := &agentResponseBody{AgentInstance: agent}
+ if agent.Role == "" {
+ m := agent.Model
+ body.Model = &m
+ }
+ return body
+}
+
+// newAgentResponseBodies wraps a slice of agents for a list response.
+func newAgentResponseBodies(agents []*models.AgentInstance) []*agentResponseBody {
+ out := make([]*agentResponseBody, len(agents))
+ for i, a := range agents {
+ out[i] = newAgentResponseBody(a)
+ }
+ return out
+}
+
// AgentResponse wraps a single agent instance.
type AgentResponse struct {
- Agent *models.AgentInstance `json:"agent"`
+ Agent *agentResponseBody `json:"agent"`
}
// AgentListResponse wraps a list of agent instances.
type AgentListResponse struct {
- Agents []*models.AgentInstance `json:"agents"`
+ Agents []*agentResponseBody `json:"agents"`
}
// UpsertMemoryRequest is the request body for creating/updating agent memory.
diff --git a/apps/backend/internal/office/agents/handler.go b/apps/backend/internal/office/agents/handler.go
index df2d4e9571..1745955444 100644
--- a/apps/backend/internal/office/agents/handler.go
+++ b/apps/backend/internal/office/agents/handler.go
@@ -1,7 +1,9 @@
package agents
import (
+ "encoding/json"
"errors"
+ "io"
"net/http"
"strings"
@@ -19,6 +21,12 @@ const (
ctxKeyAgentCaller = "agent_caller"
)
+// maxUpdateAgentBodyBytes bounds the update-agent request body via
+// http.MaxBytesReader, writing the 413 response below when exceeded.
+// UpdateAgentRequest carries only short scalar fields plus a routing
+// override blob, so this is generous relative to any legitimate payload.
+const maxUpdateAgentBodyBytes = 64 * 1024
+
// Handler provides HTTP handlers for agent routes.
type Handler struct {
svc *AgentService
@@ -120,7 +128,7 @@ func (h *Handler) listAgents(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
- c.JSON(http.StatusOK, AgentListResponse{Agents: agents})
+ c.JSON(http.StatusOK, AgentListResponse{Agents: newAgentResponseBodies(agents)})
}
func (h *Handler) createAgent(c *gin.Context) {
@@ -165,7 +173,7 @@ func (h *Handler) createAgent(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
- c.JSON(http.StatusCreated, AgentResponse{Agent: agent})
+ c.JSON(http.StatusCreated, AgentResponse{Agent: newAgentResponseBody(agent)})
}
func (h *Handler) getAgent(c *gin.Context) {
@@ -174,7 +182,7 @@ func (h *Handler) getAgent(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
- c.JSON(http.StatusOK, AgentResponse{Agent: agent})
+ c.JSON(http.StatusOK, AgentResponse{Agent: newAgentResponseBody(agent)})
}
func (h *Handler) updateAgent(c *gin.Context) {
@@ -191,9 +199,8 @@ func (h *Handler) updateAgent(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
- var req UpdateAgentRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ req, ok := decodeUpdateAgentRequest(c, agent.Role != "")
+ if !ok {
return
}
if req.Permissions != nil {
@@ -212,7 +219,7 @@ func (h *Handler) updateAgent(c *gin.Context) {
})
return
}
- applyAgentUpdates(agent, &req)
+ applyAgentUpdates(agent, req)
if err := h.svc.UpdateAgentInstance(ctx, agent); err != nil {
if code := agentValidationErrorCode(err); code != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": code})
@@ -221,7 +228,57 @@ func (h *Handler) updateAgent(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
- c.JSON(http.StatusOK, AgentResponse{Agent: agent})
+ c.JSON(http.StatusOK, AgentResponse{Agent: newAgentResponseBody(agent)})
+}
+
+// decodeUpdateAgentRequest buffers the body once so the handler can inspect
+// raw keys and then decode the typed request. Office identities reject the
+// ignored model key before the request reaches the normal update path.
+func decodeUpdateAgentRequest(c *gin.Context, officeAgent bool) (*UpdateAgentRequest, bool) {
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUpdateAgentBodyBytes)
+ body, err := io.ReadAll(c.Request.Body)
+ if err != nil {
+ var maxBytesError *http.MaxBytesError
+ if errors.As(err, &maxBytesError) {
+ c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "request body too large"})
+ return nil, false
+ }
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return nil, false
+ }
+ if officeAgent {
+ hasModel, err := requestBodyHasKey(body, "model")
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return nil, false
+ }
+ if hasModel {
+ respondRoutingValidation(c, &routing.ValidationError{
+ Field: "model",
+ Message: "model no longer selects a launched model for an Office agent; " +
+ "update the agent routing override or workspace tier profiles",
+ })
+ return nil, false
+ }
+ }
+ var req UpdateAgentRequest
+ if err := json.Unmarshal(body, &req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return nil, false
+ }
+ return &req, true
+}
+
+// requestBodyHasKey reports whether the top-level JSON object in body
+// carries key, without caring about its value — {"model":"x"},
+// {"model":""}, and {"model":null} must all be detected identically.
+func requestBodyHasKey(body []byte, key string) (bool, error) {
+ raw := map[string]json.RawMessage{}
+ if err := json.Unmarshal(body, &raw); err != nil {
+ return false, err
+ }
+ _, ok := raw[key]
+ return ok, nil
}
// applyRoutingOverride validates the override blob and writes it onto
@@ -322,7 +379,7 @@ func (h *Handler) updateAgentStatus(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
- c.JSON(http.StatusOK, AgentResponse{Agent: agent})
+ c.JSON(http.StatusOK, AgentResponse{Agent: newAgentResponseBody(agent)})
}
// -- Instruction handlers --
diff --git a/apps/backend/internal/office/agents/handler_routing_test.go b/apps/backend/internal/office/agents/handler_routing_test.go
index ad74eebe7d..51160ac8f3 100644
--- a/apps/backend/internal/office/agents/handler_routing_test.go
+++ b/apps/backend/internal/office/agents/handler_routing_test.go
@@ -151,6 +151,357 @@ func asString(v interface{}) string {
return s
}
+// newGetRecorder issues a GET against the agents router and returns the
+// recorded response.
+func newGetRecorder(t *testing.T, svc *AgentService, path string) *httptest.ResponseRecorder {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ group := r.Group("/api/v1")
+ RegisterRoutes(group, svc, logger.Default())
+
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ rec := httptest.NewRecorder()
+ r.ServeHTTP(rec, req)
+ return rec
+}
+
+// decodeAgentMap decodes body's top-level "agent" key into a plain map, so
+// tests can assert on key *presence* rather than on a typed zero value —
+// AC-13c requires the "model" key to be absent, not merely empty.
+func decodeAgentMap(t *testing.T, body []byte) map[string]interface{} {
+ t.Helper()
+ var resp map[string]json.RawMessage
+ if err := json.Unmarshal(body, &resp); err != nil {
+ t.Fatalf("decode response envelope: %v", err)
+ }
+ var agent map[string]interface{}
+ if err := json.Unmarshal(resp["agent"], &agent); err != nil {
+ t.Fatalf("decode agent: %v", err)
+ }
+ return agent
+}
+
+// AC-13/AC-13b/AC-13c: the "model" key is omitted entirely (not merely
+// empty) for an Office agent (role != "") across every handler that
+// serialises an agentResponseBody.
+func TestGetAgent_OmitsModelFieldForOfficeAgent(t *testing.T) {
+ svc, _ := newTestAgentService(t)
+ ctx := context.Background()
+ agent := &models.AgentInstance{WorkspaceID: "ws-1", Name: "Worker", Role: models.AgentRoleWorker}
+ if err := svc.CreateAgentInstance(ctx, agent); err != nil {
+ t.Fatalf("create agent: %v", err)
+ }
+
+ rec := newGetRecorder(t, svc, "/api/v1/agents/"+agent.ID)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
+ }
+ got := decodeAgentMap(t, rec.Body.Bytes())
+ if _, ok := got["model"]; ok {
+ t.Errorf("model key present for Office agent: %+v", got)
+ }
+}
+
+// AC-13: a legacy execution-profile row (role == "") keeps the "model" key,
+// even when the value itself is the empty string. Such rows predate the
+// Office identity model and are constructed directly against the
+// repository, bypassing CreateAgentInstance's role validation (empty role
+// is not a creatable value through the service layer).
+func TestGetAgent_IncludesModelFieldForExecutionProfileAgent(t *testing.T) {
+ svc, repo := newTestAgentService(t)
+ ctx := context.Background()
+ agent := &models.AgentInstance{
+ ID: "legacy-1", WorkspaceID: "ws-1", Name: "Legacy Profile", Model: "gpt-5",
+ }
+ if err := repo.CreateAgentInstance(ctx, agent); err != nil {
+ t.Fatalf("create legacy agent: %v", err)
+ }
+
+ rec := newGetRecorder(t, svc, "/api/v1/agents/"+agent.ID)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
+ }
+ got := decodeAgentMap(t, rec.Body.Bytes())
+ if v, ok := got["model"]; !ok || v != "gpt-5" {
+ t.Errorf("model key = %v (present=%v), want present with gpt-5", v, ok)
+ }
+}
+
+// AC-13a: the shadow field on agentResponseBody must be a *string, not a
+// plain string. This is the regression the type choice guards against:
+// a legacy execution-profile row (role == "") with an EMPTY model value
+// must still emit the "model" key (present, empty string) — this only
+// passes because the shadow field's omitempty is checked against a nil
+// *pointer*, not the empty string it points to. A plain `string` field
+// with omitempty would silently drop this key when the value is "",
+// which is exactly the regression TestGetAgent_IncludesModelFieldForExecutionProfileAgent's
+// non-empty "gpt-5" fixture cannot catch.
+func TestGetAgent_IncludesEmptyModelKeyForExecutionProfileAgent(t *testing.T) {
+ svc, repo := newTestAgentService(t)
+ ctx := context.Background()
+ agent := &models.AgentInstance{
+ ID: "legacy-empty-model", WorkspaceID: "ws-1", Name: "Legacy No Model", Model: "",
+ }
+ if err := repo.CreateAgentInstance(ctx, agent); err != nil {
+ t.Fatalf("create legacy agent: %v", err)
+ }
+
+ rec := newGetRecorder(t, svc, "/api/v1/agents/"+agent.ID)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
+ }
+ got := decodeAgentMap(t, rec.Body.Bytes())
+ v, ok := got["model"]
+ if !ok {
+ t.Fatalf("model key absent for execution-profile agent with empty model: %+v", got)
+ }
+ if v != "" {
+ t.Errorf("model = %v, want empty string", v)
+ }
+}
+
+// AC-13b: the omission also applies to the list endpoint.
+func TestListAgents_OmitsModelFieldForOfficeAgents(t *testing.T) {
+ svc, _ := newTestAgentService(t)
+ ctx := context.Background()
+ agent := &models.AgentInstance{WorkspaceID: "ws-1", Name: "Worker", Role: models.AgentRoleWorker}
+ if err := svc.CreateAgentInstance(ctx, agent); err != nil {
+ t.Fatalf("create agent: %v", err)
+ }
+
+ rec := newGetRecorder(t, svc, "/api/v1/workspaces/ws-1/agents")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
+ }
+ var resp map[string]json.RawMessage
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("decode envelope: %v", err)
+ }
+ var list []map[string]interface{}
+ if err := json.Unmarshal(resp["agents"], &list); err != nil {
+ t.Fatalf("decode agents: %v", err)
+ }
+ if len(list) != 1 {
+ t.Fatalf("expected 1 agent, got %d", len(list))
+ }
+ if _, ok := list[0]["model"]; ok {
+ t.Errorf("model key present for Office agent in list: %+v", list[0])
+ }
+}
+
+// AC-13b: create returns the same shape as get/list.
+func TestCreateAgent_OmitsModelFieldForOfficeAgent(t *testing.T) {
+ svc, _ := newTestAgentService(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ group := r.Group("/api/v1")
+ RegisterRoutes(group, svc, logger.Default())
+
+ body := `{"name":"Worker","role":"worker"}`
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/workspaces/ws-1/agents", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ r.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusCreated {
+ t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
+ }
+ got := decodeAgentMap(t, rec.Body.Bytes())
+ if _, ok := got["model"]; ok {
+ t.Errorf("model key present for created Office agent: %+v", got)
+ }
+}
+
+// AC-13b: status transitions return the same shape too.
+func TestUpdateAgentStatus_OmitsModelFieldForOfficeAgent(t *testing.T) {
+ svc, _ := newTestAgentService(t)
+ ctx := context.Background()
+ agent := &models.AgentInstance{WorkspaceID: "ws-1", Name: "Worker", Role: models.AgentRoleWorker}
+ if err := svc.CreateAgentInstance(ctx, agent); err != nil {
+ t.Fatalf("create agent: %v", err)
+ }
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ group := r.Group("/api/v1")
+ RegisterRoutes(group, svc, logger.Default())
+
+ body := `{"status":"paused","pause_reason":"testing"}`
+ req := httptest.NewRequest(http.MethodPatch, "/api/v1/agents/"+agent.ID+"/status", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ r.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
+ }
+ got := decodeAgentMap(t, rec.Body.Bytes())
+ if _, ok := got["model"]; ok {
+ t.Errorf("model key present for Office agent after status update: %+v", got)
+ }
+}
+
+// AC-13b: a routine field-only PATCH also omits "model" from the response.
+func TestUpdateAgent_OmitsModelFieldForOfficeAgent(t *testing.T) {
+ svc, _ := newTestAgentService(t)
+ ctx := context.Background()
+ agent := &models.AgentInstance{WorkspaceID: "ws-1", Name: "Worker", Role: models.AgentRoleWorker}
+ if err := svc.CreateAgentInstance(ctx, agent); err != nil {
+ t.Fatalf("create agent: %v", err)
+ }
+
+ rec := newPatchAgentRecorder(t, svc, agent.ID, `{"icon":"robot"}`)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
+ }
+ got := decodeAgentMap(t, rec.Body.Bytes())
+ if _, ok := got["model"]; ok {
+ t.Errorf("model key present for Office agent after PATCH: %+v", got)
+ }
+}
+
+// AC-14/AC-14a: PATCH rejects a "model" key for an Office agent with a
+// structured 400 naming the field, regardless of the key's value shape —
+// a string, an empty string, and a JSON null must all be detected
+// identically since requestBodyHasKey only checks key presence.
+func TestUpdateAgent_RejectsModelKeyForOfficeAgent(t *testing.T) {
+ svc, _ := newTestAgentService(t)
+ ctx := context.Background()
+
+ cases := []struct {
+ name string
+ body string
+ }{
+ {"string value", `{"model":"gpt-5"}`},
+ {"empty string value", `{"model":""}`},
+ {"null value", `{"model":null}`},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ agent := &models.AgentInstance{WorkspaceID: "ws-1", Name: "Worker " + tc.name, Role: models.AgentRoleWorker}
+ if err := svc.CreateAgentInstance(ctx, agent); err != nil {
+ t.Fatalf("create agent: %v", err)
+ }
+
+ rec := newPatchAgentRecorder(t, svc, agent.ID, tc.body)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
+ }
+ var resp map[string]interface{}
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if resp["field"] != "model" {
+ t.Errorf("response field = %v, want model", resp["field"])
+ }
+
+ stored, err := svc.GetAgentFromConfig(ctx, agent.ID)
+ if err != nil {
+ t.Fatalf("get agent: %v", err)
+ }
+ if stored.Model != "" {
+ t.Errorf("model persisted despite 400: %q", stored.Model)
+ }
+ })
+ }
+}
+
+// TestUpdateAgent_RejectsOversizedBody pins the request-body size cap on
+// PATCH /agents/:id: a body larger than maxUpdateAgentBodyBytes must be
+// rejected with 413 before JSON decoding runs, not buffered unbounded into
+// memory.
+func TestUpdateAgent_RejectsOversizedBody(t *testing.T) {
+ svc, _ := newTestAgentService(t)
+ ctx := context.Background()
+ agent := &models.AgentInstance{WorkspaceID: "ws-1", Name: "Worker", Role: models.AgentRoleWorker}
+ if err := svc.CreateAgentInstance(ctx, agent); err != nil {
+ t.Fatalf("create agent: %v", err)
+ }
+
+ oversized := `{"name":"` + strings.Repeat("x", maxUpdateAgentBodyBytes+1) + `"}`
+ rec := newPatchAgentRecorder(t, svc, agent.ID, oversized)
+ if rec.Code != http.StatusRequestEntityTooLarge {
+ t.Fatalf("status = %d, want 413; body=%s", rec.Code, rec.Body.String())
+ }
+
+ stored, err := svc.GetAgentFromConfig(ctx, agent.ID)
+ if err != nil {
+ t.Fatalf("get agent: %v", err)
+ }
+ if stored.Name != "Worker" {
+ t.Errorf("name changed despite 413: %q", stored.Name)
+ }
+}
+
+// AC-14c (AC-40 test #4): the model-key rejection uses the same structured
+// ValidationError shape (field + details) as applyRoutingOverride, not the
+// bare {"error": "..."} shape the pre-existing agent_profile_id rejection
+// uses below in the same handler — proving these are genuinely distinct
+// response contracts, not incidentally similar ones.
+func TestUpdateAgent_ModelRejection_UsesStructuredShapeUnlikeAgentProfileIDRejection(t *testing.T) {
+ svc, _ := newTestAgentService(t)
+ ctx := context.Background()
+
+ modelAgent := &models.AgentInstance{WorkspaceID: "ws-1", Name: "Worker Model", Role: models.AgentRoleWorker}
+ if err := svc.CreateAgentInstance(ctx, modelAgent); err != nil {
+ t.Fatalf("create agent: %v", err)
+ }
+ profileAgent := &models.AgentInstance{WorkspaceID: "ws-1", Name: "Worker Profile", Role: models.AgentRoleWorker}
+ if err := svc.CreateAgentInstance(ctx, profileAgent); err != nil {
+ t.Fatalf("create agent: %v", err)
+ }
+
+ modelRec := newPatchAgentRecorder(t, svc, modelAgent.ID, `{"model":"gpt-5"}`)
+ profileRec := newPatchAgentRecorder(t, svc, profileAgent.ID, `{"agent_profile_id":"prof-1"}`)
+
+ var modelResp, profileResp map[string]interface{}
+ if err := json.Unmarshal(modelRec.Body.Bytes(), &modelResp); err != nil {
+ t.Fatalf("decode model response: %v", err)
+ }
+ if err := json.Unmarshal(profileRec.Body.Bytes(), &profileResp); err != nil {
+ t.Fatalf("decode profile response: %v", err)
+ }
+
+ if _, ok := modelResp["field"]; !ok {
+ t.Errorf("model rejection missing structured field key: %+v", modelResp)
+ }
+ if _, ok := profileResp["field"]; ok {
+ t.Errorf("agent_profile_id rejection unexpectedly carries a field key: %+v", profileResp)
+ }
+}
+
+// AC-14b: the model-key gate is scoped to Office agents (role != "") —
+// requestBodyHasKey is only consulted when agent.Role != "". A legacy
+// execution-profile row (role == "") never reaches this endpoint
+// successfully at all (validateAgentUpdate rejects any empty role,
+// independent of this feature), so the gate being scoped correctly is
+// what keeps that pre-existing, unrelated 500 as the response instead of
+// this feature's 400 "field":"model" shape shadowing it.
+func TestUpdateAgent_ModelKeyGateSkipped_ForExecutionProfileAgent(t *testing.T) {
+ svc, repo := newTestAgentService(t)
+ ctx := context.Background()
+ agent := &models.AgentInstance{
+ ID: "legacy-2", WorkspaceID: "ws-1", Name: "Legacy Profile", Model: "gpt-5",
+ }
+ if err := repo.CreateAgentInstance(ctx, agent); err != nil {
+ t.Fatalf("create legacy agent: %v", err)
+ }
+
+ rec := newPatchAgentRecorder(t, svc, agent.ID, `{"model":"claude-opus"}`)
+ if rec.Code != http.StatusInternalServerError {
+ t.Fatalf("status = %d, want 500 (pre-existing invalid-role rejection); body=%s",
+ rec.Code, rec.Body.String())
+ }
+ var resp map[string]interface{}
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if resp["field"] == "model" {
+ t.Errorf("model-key gate fired for role==\"\" agent, want it scoped to Office agents: %+v", resp)
+ }
+}
+
// TestAgentAuthMiddleware_RejectsCrossWorkspaceToken ensures a token minted
// for workspace A cannot access endpoints scoped to workspace B via the
// :wsId path parameter. Without this check, an agent in one workspace could
diff --git a/apps/backend/internal/office/dashboard/handler_routing_test.go b/apps/backend/internal/office/dashboard/handler_routing_test.go
index 3652978756..d4ef029b8c 100644
--- a/apps/backend/internal/office/dashboard/handler_routing_test.go
+++ b/apps/backend/internal/office/dashboard/handler_routing_test.go
@@ -251,7 +251,7 @@ func TestRouting_PreviewComposesAgentRows(t *testing.T) {
fake.preview = []routing.PreviewItem{
{
AgentID: "a1", AgentName: "Alice",
- TierSource: "inherit", EffectiveTier: "balanced",
+ TierSource: routing.TierSourceWorkspace, EffectiveTier: "balanced",
PrimaryProviderID: "claude-acp", PrimaryModel: "sonnet",
FallbackChain: []routing.PreviewProviderModel{
{ProviderID: "codex-acp", Model: "gpt-5", Tier: "balanced"},
@@ -284,6 +284,87 @@ func TestRouting_PreviewComposesAgentRows(t *testing.T) {
}
}
+// AC-18a/AC-20: the preview row's TierSource passes through unmodified,
+// including the two new widened values ("role", "workspace") the
+// dashboard layer never previously had to carry.
+func TestRouting_PreviewRow_WidenedTierSourceValues(t *testing.T) {
+ cases := []string{
+ routing.TierSourceOverride,
+ routing.TierSourceRole,
+ routing.TierSourceWorkspace,
+ }
+ for _, source := range cases {
+ t.Run(source, func(t *testing.T) {
+ deps, fake := newRoutingTestDeps(t)
+ fake.preview = []routing.PreviewItem{
+ {AgentID: "a1", AgentName: "Alice", TierSource: source, EffectiveTier: "balanced"},
+ }
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/office/workspaces/ws-1/routing/preview", nil)
+ rec := httptest.NewRecorder()
+ deps.router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
+ }
+ var resp dashboard.RoutingPreviewResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if len(resp.Agents) != 1 || resp.Agents[0].TierSource != source {
+ t.Fatalf("TierSource = %+v, want %q", resp.Agents, source)
+ }
+ })
+ }
+}
+
+// AC-20/AC-20a: RouteAttemptDTO.TierSource carries the persisted
+// precedence level through the /runs/:id/attempts endpoint.
+func TestRouting_AttemptsEndpoint_TierSourceRoundTrips(t *testing.T) {
+ deps, _ := newRoutingTestDeps(t)
+ lister := &fakeAttemptLister{byRun: map[string][]models.RouteAttempt{
+ "run-1": {
+ {RunID: "run-1", Seq: 1, ProviderID: "claude-acp", Model: "haiku",
+ Tier: "economy", TierSource: "role", Outcome: "launched",
+ StartedAt: time.Now().UTC()},
+ },
+ }}
+ deps.svc.SetRouteAttemptLister(lister)
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/office/runs/run-1/attempts", nil)
+ rec := httptest.NewRecorder()
+ deps.router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
+ }
+ var resp dashboard.RouteAttemptsResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if len(resp.Attempts) != 1 || resp.Attempts[0].TierSource != "role" {
+ t.Fatalf("unexpected attempts: %+v", resp.Attempts)
+ }
+}
+
+// An attempt row with no recorded tier_source (pre-migration or a
+// max-attempts-exceeded terminal row) omits the field entirely rather
+// than emitting "tier_source":"" — the DTO field carries omitempty.
+func TestRouting_AttemptsEndpoint_EmptyTierSourceOmittedFromJSON(t *testing.T) {
+ deps, _ := newRoutingTestDeps(t)
+ lister := &fakeAttemptLister{byRun: map[string][]models.RouteAttempt{
+ "run-1": {
+ {RunID: "run-1", Seq: 1, Outcome: "skipped_max_attempts", StartedAt: time.Now().UTC()},
+ },
+ }}
+ deps.svc.SetRouteAttemptLister(lister)
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/office/runs/run-1/attempts", nil)
+ rec := httptest.NewRecorder()
+ deps.router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
+ }
+ if bytes.Contains(rec.Body.Bytes(), []byte(`"tier_source"`)) {
+ t.Fatalf("expected tier_source to be omitted, got %s", rec.Body.String())
+ }
+}
+
func TestRouting_HealthEndpointReturnsRows(t *testing.T) {
deps, fake := newRoutingTestDeps(t)
fake.healthRows = []models.ProviderHealth{
diff --git a/apps/backend/internal/office/dashboard/routing_dto.go b/apps/backend/internal/office/dashboard/routing_dto.go
index 6cd974c047..69df5a86d3 100644
--- a/apps/backend/internal/office/dashboard/routing_dto.go
+++ b/apps/backend/internal/office/dashboard/routing_dto.go
@@ -31,8 +31,11 @@ type RoutingHealthResponse struct {
}
// AgentRoutePreview is one row in the workspace routing preview table.
-// TierSource is "inherit" when the agent uses the workspace default and
-// "override" when its override blob sets a tier explicitly.
+// TierSource names the precedence level that supplied EffectiveTier:
+// one of "wake_reason", "override", "role", "workspace" (see
+// routing.PreviewItem.TierSource / effectiveTier). Always computed
+// with an empty run reason, so this never reports "wake_reason" even
+// when the agent's own wake-reason policy would shadow it at runtime.
//
// PrimaryProviderID/PrimaryModel reflect the configured intent (first
// entry in the effective provider order). CurrentProviderID/
@@ -72,21 +75,26 @@ type RoutingPreviewResponse struct {
// RouteAttemptDTO mirrors models.RouteAttempt with stable JSON tags for
// the dashboard run-detail UI. Timestamps are formatted as RFC3339.
type RouteAttemptDTO struct {
- Seq int `json:"seq"`
- ExecutionProfileID string `json:"execution_profile_id,omitempty"`
- ProviderID string `json:"provider_id"`
- Model string `json:"model,omitempty"`
- Tier string `json:"tier"`
- Outcome string `json:"outcome"`
- ErrorCode string `json:"error_code,omitempty"`
- ErrorConfidence string `json:"error_confidence,omitempty"`
- AdapterPhase string `json:"adapter_phase,omitempty"`
- ClassifierRule string `json:"classifier_rule,omitempty"`
- ExitCode *int `json:"exit_code,omitempty"`
- RawExcerpt string `json:"raw_excerpt,omitempty"`
- ResetHint *string `json:"reset_hint,omitempty"`
- StartedAt string `json:"started_at"`
- FinishedAt *string `json:"finished_at,omitempty"`
+ Seq int `json:"seq"`
+ ExecutionProfileID string `json:"execution_profile_id,omitempty"`
+ ProviderID string `json:"provider_id"`
+ Model string `json:"model,omitempty"`
+ Tier string `json:"tier"`
+ // TierSource names the precedence level that supplied Tier for this
+ // attempt: one of "wake_reason", "override", "role", "workspace",
+ // or "" for attempts recorded before this column existed or that
+ // never resolved a tier (see models.RouteAttempt.TierSource).
+ TierSource string `json:"tier_source,omitempty"`
+ Outcome string `json:"outcome"`
+ ErrorCode string `json:"error_code,omitempty"`
+ ErrorConfidence string `json:"error_confidence,omitempty"`
+ AdapterPhase string `json:"adapter_phase,omitempty"`
+ ClassifierRule string `json:"classifier_rule,omitempty"`
+ ExitCode *int `json:"exit_code,omitempty"`
+ RawExcerpt string `json:"raw_excerpt,omitempty"`
+ ResetHint *string `json:"reset_hint,omitempty"`
+ StartedAt string `json:"started_at"`
+ FinishedAt *string `json:"finished_at,omitempty"`
}
// RunRouting is embedded on the run-detail response so the routing
@@ -130,6 +138,7 @@ func routeAttemptToDTO(a models.RouteAttempt) RouteAttemptDTO {
ProviderID: a.ProviderID,
Model: a.Model,
Tier: a.Tier,
+ TierSource: a.TierSource,
Outcome: string(a.Outcome),
ErrorCode: a.ErrorCode,
ErrorConfidence: string(a.ErrorConfidence),
diff --git a/apps/backend/internal/office/models/models.go b/apps/backend/internal/office/models/models.go
index 85ead513e1..f6fc78a769 100644
--- a/apps/backend/internal/office/models/models.go
+++ b/apps/backend/internal/office/models/models.go
@@ -459,22 +459,28 @@ type Run struct {
// Persisted by the routing scheduler dispatcher in
// internal/office/scheduler/dispatch_routing.go.
type RouteAttempt struct {
- RunID string `json:"run_id" db:"run_id"`
- Seq int `json:"seq" db:"seq"`
- ExecutionProfileID string `json:"execution_profile_id" db:"execution_profile_id"`
- ProviderID string `json:"provider_id" db:"provider_id"`
- Model string `json:"model" db:"model"`
- Tier string `json:"tier" db:"tier"`
- Outcome RouteAttemptOutcome `json:"outcome" db:"outcome"`
- ErrorCode string `json:"error_code,omitempty" db:"error_code"`
- ErrorConfidence ErrorConfidence `json:"error_confidence,omitempty" db:"error_confidence"`
- AdapterPhase AdapterPhase `json:"adapter_phase,omitempty" db:"adapter_phase"`
- ClassifierRule string `json:"classifier_rule,omitempty" db:"classifier_rule"`
- ExitCode *int `json:"exit_code,omitempty" db:"exit_code"`
- RawExcerpt string `json:"raw_excerpt,omitempty" db:"raw_excerpt"`
- ResetHint *time.Time `json:"reset_hint,omitempty" db:"reset_hint"`
- StartedAt time.Time `json:"started_at" db:"started_at"`
- FinishedAt *time.Time `json:"finished_at,omitempty" db:"finished_at"`
+ RunID string `json:"run_id" db:"run_id"`
+ Seq int `json:"seq" db:"seq"`
+ ExecutionProfileID string `json:"execution_profile_id" db:"execution_profile_id"`
+ ProviderID string `json:"provider_id" db:"provider_id"`
+ Model string `json:"model" db:"model"`
+ Tier string `json:"tier" db:"tier"`
+ // TierSource names the precedence level that supplied Tier: one of
+ // "wake_reason", "override", "role", "workspace". Empty means "not
+ // recorded" — either a pre-migration row (Tier non-empty) or an
+ // attempt that never resolved a tier (both empty, e.g. a
+ // max-attempts-exceeded row) — never interpreted as "workspace".
+ TierSource string `json:"tier_source,omitempty" db:"tier_source"`
+ Outcome RouteAttemptOutcome `json:"outcome" db:"outcome"`
+ ErrorCode string `json:"error_code,omitempty" db:"error_code"`
+ ErrorConfidence ErrorConfidence `json:"error_confidence,omitempty" db:"error_confidence"`
+ AdapterPhase AdapterPhase `json:"adapter_phase,omitempty" db:"adapter_phase"`
+ ClassifierRule string `json:"classifier_rule,omitempty" db:"classifier_rule"`
+ ExitCode *int `json:"exit_code,omitempty" db:"exit_code"`
+ RawExcerpt string `json:"raw_excerpt,omitempty" db:"raw_excerpt"`
+ ResetHint *time.Time `json:"reset_hint,omitempty" db:"reset_hint"`
+ StartedAt time.Time `json:"started_at" db:"started_at"`
+ FinishedAt *time.Time `json:"finished_at,omitempty" db:"finished_at"`
}
// ProviderHealth records the health state of one (workspace, provider,
diff --git a/apps/backend/internal/office/repository/sqlite/base_migrations.go b/apps/backend/internal/office/repository/sqlite/base_migrations.go
index 5d24492546..1c9d2932c0 100644
--- a/apps/backend/internal/office/repository/sqlite/base_migrations.go
+++ b/apps/backend/internal/office/repository/sqlite/base_migrations.go
@@ -94,6 +94,7 @@ func (r *Repository) migrateProviderRouting() {
provider_order TEXT NOT NULL DEFAULT '[]',
provider_profiles TEXT NOT NULL DEFAULT '{}',
tier_per_reason TEXT NOT NULL DEFAULT '{}',
+ role_tiers TEXT NOT NULL DEFAULT '{}',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)`)
@@ -105,6 +106,7 @@ func (r *Repository) migrateProviderRouting() {
provider_id TEXT NOT NULL,
model TEXT NOT NULL,
tier TEXT NOT NULL,
+ tier_source TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL,
error_code TEXT,
error_confidence TEXT,
@@ -123,6 +125,10 @@ func (r *Repository) migrateProviderRouting() {
`ALTER TABLE runs ADD COLUMN resolved_execution_profile_id TEXT`)
r.migrate.Apply("office_run_route_attempts.execution_profile_id",
`ALTER TABLE office_run_route_attempts ADD COLUMN execution_profile_id TEXT NOT NULL DEFAULT ''`)
+ r.migrate.Apply("office_workspace_routing.role_tiers",
+ `ALTER TABLE office_workspace_routing ADD COLUMN role_tiers TEXT NOT NULL DEFAULT '{}'`)
+ r.migrate.Apply("office_run_route_attempts.tier_source",
+ `ALTER TABLE office_run_route_attempts ADD COLUMN tier_source TEXT NOT NULL DEFAULT ''`)
_, _ = r.db.Exec(`
CREATE TABLE IF NOT EXISTS office_provider_health (
diff --git a/apps/backend/internal/office/repository/sqlite/role_tiers_test.go b/apps/backend/internal/office/repository/sqlite/role_tiers_test.go
new file mode 100644
index 0000000000..4a187bc029
--- /dev/null
+++ b/apps/backend/internal/office/repository/sqlite/role_tiers_test.go
@@ -0,0 +1,110 @@
+package sqlite_test
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/kandev/kandev/internal/office/routing"
+)
+
+// TestUpsertWorkspaceRouting_RoleTiersRoundTrip pins the JSON round-trip
+// for the new role_tiers column, including AC-11's drop-empty-values
+// behavior on write.
+func TestUpsertWorkspaceRouting_RoleTiersRoundTrip(t *testing.T) {
+ repo := newTestRepo(t)
+ ctx := context.Background()
+ in := &routing.WorkspaceConfig{
+ Enabled: true,
+ DefaultTier: routing.TierBalanced,
+ ProviderOrder: []routing.ProviderID{"claude-acp"},
+ ProviderProfiles: map[routing.ProviderID]routing.ProviderProfile{
+ "claude-acp": {TierMap: routing.TierMap{Balanced: "sonnet", Economy: "haiku"}},
+ },
+ RoleTiers: routing.RoleTierMap{
+ "qa": routing.TierEconomy,
+ "devops": routing.TierEconomy,
+ "worker": "", // AC-11: dropped before persistence
+ },
+ }
+ if err := repo.UpsertWorkspaceRouting(ctx, "ws-1", in); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ got, err := repo.GetWorkspaceRouting(ctx, "ws-1")
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ if got.RoleTiers["qa"] != routing.TierEconomy {
+ t.Errorf("qa tier = %q, want economy", got.RoleTiers["qa"])
+ }
+ if got.RoleTiers["devops"] != routing.TierEconomy {
+ t.Errorf("devops tier = %q, want economy", got.RoleTiers["devops"])
+ }
+ if _, ok := got.RoleTiers["worker"]; ok {
+ t.Errorf("empty-value worker entry survived persistence: %+v", got.RoleTiers)
+ }
+}
+
+// AC-40 test #1: a literal empty-string role_tiers column (the state a
+// pre-migration row lands in via ADD COLUMN ... DEFAULT ”) decodes to an
+// empty map with no error. This is the precedent-trap test proving
+// decodeRoleTiers's raw=="" special case was necessary — the sibling
+// tier_per_reason column only needs COALESCE(..., '{}') because SQL NULL
+// is its only "missing" state, but role_tiers can also be a literal ”
+// coming from the migration's own DEFAULT clause.
+func TestGetWorkspaceRouting_EmptyStringRoleTiersColumn(t *testing.T) {
+ repo, _ := newTestRepoWithDB(t)
+ ctx := context.Background()
+ if _, err := repo.ExecRaw(ctx, `
+ INSERT INTO office_workspace_routing
+ (workspace_id, enabled, default_tier, provider_order, provider_profiles, role_tiers, updated_at)
+ VALUES (?, 1, 'balanced', '[]', '{}', '', datetime('now'))
+ `, "ws-empty-roles"); err != nil {
+ t.Fatalf("insert row with literal empty role_tiers: %v", err)
+ }
+
+ cfg, err := repo.GetWorkspaceRouting(ctx, "ws-empty-roles")
+ if err != nil {
+ t.Fatalf("GetWorkspaceRouting: %v", err)
+ }
+ if len(cfg.RoleTiers) != 0 {
+ t.Errorf("RoleTiers = %+v, want empty map", cfg.RoleTiers)
+ }
+}
+
+// AC-33: malformed (non-empty, non-JSON) role_tiers content is rejected
+// rather than silently swallowed.
+func TestGetWorkspaceRouting_MalformedRoleTiersColumn(t *testing.T) {
+ repo, _ := newTestRepoWithDB(t)
+ ctx := context.Background()
+ if _, err := repo.ExecRaw(ctx, `
+ INSERT INTO office_workspace_routing
+ (workspace_id, enabled, default_tier, provider_order, provider_profiles, role_tiers, updated_at)
+ VALUES (?, 1, 'balanced', '[]', '{}', 'not-json', datetime('now'))
+ `, "ws-bad-roles"); err != nil {
+ t.Fatalf("insert row with malformed role_tiers: %v", err)
+ }
+
+ _, err := repo.GetWorkspaceRouting(ctx, "ws-bad-roles")
+ if err == nil {
+ t.Fatal("expected error decoding malformed role_tiers")
+ }
+ if !strings.Contains(err.Error(), "role_tiers") {
+ t.Errorf("error should name role_tiers, got %v", err)
+ }
+}
+
+// defaultWorkspaceRouting (the never-configured-workspace path) also
+// carries a non-nil, empty RoleTiers map.
+func TestGetWorkspaceRouting_DefaultOnEmpty_RoleTiers(t *testing.T) {
+ repo := newTestRepo(t)
+ ctx := context.Background()
+
+ cfg, err := repo.GetWorkspaceRouting(ctx, "ws-never-configured")
+ if err != nil {
+ t.Fatalf("GetWorkspaceRouting: %v", err)
+ }
+ if len(cfg.RoleTiers) != 0 {
+ t.Errorf("RoleTiers = %+v, want empty map", cfg.RoleTiers)
+ }
+}
diff --git a/apps/backend/internal/office/repository/sqlite/route_attempts.go b/apps/backend/internal/office/repository/sqlite/route_attempts.go
index 7e46793e42..3e4ed3e67e 100644
--- a/apps/backend/internal/office/repository/sqlite/route_attempts.go
+++ b/apps/backend/internal/office/repository/sqlite/route_attempts.go
@@ -22,11 +22,11 @@ func (r *Repository) AppendRouteAttempt(
}
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO office_run_route_attempts (
- run_id, seq, execution_profile_id, provider_id, model, tier, outcome,
+ run_id, seq, execution_profile_id, provider_id, model, tier, tier_source, outcome,
error_code, error_confidence, adapter_phase, classifier_rule,
exit_code, raw_excerpt, reset_hint, started_at, finished_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- `), a.RunID, a.Seq, a.ExecutionProfileID, a.ProviderID, a.Model, a.Tier, a.Outcome,
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `), a.RunID, a.Seq, a.ExecutionProfileID, a.ProviderID, a.Model, a.Tier, a.TierSource, a.Outcome,
nullableString(a.ErrorCode), nullableString(string(a.ErrorConfidence)),
nullableString(string(a.AdapterPhase)), nullableString(a.ClassifierRule),
a.ExitCode, nullableString(a.RawExcerpt), a.ResetHint,
@@ -45,7 +45,7 @@ func (r *Repository) ListRouteAttempts(
ctx context.Context, runID string,
) ([]models.RouteAttempt, error) {
rows, err := r.ro.QueryxContext(ctx, r.ro.Rebind(`
- SELECT run_id, seq, execution_profile_id, provider_id, model, tier, outcome,
+ SELECT run_id, seq, execution_profile_id, provider_id, model, tier, tier_source, outcome,
COALESCE(error_code,'') AS error_code,
COALESCE(error_confidence,'') AS error_confidence,
COALESCE(adapter_phase,'') AS adapter_phase,
diff --git a/apps/backend/internal/office/repository/sqlite/route_attempts_test.go b/apps/backend/internal/office/repository/sqlite/route_attempts_test.go
index f361fc2f12..752635e394 100644
--- a/apps/backend/internal/office/repository/sqlite/route_attempts_test.go
+++ b/apps/backend/internal/office/repository/sqlite/route_attempts_test.go
@@ -102,6 +102,47 @@ func TestAppendAndListRouteAttempts_RoundTrip(t *testing.T) {
}
}
+// TestAppendAndListRouteAttempts_TierSourceRoundTrip pins the JSON-free
+// round-trip for the new tier_source column, including the empty-string
+// default (a max-attempts-exceeded row, or any attempt appended before
+// this column existed, never resolved a tier).
+func TestAppendAndListRouteAttempts_TierSourceRoundTrip(t *testing.T) {
+ repo := newTestRepo(t)
+ ctx := context.Background()
+ now := time.Now().UTC().Truncate(time.Second)
+
+ seedRun(t, repo, "run-1", "agent-1", now)
+
+ withSource := &models.RouteAttempt{
+ RunID: "run-1", Seq: 1, ProviderID: "claude-acp", Model: "haiku",
+ Tier: "economy", TierSource: "role", Outcome: "launched", StartedAt: now,
+ }
+ if err := repo.AppendRouteAttempt(ctx, withSource); err != nil {
+ t.Fatalf("append with tier_source: %v", err)
+ }
+ withoutSource := &models.RouteAttempt{
+ RunID: "run-1", Seq: 2, ProviderID: "claude-acp", Model: "sonnet",
+ Tier: "balanced", Outcome: "launched", StartedAt: now.Add(time.Minute),
+ }
+ if err := repo.AppendRouteAttempt(ctx, withoutSource); err != nil {
+ t.Fatalf("append without tier_source: %v", err)
+ }
+
+ attempts, err := repo.ListRouteAttempts(ctx, "run-1")
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(attempts) != 2 {
+ t.Fatalf("expected 2 attempts, got %d", len(attempts))
+ }
+ if attempts[0].TierSource != "role" {
+ t.Errorf("attempts[0].TierSource = %q, want role", attempts[0].TierSource)
+ }
+ if attempts[1].TierSource != "" {
+ t.Errorf("attempts[1].TierSource = %q, want empty", attempts[1].TierSource)
+ }
+}
+
func TestListRouteAttempts_EmptyReturnsEmptySlice(t *testing.T) {
repo := newTestRepo(t)
ctx := context.Background()
diff --git a/apps/backend/internal/office/repository/sqlite/workspace_routing.go b/apps/backend/internal/office/repository/sqlite/workspace_routing.go
index 9a0a99148d..14b4b10d71 100644
--- a/apps/backend/internal/office/repository/sqlite/workspace_routing.go
+++ b/apps/backend/internal/office/repository/sqlite/workspace_routing.go
@@ -33,14 +33,15 @@ func (r *Repository) GetWorkspaceRouting(
orderRaw string
profsRaw string
reasonsRaw string
+ rolesRaw string
updatedAt time.Time
)
err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(`
SELECT enabled, default_tier, provider_order, provider_profiles,
- COALESCE(tier_per_reason, '{}'), updated_at
+ COALESCE(tier_per_reason, '{}'), COALESCE(role_tiers, '{}'), updated_at
FROM office_workspace_routing
WHERE workspace_id = ?
- `), workspaceID).Scan(&enabled, &tier, &orderRaw, &profsRaw, &reasonsRaw, &updatedAt)
+ `), workspaceID).Scan(&enabled, &tier, &orderRaw, &profsRaw, &reasonsRaw, &rolesRaw, &updatedAt)
if errors.Is(err, sql.ErrNoRows) {
return defaultWorkspaceRouting(), nil
}
@@ -60,12 +61,31 @@ func (r *Repository) GetWorkspaceRouting(
if err := json.Unmarshal([]byte(reasonsRaw), &cfg.TierPerReason); err != nil {
return nil, fmt.Errorf("routing: decode tier_per_reason: %w", err)
}
+ if err := decodeRoleTiers(rolesRaw, &cfg.RoleTiers); err != nil {
+ return nil, fmt.Errorf("routing: decode role_tiers: %w", err)
+ }
if cfg.ProviderProfiles == nil {
cfg.ProviderProfiles = map[routing.ProviderID]routing.ProviderProfile{}
}
return cfg, nil
}
+// decodeRoleTiers decodes raw into *out, treating a literal empty
+// string as "{}" (no role policy) before unmarshalling (AC-32). The
+// adjacent tier_per_reason decode above does not need this: it reads
+// COALESCE(tier_per_reason, '{}'), which only substitutes for SQL
+// NULL, so a literal ” falls through to json.Unmarshal and errors —
+// which happens to be correct there (AC-33 malformed-JSON rejection),
+// but AC-32 explicitly names ” as a valid role_tiers value, so
+// copying the sibling's COALESCE-only pattern would wrongly reject it.
+func decodeRoleTiers(raw string, out *routing.RoleTierMap) error {
+ if raw == "" {
+ *out = routing.RoleTierMap{}
+ return nil
+ }
+ return json.Unmarshal([]byte(raw), out)
+}
+
// UpsertWorkspaceRouting persists cfg for workspaceID, replacing any
// previously stored row. JSON marshaling errors are surfaced before
// touching the DB so a malformed config can never leave a half-written
@@ -88,6 +108,10 @@ func (r *Repository) UpsertWorkspaceRouting(
if err != nil {
return err
}
+ rolesRaw, err := marshalRoleTiers(cfg.RoleTiers)
+ if err != nil {
+ return err
+ }
enabled := 0
if cfg.Enabled {
enabled = 1
@@ -98,16 +122,17 @@ func (r *Repository) UpsertWorkspaceRouting(
}
_, err = r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO office_workspace_routing
- (workspace_id, enabled, default_tier, provider_order, provider_profiles, tier_per_reason, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?)
+ (workspace_id, enabled, default_tier, provider_order, provider_profiles, tier_per_reason, role_tiers, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(workspace_id) DO UPDATE SET
enabled = excluded.enabled,
default_tier = excluded.default_tier,
provider_order = excluded.provider_order,
provider_profiles = excluded.provider_profiles,
tier_per_reason = excluded.tier_per_reason,
+ role_tiers = excluded.role_tiers,
updated_at = excluded.updated_at
- `), workspaceID, enabled, tier, orderRaw, profsRaw, reasonsRaw, time.Now().UTC())
+ `), workspaceID, enabled, tier, orderRaw, profsRaw, reasonsRaw, rolesRaw, time.Now().UTC())
if err != nil {
return fmt.Errorf("routing: upsert workspace %q: %w", workspaceID, err)
}
@@ -123,6 +148,7 @@ func defaultWorkspaceRouting() *routing.WorkspaceConfig {
ProviderOrder: []routing.ProviderID{},
ProviderProfiles: map[routing.ProviderID]routing.ProviderProfile{},
TierPerReason: routing.TierPerReason{},
+ RoleTiers: routing.RoleTierMap{},
}
}
@@ -215,6 +241,26 @@ func marshalTierPerReason(m routing.TierPerReason) (string, error) {
return string(b), nil
}
+// marshalRoleTiers marshals the role tier policy, normalising nil to
+// "{}" and dropping empty-string values (AC-11: an empty value means
+// "absent, fall through to default_tier", not "explicitly set to a
+// blank tier" — dropping it here keeps GetWorkspaceRouting's read path
+// from ever having to special-case a persisted empty-string entry).
+func marshalRoleTiers(m routing.RoleTierMap) (string, error) {
+ out := make(routing.RoleTierMap, len(m))
+ for role, tier := range m {
+ if tier == "" {
+ continue
+ }
+ out[role] = tier
+ }
+ b, err := json.Marshal(out)
+ if err != nil {
+ return "", fmt.Errorf("routing: marshal role_tiers: %w", err)
+ }
+ return string(b), nil
+}
+
// marshalOrder marshals the provider order, normalising nil to "[]"
// so the column's NOT NULL default invariant is preserved.
func marshalOrder(order []routing.ProviderID) (string, error) {
diff --git a/apps/backend/internal/office/routing/provider.go b/apps/backend/internal/office/routing/provider.go
index a557af6b99..eb758e1829 100644
--- a/apps/backend/internal/office/routing/provider.go
+++ b/apps/backend/internal/office/routing/provider.go
@@ -22,8 +22,15 @@ import (
// eligible candidate); when not degraded these equal the primary.
// When every provider is skipped, both Current fields are empty.
type PreviewItem struct {
- AgentID string
- AgentName string
+ AgentID string
+ AgentName string
+ // TierSource names the precedence level supplying EffectiveTier:
+ // one of TierSourceWakeReason, TierSourceOverride, TierSourceRole,
+ // TierSourceWorkspace (see effectiveTier). Always computed with an
+ // empty reason (AC-18b), so this never reports TierSourceWakeReason
+ // even when the agent's own wake-reason policy would shadow it at
+ // runtime — the frontend surfaces that shadowing separately from
+ // the agent's TierPerReason data, not from this field.
TierSource string
EffectiveTier string
PrimaryProviderID string
@@ -166,14 +173,15 @@ func (p *Provider) ListExecutionProfiles(
// - enabled→disabled: parked runs would otherwise sit forever waiting
// on a provider that no longer matters.
// - enabled→enabled with a material change (provider order, default
-// tier, or provider profiles): a user fixing a missing tier mapping
-// should unblock blocked_provider_action_required runs immediately;
-// without this, those parks persist until the user manually retries.
+// tier, role tiers, or provider profiles): a user fixing a missing
+// tier mapping should unblock blocked_provider_action_required runs
+// immediately; without this, those parks persist until the user
+// manually retries.
//
// "Material" is defined coarsely — any change to ProviderOrder,
-// DefaultTier, or ProviderProfiles triggers a clear. False positives
-// (clearing when the change couldn't affect any block reason) are
-// harmless because runs simply re-dispatch and re-park with the
+// DefaultTier, RoleTiers, or ProviderProfiles triggers a clear. False
+// positives (clearing when the change couldn't affect any block reason)
+// are harmless because runs simply re-dispatch and re-park with the
// latest verdict.
func (p *Provider) UpdateConfig(
ctx context.Context, workspaceID string, cfg WorkspaceConfig,
@@ -396,8 +404,8 @@ func shouldClearParked(prev *WorkspaceConfig, next WorkspaceConfig) bool {
// routingConfigEqual reports whether two enabled configs are
// behaviorally identical for routing decisions. Compares provider
-// order, default tier, and per-provider profile maps. The Enabled
-// field is intentionally not compared — callers check that.
+// order, default tier, per-provider profile maps, and role tiers. The
+// Enabled field is intentionally not compared — callers check that.
func routingConfigEqual(a, b WorkspaceConfig) bool {
if a.DefaultTier != b.DefaultTier {
return false
@@ -405,9 +413,24 @@ func routingConfigEqual(a, b WorkspaceConfig) bool {
if !providerOrderEqual(a.ProviderOrder, b.ProviderOrder) {
return false
}
+ if !roleTiersEqual(a.RoleTiers, b.RoleTiers) {
+ return false
+ }
return providerProfilesEqual(a.ProviderProfiles, b.ProviderProfiles)
}
+func roleTiersEqual(a, b RoleTierMap) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for k, va := range a {
+ if vb, ok := b[k]; !ok || va != vb {
+ return false
+ }
+ }
+ return true
+}
+
func providerOrderEqual(a, b []ProviderID) bool {
if len(a) != len(b) {
return false
@@ -567,11 +590,11 @@ func (p *Provider) previewForAgent(
ctx context.Context, workspaceID string,
agent models.AgentInstance, cfg *WorkspaceConfig,
) (PreviewItem, error) {
- tierSource := tierSourceForAgent(agent, cfg)
res, err := p.resolver.Resolve(ctx, workspaceID, agent, ResolveOptions{})
if err != nil {
return PreviewItem{}, fmt.Errorf("routing: resolve %s: %w", agent.ID, err)
}
+ tierSource := previewTierSource(res, agent, cfg)
primaryProvider, primaryProfile, primaryModel := primaryProviderModel(res, cfg)
return PreviewItem{
AgentID: agent.ID,
@@ -607,17 +630,43 @@ func primaryProviderModel(res *Resolution, cfg *WorkspaceConfig) (string, string
return string(first), prof.ExecutionProfileID(res.RequestedTier), prof.TierMap.Model(res.RequestedTier)
}
-// tierSourceForAgent returns "override" when the agent's settings flip
-// TierSource explicitly; "inherit" otherwise.
-func tierSourceForAgent(agent models.AgentInstance, _ *WorkspaceConfig) string {
- ov, err := ReadAgentOverrides(agent.Settings)
- if err != nil {
- return "inherit"
+// tierSourceForAgent delegates to effectiveTier — the resolve path's
+// producer — rather than deciding independently, so preview and resolve
+// never diverge on which precedence level supplied the tier (AC-20d).
+// Always called with an empty reason (AC-18b): a preview never carries
+// run context, so this can never report TierSourceWakeReason. A parse
+// error on the agent's settings blob falls back to zero-value overrides
+// rather than a fixed string, so the result still lands on one of the
+// four widened values instead of the old error-only "inherit" literal.
+func tierSourceForAgent(agent models.AgentInstance, cfg *WorkspaceConfig) string {
+ ov, _ := ReadAgentOverrides(agent.Settings)
+ if cfg == nil {
+ cfg = &WorkspaceConfig{}
}
- if ov.TierSource == TierSourceOverride && ov.Tier != "" {
- return "override"
+ _, source := effectiveTier(cfg, ov, string(agent.Role), "")
+ return source
+}
+
+// previewTierSource returns the source PreviewItem.TierSource must carry.
+// AC-20d requires preview and resolve to share one producer AND never
+// disagree — sharing the effectiveTier function alone is not enough,
+// because previewForAgent's cfg snapshot and Resolve's own internal
+// GetWorkspaceRouting read are two independent reads of the same row: a
+// workspace routing write landing between them would otherwise let
+// tierSourceForAgent(agent, cfg) answer from a config Resolve never saw.
+// res.TierSource is therefore the primary source — it is exactly what
+// Resolve computed against the config it actually used. The one
+// exception is Resolve's disabled-empty-config early return
+// (resolver.go, cfg == nil || (!cfg.Enabled && len(cfg.ProviderOrder) ==
+// 0)), which returns a Resolution that never calls effectiveTier and so
+// carries an empty TierSource; previewForAgent's own contract still
+// requires a non-blank preview row in that case, so it falls back to the
+// snapshot computation only then.
+func previewTierSource(res *Resolution, agent models.AgentInstance, cfg *WorkspaceConfig) string {
+ if res != nil && res.TierSource != "" {
+ return res.TierSource
}
- return "inherit"
+ return tierSourceForAgent(agent, cfg)
}
// effectivePreviewTier returns the tier the resolver consumed, falling
diff --git a/apps/backend/internal/office/routing/provider_test.go b/apps/backend/internal/office/routing/provider_test.go
index ec60c7bcd2..ef1db23e27 100644
--- a/apps/backend/internal/office/routing/provider_test.go
+++ b/apps/backend/internal/office/routing/provider_test.go
@@ -19,9 +19,22 @@ type fakeProviderRepo struct {
byID map[string]*models.AgentInstance
clearedParkedFor []string // workspace ids passed to ClearAllParked
clearedParkedErr error
+
+ // cfgSeq, when non-nil, makes GetWorkspaceRouting return a different
+ // config on each successive call (advancing cfgSeqIdx), simulating a
+ // workspace routing row changing between two reads within a single
+ // logical operation (AC-20d race coverage). Falls back to cfg once
+ // the sequence is exhausted.
+ cfgSeq []*WorkspaceConfig
+ cfgSeqIdx int
}
func (f *fakeProviderRepo) GetWorkspaceRouting(_ context.Context, _ string) (*WorkspaceConfig, error) {
+ if f.cfgSeqIdx < len(f.cfgSeq) {
+ cfg := f.cfgSeq[f.cfgSeqIdx]
+ f.cfgSeqIdx++
+ return cfg, nil
+ }
return f.cfg, nil
}
@@ -414,6 +427,61 @@ func TestProvider_PreviewAgentRoundTrips(t *testing.T) {
}
}
+// AC-20d: PreviewItem.TierSource must equal what Resolve actually used,
+// never a value computed from a separately-read, potentially stale copy
+// of the workspace config. previewForAgent reads the workspace config
+// once for tierSourceForAgent and Resolve reads it again internally;
+// this simulates a routing config write landing between those two reads
+// and asserts the preview reports the same source the live resolve did,
+// not the stale snapshot's answer.
+func TestProvider_PreviewAgentTierSourceMatchesResolveDespiteConfigRaceBetweenReads(t *testing.T) {
+ profiles := map[ProviderID]ProviderProfile{
+ "claude-acp": {
+ ExecutionProfileIDs: ExecutionProfileIDs{Frontier: "claude-opus", Balanced: "claude-sonnet", Economy: "claude-haiku"},
+ TierMap: TierMap{Frontier: "opus", Balanced: "sonnet", Economy: "haiku"},
+ },
+ }
+ stale := &WorkspaceConfig{
+ Enabled: true,
+ DefaultTier: TierBalanced,
+ ProviderOrder: []ProviderID{"claude-acp"},
+ ProviderProfiles: profiles,
+ RoleTiers: RoleTierMap{"qa": TierFrontier},
+ }
+ fresh := &WorkspaceConfig{
+ Enabled: true,
+ DefaultTier: TierBalanced,
+ ProviderOrder: []ProviderID{"claude-acp"},
+ ProviderProfiles: profiles,
+ RoleTiers: RoleTierMap{}, // role tier removed between the two reads
+ }
+ agent := &models.AgentInstance{ID: "a1", Name: "Alice", WorkspaceID: "ws-1", Role: settingsmodels.AgentRoleQA}
+ repo := &fakeProviderRepo{
+ cfgSeq: []*WorkspaceConfig{stale, fresh},
+ agents: []*models.AgentInstance{agent},
+ byID: map[string]*models.AgentInstance{"a1": agent},
+ }
+ resolver := NewResolver(&providerResolverAdapter{repo: repo}, nil)
+ p := NewProvider(repo, nil, resolver, &fakeRetry{})
+
+ item, err := p.PreviewAgent(context.Background(), "a1")
+ if err != nil {
+ t.Fatalf("PreviewAgent: %v", err)
+ }
+ if item == nil {
+ t.Fatal("PreviewAgent returned nil item")
+ return
+ }
+ if item.TierSource != TierSourceWorkspace {
+ t.Errorf("TierSource = %q, want %q — must match what Resolve actually computed against"+
+ " the fresh config, not the stale role_tiers entry read earlier",
+ item.TierSource, TierSourceWorkspace)
+ }
+ if item.EffectiveTier != string(TierBalanced) {
+ t.Errorf("EffectiveTier = %q, want %q", item.EffectiveTier, TierBalanced)
+ }
+}
+
func TestProvider_UpdateRejectsInvalid(t *testing.T) {
cfg := &WorkspaceConfig{}
p, _ := newProviderTest(cfg, nil)
@@ -525,6 +593,37 @@ func TestProvider_UpdateClearsParkedWhenExecutionProfileChanges(t *testing.T) {
}
}
+func TestProvider_UpdateClearsParkedOnRoleTiersOnlyChange(t *testing.T) {
+ prev := &WorkspaceConfig{
+ Enabled: true,
+ DefaultTier: TierBalanced,
+ ProviderOrder: []ProviderID{"claude-acp"},
+ ProviderProfiles: map[ProviderID]ProviderProfile{
+ "claude-acp": {
+ TierMap: TierMap{Frontier: "opus", Balanced: "sonnet"},
+ ExecutionProfileIDs: ExecutionProfileIDs{
+ Frontier: "claude-opus", Balanced: "claude-sonnet",
+ },
+ },
+ },
+ RoleTiers: RoleTierMap{"qa": TierFrontier},
+ }
+ p, repo := newProviderTest(prev, nil)
+ // Only RoleTiers changes; DefaultTier, ProviderOrder, and
+ // ProviderProfiles stay identical. A run parked because the "qa"
+ // role resolved to frontier with no usable candidate should unblock
+ // once that role is remapped to balanced.
+ next := *prev
+ next.RoleTiers = RoleTierMap{"qa": TierBalanced}
+
+ if err := p.UpdateConfig(context.Background(), "ws-1", next); err != nil {
+ t.Fatalf("update config: %v", err)
+ }
+ if len(repo.clearedParkedFor) != 1 || repo.clearedParkedFor[0] != "ws-1" {
+ t.Fatalf("role-tiers-only change did not clear parked runs: %v", repo.clearedParkedFor)
+ }
+}
+
func TestProvider_UpdateDoesNotClearWhenConfigUnchanged(t *testing.T) {
prev := &WorkspaceConfig{
Enabled: true,
diff --git a/apps/backend/internal/office/routing/resolver.go b/apps/backend/internal/office/routing/resolver.go
index 52339f3c84..c060b85fe6 100644
--- a/apps/backend/internal/office/routing/resolver.go
+++ b/apps/backend/internal/office/routing/resolver.go
@@ -124,8 +124,14 @@ type BlockReason struct {
// fall through to the existing concrete-profile launch path and ignore
// the other fields.
type Resolution struct {
- Enabled bool
- RequestedTier Tier
+ Enabled bool
+ RequestedTier Tier
+ // TierSource names the precedence level that supplied
+ // RequestedTier: one of TierSourceWakeReason, TierSourceOverride,
+ // TierSourceRole, TierSourceWorkspace, or "" (only when
+ // RequestedTier is also "", see effectiveTier). This is the sole
+ // carrier of the resolved source — it is not recomputed downstream.
+ TierSource string
ProviderOrder []ProviderID
Candidates []Candidate
SkippedDegraded []SkippedCandidate
@@ -174,12 +180,17 @@ func (r *Resolver) Resolve(
if err != nil {
return nil, fmt.Errorf("routing: load agent overrides: %w", err)
}
- tier := effectiveTier(cfg, ov, opts.Reason)
+ tier, tierSource := effectiveTier(cfg, ov, string(agent.Role), opts.Reason)
order := effectiveOrder(cfg, ov)
if len(order) == 0 {
return nil, ErrEmptyOrder
}
- res := &Resolution{Enabled: cfg.Enabled, RequestedTier: tier, ProviderOrder: order}
+ res := &Resolution{
+ Enabled: cfg.Enabled,
+ RequestedTier: tier,
+ TierSource: tierSource,
+ ProviderOrder: order,
+ }
idx, err := r.loadHealthIndex(ctx, workspaceID)
if err != nil {
return nil, err
@@ -414,21 +425,47 @@ func aggregateBlock(skipped []SkippedCandidate) BlockReason {
return br
}
-// effectiveTier resolves the tier for one run.
+// effectiveTier resolves the tier for one run and the precedence level
+// that supplied it (see Resolution.TierSource and PreviewItem.TierSource).
// Order: 1) wake-reason policy (agent override > workspace policy),
-// 2) agent tier override, 3) workspace default. The reason argument
-// may be empty when the caller has no run context — in that case the
-// wake-reason step is skipped.
-func effectiveTier(cfg *WorkspaceConfig, ov AgentOverrides, reason string) Tier {
+// 2) agent tier override, 3) per-role tier (role's entry in
+// cfg.RoleTiers), 4) workspace default. The reason argument may be
+// empty when the caller has no run context — in that case the
+// wake-reason step is skipped (this is how a preview computation,
+// which never carries a reason, is guaranteed to never report
+// TierSourceWakeReason — see AC-18b). role is the agent's role string;
+// an empty role, or a role absent from (or empty-valued in)
+// cfg.RoleTiers, simply never matches the map lookup and falls through.
+// A non-empty role outside the seven-value enum is explicitly gated by
+// agentRoleSet() (AC-34): role_tiers may persist a matching key for such
+// a role (AC-34b permits it on the read path), so the lookup itself
+// would otherwise match — the enum check exists specifically to stop
+// that from applying. When cfg.DefaultTier is itself empty, the function
+// returns ("", "") rather than (DefaultTier, TierSourceWorkspace): an
+// empty source must never be interpreted as "workspace" (AC-20c). This
+// cannot happen in practice (validateTier rejects an empty default_tier
+// and the column default is non-empty), but the fallthrough is pinned
+// defensively anyway.
+func effectiveTier(cfg *WorkspaceConfig, ov AgentOverrides, role string, reason string) (Tier, string) {
if reason != "" {
if t := wakeReasonTier(cfg, ov, reason); t != "" {
- return t
+ return t, TierSourceWakeReason
}
}
if ov.TierSource == TierSourceOverride && ov.Tier != "" {
- return ov.Tier
+ return ov.Tier, TierSourceOverride
}
- return cfg.DefaultTier
+ if role != "" {
+ if _, known := agentRoleSet()[role]; known {
+ if t, ok := cfg.RoleTiers[role]; ok && t != "" {
+ return t, TierSourceRole
+ }
+ }
+ }
+ if cfg.DefaultTier == "" {
+ return "", ""
+ }
+ return cfg.DefaultTier, TierSourceWorkspace
}
// wakeReasonTier returns the tier the wake-reason policy assigns for
diff --git a/apps/backend/internal/office/routing/role_tiers_resolve_test.go b/apps/backend/internal/office/routing/role_tiers_resolve_test.go
new file mode 100644
index 0000000000..f391d0b5f8
--- /dev/null
+++ b/apps/backend/internal/office/routing/role_tiers_resolve_test.go
@@ -0,0 +1,234 @@
+package routing_test
+
+import (
+ "context"
+ "testing"
+
+ settingsmodels "github.com/kandev/kandev/internal/agent/settings/models"
+ "github.com/kandev/kandev/internal/office/routing"
+)
+
+// agentWithRole builds a resolver-ready agent profile carrying role and an
+// optional overrides blob, mirroring agentWithOverrides but adding the Role
+// field the widened effectiveTier precedence needs.
+func agentWithRole(t *testing.T, role settingsmodels.AgentRole, ov routing.AgentOverrides) settingsmodels.AgentProfile {
+ t.Helper()
+ agent := agentWithOverrides(t, ov)
+ agent.Role = role
+ return agent
+}
+
+// AC-3/AC-7 style: with no wake reason, no per-agent override, and no
+// matching role entry, the workspace default_tier supplies the tier and
+// TierSource reports "workspace".
+func TestResolve_TierSource_WorkspaceDefault_NoRoleEntry(t *testing.T) {
+ cfg := twoProviderCfg()
+ repo := &fakeRepo{cfg: cfg}
+ r := newResolver(t, repo)
+
+ res, err := r.Resolve(context.Background(), wsID,
+ agentWithRole(t, settingsmodels.AgentRoleQA, routing.AgentOverrides{}),
+ routing.ResolveOptions{})
+ if err != nil {
+ t.Fatalf("resolve: %v", err)
+ }
+ if res.RequestedTier != cfg.DefaultTier {
+ t.Errorf("RequestedTier = %q, want workspace default %q", res.RequestedTier, cfg.DefaultTier)
+ }
+ if res.TierSource != routing.TierSourceWorkspace {
+ t.Errorf("TierSource = %q, want %q", res.TierSource, routing.TierSourceWorkspace)
+ }
+}
+
+// AC-11 fallthrough at the resolve layer: a role_tiers entry present but
+// set to "" behaves identically to the role being entirely absent from
+// the map — falls through to the workspace default.
+func TestResolve_TierSource_EmptyRoleEntryFallsThroughToWorkspace(t *testing.T) {
+ cfg := twoProviderCfg()
+ cfg.RoleTiers = routing.RoleTierMap{"qa": ""}
+ repo := &fakeRepo{cfg: cfg}
+ r := newResolver(t, repo)
+
+ res, err := r.Resolve(context.Background(), wsID,
+ agentWithRole(t, settingsmodels.AgentRoleQA, routing.AgentOverrides{}),
+ routing.ResolveOptions{})
+ if err != nil {
+ t.Fatalf("resolve: %v", err)
+ }
+ if res.RequestedTier != cfg.DefaultTier || res.TierSource != routing.TierSourceWorkspace {
+ t.Errorf("got (%q,%q), want (%q,%q)",
+ res.RequestedTier, res.TierSource, cfg.DefaultTier, routing.TierSourceWorkspace)
+ }
+}
+
+// The new level 3: a role_tiers entry matching the agent's role supplies
+// the tier, below any per-agent override and above the workspace default.
+func TestResolve_TierSource_RoleTierApplies(t *testing.T) {
+ cfg := twoProviderCfg()
+ cfg.RoleTiers = routing.RoleTierMap{"qa": routing.TierEconomy}
+ repo := &fakeRepo{cfg: cfg}
+ r := newResolver(t, repo)
+
+ res, err := r.Resolve(context.Background(), wsID,
+ agentWithRole(t, settingsmodels.AgentRoleQA, routing.AgentOverrides{}),
+ routing.ResolveOptions{})
+ if err != nil {
+ t.Fatalf("resolve: %v", err)
+ }
+ if res.RequestedTier != routing.TierEconomy {
+ t.Errorf("RequestedTier = %q, want economy", res.RequestedTier)
+ }
+ if res.TierSource != routing.TierSourceRole {
+ t.Errorf("TierSource = %q, want %q", res.TierSource, routing.TierSourceRole)
+ }
+}
+
+// A role_tiers entry for a different role than the agent's own must not
+// apply — it falls through to the workspace default exactly as if
+// role_tiers were empty.
+func TestResolve_TierSource_RoleTierForDifferentRoleDoesNotApply(t *testing.T) {
+ cfg := twoProviderCfg()
+ cfg.RoleTiers = routing.RoleTierMap{"devops": routing.TierEconomy}
+ repo := &fakeRepo{cfg: cfg}
+ r := newResolver(t, repo)
+
+ res, err := r.Resolve(context.Background(), wsID,
+ agentWithRole(t, settingsmodels.AgentRoleQA, routing.AgentOverrides{}),
+ routing.ResolveOptions{})
+ if err != nil {
+ t.Fatalf("resolve: %v", err)
+ }
+ if res.RequestedTier != cfg.DefaultTier || res.TierSource != routing.TierSourceWorkspace {
+ t.Errorf("got (%q,%q), want workspace default", res.RequestedTier, res.TierSource)
+ }
+}
+
+// Level 2 beats level 3: a per-agent tier override outranks the agent's
+// role tier.
+func TestResolve_TierSource_AgentOverrideBeatsRoleTier(t *testing.T) {
+ cfg := twoProviderCfg()
+ cfg.RoleTiers = routing.RoleTierMap{"qa": routing.TierEconomy}
+ repo := &fakeRepo{cfg: cfg}
+ r := newResolver(t, repo)
+
+ ov := routing.AgentOverrides{TierSource: routing.TierSourceOverride, Tier: routing.TierFrontier}
+ res, err := r.Resolve(context.Background(), wsID,
+ agentWithRole(t, settingsmodels.AgentRoleQA, ov),
+ routing.ResolveOptions{})
+ if err != nil {
+ t.Fatalf("resolve: %v", err)
+ }
+ if res.RequestedTier != routing.TierFrontier {
+ t.Errorf("RequestedTier = %q, want frontier (agent override)", res.RequestedTier)
+ }
+ if res.TierSource != routing.TierSourceOverride {
+ t.Errorf("TierSource = %q, want %q", res.TierSource, routing.TierSourceOverride)
+ }
+}
+
+// Level 1 beats everything: a matching wake-reason policy outranks both
+// the per-agent override and the role tier.
+func TestResolve_TierSource_WakeReasonBeatsOverrideAndRoleTier(t *testing.T) {
+ cfg := twoProviderCfg()
+ cfg.TierPerReason = routing.TierPerReason{routing.WakeReasonHeartbeat: routing.TierEconomy}
+ cfg.RoleTiers = routing.RoleTierMap{"qa": routing.TierFrontier}
+ repo := &fakeRepo{cfg: cfg}
+ r := newResolver(t, repo)
+
+ ov := routing.AgentOverrides{TierSource: routing.TierSourceOverride, Tier: routing.TierBalanced}
+ res, err := r.Resolve(context.Background(), wsID,
+ agentWithRole(t, settingsmodels.AgentRoleQA, ov),
+ routing.ResolveOptions{Reason: routing.WakeReasonHeartbeat})
+ if err != nil {
+ t.Fatalf("resolve: %v", err)
+ }
+ if res.RequestedTier != routing.TierEconomy {
+ t.Errorf("RequestedTier = %q, want economy (wake reason)", res.RequestedTier)
+ }
+ if res.TierSource != routing.TierSourceWakeReason {
+ t.Errorf("TierSource = %q, want %q", res.TierSource, routing.TierSourceWakeReason)
+ }
+}
+
+// AC-34: an agent whose role is outside the seven-value enum (a row
+// written by an older build or by hand) must never consult role_tiers,
+// even when the persisted map happens to hold a matching non-enum key
+// (AC-34b permits such a key to exist on the read path). Resolution
+// falls through to the workspace default exactly as if role_tiers were
+// empty for this agent.
+func TestResolve_TierSource_NonEnumRoleIgnoresMatchingRoleTiersKey(t *testing.T) {
+ cfg := twoProviderCfg()
+ cfg.RoleTiers = routing.RoleTierMap{"legacy-role": routing.TierFrontier}
+ repo := &fakeRepo{cfg: cfg}
+ r := newResolver(t, repo)
+
+ res, err := r.Resolve(context.Background(), wsID,
+ agentWithRole(t, settingsmodels.AgentRole("legacy-role"), routing.AgentOverrides{}),
+ routing.ResolveOptions{})
+ if err != nil {
+ t.Fatalf("resolve: %v", err)
+ }
+ if res.RequestedTier != cfg.DefaultTier {
+ t.Errorf("RequestedTier = %q, want workspace default %q (non-enum role must not match role_tiers)",
+ res.RequestedTier, cfg.DefaultTier)
+ }
+ if res.TierSource != routing.TierSourceWorkspace {
+ t.Errorf("TierSource = %q, want %q", res.TierSource, routing.TierSourceWorkspace)
+ }
+}
+
+// AC-6: an agent whose role is the empty string must never consult
+// role_tiers at all, regardless of the map's contents. This is the
+// strongest form of the guard: role_tiers holds an entry keyed by the
+// empty string (unreachable via the write path, since AC-8 restricts
+// keys to the seven-value enum, but a resolver read-path lookup does not
+// itself know that) to prove the `role != ""` gate in effectiveTier, not
+// merely the absence of an empty key in a realistic fixture.
+func TestResolve_TierSource_EmptyRoleNeverConsultsRoleTiers(t *testing.T) {
+ cfg := twoProviderCfg()
+ cfg.RoleTiers = routing.RoleTierMap{"": routing.TierFrontier}
+ repo := &fakeRepo{cfg: cfg}
+ r := newResolver(t, repo)
+
+ res, err := r.Resolve(context.Background(), wsID,
+ agentWithRole(t, settingsmodels.AgentRole(""), routing.AgentOverrides{}),
+ routing.ResolveOptions{})
+ if err != nil {
+ t.Fatalf("resolve: %v", err)
+ }
+ if res.RequestedTier != cfg.DefaultTier {
+ t.Errorf("RequestedTier = %q, want workspace default %q (empty role must not match role_tiers)",
+ res.RequestedTier, cfg.DefaultTier)
+ }
+ if res.TierSource != routing.TierSourceWorkspace {
+ t.Errorf("TierSource = %q, want %q", res.TierSource, routing.TierSourceWorkspace)
+ }
+}
+
+// AC-12a: a disabled workspace with a non-empty provider order still
+// resolves the role tier — Enabled only gates automatic fallback, not
+// tier resolution. This is the branch that does NOT hit Resolve's
+// "disabled and empty order" early return.
+func TestResolve_TierSource_RoleTierAppliesWhenDisabled(t *testing.T) {
+ cfg := twoProviderCfg()
+ cfg.Enabled = false
+ cfg.RoleTiers = routing.RoleTierMap{"qa": routing.TierEconomy}
+ repo := &fakeRepo{cfg: cfg}
+ r := newResolver(t, repo)
+
+ res, err := r.Resolve(context.Background(), wsID,
+ agentWithRole(t, settingsmodels.AgentRoleQA, routing.AgentOverrides{}),
+ routing.ResolveOptions{})
+ if err != nil {
+ t.Fatalf("resolve: %v", err)
+ }
+ if res.Enabled {
+ t.Fatalf("expected Enabled=false to be preserved")
+ }
+ if res.RequestedTier != routing.TierEconomy {
+ t.Errorf("RequestedTier = %q, want economy even though disabled", res.RequestedTier)
+ }
+ if res.TierSource != routing.TierSourceRole {
+ t.Errorf("TierSource = %q, want %q", res.TierSource, routing.TierSourceRole)
+ }
+}
diff --git a/apps/backend/internal/office/routing/role_tiers_test.go b/apps/backend/internal/office/routing/role_tiers_test.go
new file mode 100644
index 0000000000..7d6f9acd5d
--- /dev/null
+++ b/apps/backend/internal/office/routing/role_tiers_test.go
@@ -0,0 +1,191 @@
+package routing_test
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/kandev/kandev/internal/office/routing"
+)
+
+// roleTiersCfg returns a minimal enabled config with one provider mapping
+// all three tiers, used as the base for role_tiers validation cases.
+func roleTiersCfg() routing.WorkspaceConfig {
+ return routing.WorkspaceConfig{
+ Enabled: true,
+ DefaultTier: routing.TierBalanced,
+ ProviderOrder: []routing.ProviderID{"claude-acp"},
+ ProviderProfiles: map[routing.ProviderID]routing.ProviderProfile{
+ "claude-acp": {
+ ExecutionProfileIDs: routing.ExecutionProfileIDs{
+ Frontier: "claude-opus-profile",
+ Balanced: "claude-sonnet-profile",
+ Economy: "claude-haiku-profile",
+ },
+ TierMap: routing.TierMap{
+ Frontier: "opus",
+ Balanced: "sonnet",
+ Economy: "haiku",
+ },
+ },
+ },
+ }
+}
+
+// AC-8: an unknown role key is rejected with a per-key ValidationDetail.
+func TestValidateWorkspaceConfig_RoleTiers_UnknownRole(t *testing.T) {
+ cfg := roleTiersCfg()
+ cfg.RoleTiers = routing.RoleTierMap{"astronaut": routing.TierEconomy}
+
+ err := routing.ValidateWorkspaceConfig(cfg, testKnown)
+ var ve *routing.ValidationError
+ if !errors.As(err, &ve) {
+ t.Fatalf("expected *ValidationError, got %v", err)
+ }
+ if ve.Field != "role_tiers" {
+ t.Fatalf("Field = %q, want role_tiers", ve.Field)
+ }
+ if len(ve.Details) != 1 || ve.Details[0].Field != "astronaut" {
+ t.Fatalf("Details = %+v, want one entry for astronaut", ve.Details)
+ }
+}
+
+// AC-9: an invalid tier value is rejected. The message must not carry
+// "default_tier" or the generic "routing config invalid:" prefix that
+// validateTier's own Error() produces — those would misattribute the
+// field to the workspace default-tier validator instead of role_tiers.
+func TestValidateWorkspaceConfig_RoleTiers_InvalidTierValue(t *testing.T) {
+ cfg := roleTiersCfg()
+ cfg.RoleTiers = routing.RoleTierMap{"qa": routing.Tier("nonsense")}
+
+ err := routing.ValidateWorkspaceConfig(cfg, testKnown)
+ var ve *routing.ValidationError
+ if !errors.As(err, &ve) {
+ t.Fatalf("expected *ValidationError, got %v", err)
+ }
+ if ve.Field != "role_tiers" {
+ t.Fatalf("Field = %q, want role_tiers", ve.Field)
+ }
+ if len(ve.Details) != 1 || ve.Details[0].Field != "qa" {
+ t.Fatalf("Details = %+v, want one entry for qa", ve.Details)
+ }
+ if strings.Contains(ve.Details[0].Message, "default_tier") {
+ t.Fatalf("detail message leaked default_tier field: %q", ve.Details[0].Message)
+ }
+ full := ve.Error()
+ if strings.Contains(full, "routing config invalid: default_tier") {
+ t.Fatalf("rendered error misattributes to default_tier: %q", full)
+ }
+}
+
+// AC-10/AC-10a: a role tier that is valid but not mapped on any provider
+// in the workspace order is rejected, accumulating per-role details the
+// same way checkTierPerReasonMapped does (not the single-value shape
+// checkTierMapped uses for the agent-override tier).
+func TestValidateWorkspaceConfig_RoleTiers_UnmappedTier(t *testing.T) {
+ cfg := roleTiersCfg()
+ // claude-acp has no frontier mapping in this variant.
+ prof := cfg.ProviderProfiles["claude-acp"]
+ prof.ExecutionProfileIDs.Frontier = ""
+ prof.TierMap.Frontier = ""
+ cfg.ProviderProfiles["claude-acp"] = prof
+ cfg.RoleTiers = routing.RoleTierMap{"ceo": routing.TierFrontier}
+
+ err := routing.ValidateWorkspaceConfig(cfg, testKnown)
+ var ve *routing.ValidationError
+ if !errors.As(err, &ve) {
+ t.Fatalf("expected *ValidationError, got %v", err)
+ }
+ if ve.Field != "role_tiers" {
+ t.Fatalf("Field = %q, want role_tiers", ve.Field)
+ }
+ if len(ve.Details) != 1 || ve.Details[0].Field != "ceo" {
+ t.Fatalf("Details = %+v, want one entry for ceo", ve.Details)
+ }
+}
+
+// AC-11: an empty tier value means "absent" and is silently tolerated,
+// both by the unknown-role/invalid-value check and the unmapped-tier
+// check.
+func TestValidateWorkspaceConfig_RoleTiers_EmptyValueIsAbsent(t *testing.T) {
+ cfg := roleTiersCfg()
+ cfg.RoleTiers = routing.RoleTierMap{"worker": ""}
+
+ if err := routing.ValidateWorkspaceConfig(cfg, testKnown); err != nil {
+ t.Fatalf("empty role tier value should validate cleanly, got %v", err)
+ }
+}
+
+// AC-12: role_tiers is validated identically regardless of cfg.Enabled —
+// checkRoleTiersMapped is not gated behind the Enabled=true block, unlike
+// the provider-order/default-tier checks in validateEnabledRules.
+func TestValidateWorkspaceConfig_RoleTiers_ValidatedWhenDisabled(t *testing.T) {
+ cfg := roleTiersCfg()
+ cfg.Enabled = false
+ cfg.RoleTiers = routing.RoleTierMap{"astronaut": routing.TierEconomy}
+
+ err := routing.ValidateWorkspaceConfig(cfg, testKnown)
+ var ve *routing.ValidationError
+ if !errors.As(err, &ve) {
+ t.Fatalf("expected role_tiers to be validated while disabled, got %v", err)
+ }
+ if ve.Field != "role_tiers" {
+ t.Fatalf("Field = %q, want role_tiers", ve.Field)
+ }
+}
+
+// AC-40 test #3: two bad role_tiers entries in a single write must
+// produce two Details entries, not collapse into one flat error.
+func TestValidateWorkspaceConfig_RoleTiers_TwoBadEntriesAccumulate(t *testing.T) {
+ cfg := roleTiersCfg()
+ cfg.RoleTiers = routing.RoleTierMap{
+ "astronaut": routing.TierEconomy, // unknown role
+ "qa": routing.Tier("nonsense"), // invalid tier value
+ }
+
+ err := routing.ValidateWorkspaceConfig(cfg, testKnown)
+ var ve *routing.ValidationError
+ if !errors.As(err, &ve) {
+ t.Fatalf("expected *ValidationError, got %v", err)
+ }
+ if len(ve.Details) != 2 {
+ t.Fatalf("Details = %+v, want 2 entries", ve.Details)
+ }
+ fields := map[string]bool{}
+ for _, d := range ve.Details {
+ fields[d.Field] = true
+ }
+ if !fields["astronaut"] || !fields["qa"] {
+ t.Fatalf("Details fields = %v, want astronaut and qa", fields)
+ }
+}
+
+// A fully valid role_tiers map (every AllAgentRoles key, every value
+// mapped) must pass cleanly.
+func TestValidateWorkspaceConfig_RoleTiers_AllRolesValid(t *testing.T) {
+ cfg := roleTiersCfg()
+ cfg.RoleTiers = routing.RoleTierMap{
+ "ceo": routing.TierFrontier,
+ "worker": routing.TierEconomy,
+ "specialist": routing.TierBalanced,
+ "assistant": routing.TierEconomy,
+ "security": routing.TierBalanced,
+ "qa": routing.TierEconomy,
+ "devops": routing.TierBalanced,
+ }
+
+ if err := routing.ValidateWorkspaceConfig(cfg, testKnown); err != nil {
+ t.Fatalf("all-known-roles config should validate cleanly, got %v", err)
+ }
+}
+
+// A nil/missing role_tiers map means "no role policy" and must never be
+// rejected.
+func TestValidateWorkspaceConfig_RoleTiers_NilMapIsValid(t *testing.T) {
+ cfg := roleTiersCfg()
+ cfg.RoleTiers = nil
+
+ if err := routing.ValidateWorkspaceConfig(cfg, testKnown); err != nil {
+ t.Fatalf("nil role_tiers should validate cleanly, got %v", err)
+ }
+}
diff --git a/apps/backend/internal/office/routing/tier_source_test.go b/apps/backend/internal/office/routing/tier_source_test.go
new file mode 100644
index 0000000000..bdaddaeb47
--- /dev/null
+++ b/apps/backend/internal/office/routing/tier_source_test.go
@@ -0,0 +1,118 @@
+package routing
+
+import (
+ "encoding/json"
+ "testing"
+
+ settingsmodels "github.com/kandev/kandev/internal/agent/settings/models"
+)
+
+// agentSettingsWithOverrides marshals ov under the "routing" settings key,
+// mirroring the production agent_settings.go shape.
+func agentSettingsWithOverrides(t *testing.T, ov AgentOverrides) string {
+ t.Helper()
+ if ov.IsZero() {
+ return ""
+ }
+ blob, err := json.Marshal(map[string]interface{}{"routing": ov})
+ if err != nil {
+ t.Fatalf("marshal overrides: %v", err)
+ }
+ return string(blob)
+}
+
+// AC-16/AC-20d: tierSourceForAgent delegates to the same effectiveTier
+// producer the resolver uses, so a role_tiers entry reports "role" here
+// exactly as it would on a live Resolve call.
+func TestTierSourceForAgent_RoleTier(t *testing.T) {
+ cfg := &WorkspaceConfig{
+ DefaultTier: TierBalanced,
+ RoleTiers: RoleTierMap{"qa": TierEconomy},
+ }
+ agent := settingsmodels.AgentProfile{Role: settingsmodels.AgentRoleQA}
+
+ if got := tierSourceForAgent(agent, cfg); got != TierSourceRole {
+ t.Errorf("tierSourceForAgent = %q, want %q", got, TierSourceRole)
+ }
+}
+
+// A per-agent override outranks a matching role tier.
+func TestTierSourceForAgent_OverrideBeatsRoleTier(t *testing.T) {
+ cfg := &WorkspaceConfig{
+ DefaultTier: TierBalanced,
+ RoleTiers: RoleTierMap{"qa": TierEconomy},
+ }
+ ov := AgentOverrides{TierSource: TierSourceOverride, Tier: TierFrontier}
+ agent := settingsmodels.AgentProfile{
+ Role: settingsmodels.AgentRoleQA,
+ Settings: agentSettingsWithOverrides(t, ov),
+ }
+
+ if got := tierSourceForAgent(agent, cfg); got != TierSourceOverride {
+ t.Errorf("tierSourceForAgent = %q, want %q", got, TierSourceOverride)
+ }
+}
+
+// No override, no matching role entry: falls through to the workspace
+// default.
+func TestTierSourceForAgent_WorkspaceDefault(t *testing.T) {
+ cfg := &WorkspaceConfig{DefaultTier: TierBalanced}
+ agent := settingsmodels.AgentProfile{Role: settingsmodels.AgentRoleQA}
+
+ if got := tierSourceForAgent(agent, cfg); got != TierSourceWorkspace {
+ t.Errorf("tierSourceForAgent = %q, want %q", got, TierSourceWorkspace)
+ }
+}
+
+// AC-18b: a preview computation never carries wake-reason context.
+// tierSourceForAgent always calls effectiveTier with an empty reason, so
+// even when the agent's own wake-reason override would shadow the tier
+// at runtime, the preview must never report "wake_reason".
+func TestTierSourceForAgent_NeverReportsWakeReason(t *testing.T) {
+ cfg := &WorkspaceConfig{
+ DefaultTier: TierBalanced,
+ TierPerReason: TierPerReason{WakeReasonHeartbeat: TierEconomy},
+ }
+ ov := AgentOverrides{
+ TierPerReasonSource: TierPerReasonSourceOverride,
+ TierPerReason: TierPerReason{WakeReasonHeartbeat: TierFrontier},
+ }
+ agent := settingsmodels.AgentProfile{
+ Role: settingsmodels.AgentRoleQA,
+ Settings: agentSettingsWithOverrides(t, ov),
+ }
+
+ got := tierSourceForAgent(agent, cfg)
+ if got == TierSourceWakeReason {
+ t.Fatalf("tierSourceForAgent reported wake_reason for a reason-less preview")
+ }
+ if got != TierSourceWorkspace {
+ t.Errorf("tierSourceForAgent = %q, want %q", got, TierSourceWorkspace)
+ }
+}
+
+// A nil cfg (defensive edge case — Provider always constructs a default
+// before calling this, but the helper is exported to package-internal
+// callers only) must not panic and falls through to the empty-source
+// pinned-defensive case in effectiveTier.
+func TestTierSourceForAgent_NilConfigDoesNotPanic(t *testing.T) {
+ agent := settingsmodels.AgentProfile{Role: settingsmodels.AgentRoleQA}
+ if got := tierSourceForAgent(agent, nil); got != "" {
+ t.Errorf("tierSourceForAgent(nil cfg) = %q, want empty", got)
+ }
+}
+
+// A malformed settings blob falls back to zero-value overrides rather
+// than erroring the preview (SR-23): the tier source still resolves via
+// the normal precedence chain instead of surfacing a fixed sentinel.
+func TestTierSourceForAgent_MalformedSettingsFallsBackToZeroOverrides(t *testing.T) {
+ cfg := &WorkspaceConfig{DefaultTier: TierBalanced}
+ agent := settingsmodels.AgentProfile{
+ Role: settingsmodels.AgentRoleQA,
+ Settings: "{not valid json",
+ }
+
+ if got := tierSourceForAgent(agent, cfg); got != TierSourceWorkspace {
+ t.Errorf("tierSourceForAgent = %q, want %q (fell back to workspace default)", got, TierSourceWorkspace)
+ }
+}
diff --git a/apps/backend/internal/office/routing/types.go b/apps/backend/internal/office/routing/types.go
index 922bdf10df..6503b27c54 100644
--- a/apps/backend/internal/office/routing/types.go
+++ b/apps/backend/internal/office/routing/types.go
@@ -12,6 +12,8 @@ import (
"errors"
"fmt"
"strings"
+
+ settingsmodels "github.com/kandev/kandev/internal/agent/settings/models"
)
// Tier is a normalized model strength bucket. Workspace and agent
@@ -37,6 +39,19 @@ const ProviderOrderSourceOverride = "override"
// that the override blob's Tier replaces the workspace default.
const TierSourceOverride = "override"
+// Tier-resolution level names for the widened, four-value tier_source
+// contract computed by effectiveTier and carried on Resolution and
+// RouteAttempt: wake_reason | override | role | workspace. This is a
+// different contract from AgentOverrides.TierSource above (still
+// inherit | override | "" — a persisted "did the user override"
+// marker, not a resolution level) even though both are spelled
+// "tier_source" and share the "override" string. See spec AC-18a.
+const (
+ TierSourceWakeReason = "wake_reason"
+ TierSourceRole = "role"
+ TierSourceWorkspace = "workspace"
+)
+
// TierPerReasonSourceOverride is the magic value AgentOverrides uses to
// signal that its TierPerReason map replaces the workspace policy
// entirely. Any other value (default "") inherits the workspace map.
@@ -67,6 +82,26 @@ var AllWakeReasons = []string{
// the agent overrides blob.
type TierPerReason map[string]Tier
+// AllAgentRoles enumerates the seven role_tiers keys, in the
+// settingsmodels.AgentRole constants' declaration order. This order is
+// also what the frontend role list follows, so it lives here as the
+// single source of truth rather than being duplicated as a literal list.
+var AllAgentRoles = []settingsmodels.AgentRole{
+ settingsmodels.AgentRoleCEO,
+ settingsmodels.AgentRoleWorker,
+ settingsmodels.AgentRoleSpecialist,
+ settingsmodels.AgentRoleAssistant,
+ settingsmodels.AgentRoleSecurity,
+ settingsmodels.AgentRoleQA,
+ settingsmodels.AgentRoleDevOps,
+}
+
+// RoleTierMap maps an agent role to the tier that role should run at.
+// Keys are restricted to AllAgentRoles; values to AllTiers. Empty /
+// missing keys mean "no role policy — fall through to the workspace
+// default_tier." Stored as JSON on the workspace routing row.
+type RoleTierMap map[string]Tier
+
// ProviderID identifies a CLI provider (the agent type ID from the
// agent/registry package). First-class IDs in v1 are restricted by
// catalogue.KnownProviders.
@@ -224,6 +259,11 @@ type WorkspaceConfig struct {
// empty / missing map means "no special policy — every reason uses
// the agent's effective tier."
TierPerReason TierPerReason `json:"tier_per_reason,omitempty"`
+ // RoleTiers maps an agent role onto the tier that role should run
+ // at, applied below TierPerReason and the per-agent override but
+ // above DefaultTier. An empty / missing map means "no role policy"
+ // — the default for every workspace.
+ RoleTiers RoleTierMap `json:"role_tiers,omitempty"`
}
// AgentOverrides is the routing override blob stored on
@@ -296,6 +336,12 @@ func ValidateWorkspaceConfig(cfg WorkspaceConfig, known []ProviderID) error {
if err := validateTierPerReason(cfg.TierPerReason, "tier_per_reason"); err != nil {
return err
}
+ if err := validateRoleTiers(cfg.RoleTiers, "role_tiers"); err != nil {
+ return err
+ }
+ if err := checkRoleTiersMapped(cfg.RoleTiers, &cfg); err != nil {
+ return err
+ }
if !cfg.Enabled {
return nil
}
@@ -506,6 +552,109 @@ func wakeReasonSet() map[string]struct{} {
return out
}
+// validateRoleTiers rejects role_tiers keys outside the seven
+// AllAgentRoles values and values outside AllTiers. Unlike
+// validateTierPerReason (fail-fast on the first bad entry),
+// role_tiers accumulates one ValidationDetail per offending key —
+// role_tiers is a map that may carry several bad entries in a single
+// write, and a single flat error would collapse them into one message
+// and hide which of the seven roles is broken. Messages are built
+// directly here rather than through validateTier, whose Field is
+// hardcoded to "default_tier" and whose Error() text is prefixed with
+// "routing config invalid:" — either leaking into a role_tiers message
+// would misattribute the offending field.
+func validateRoleTiers(m RoleTierMap, field string) error {
+ if len(m) == 0 {
+ return nil
+ }
+ knownRoles := agentRoleSet()
+ var details []ValidationDetail
+ for role, tier := range m {
+ if _, ok := knownRoles[role]; !ok {
+ details = append(details, ValidationDetail{
+ Field: role,
+ Message: fmt.Sprintf("unknown role %q", role),
+ })
+ continue
+ }
+ if tier == "" {
+ // AC-11: empty value means "absent"; dropped before
+ // persistence, not rejected.
+ continue
+ }
+ if !isValidTierValue(tier) {
+ details = append(details, ValidationDetail{
+ Field: role,
+ Message: fmt.Sprintf("invalid tier %q", string(tier)),
+ })
+ }
+ }
+ if len(details) == 0 {
+ return nil
+ }
+ return &ValidationError{
+ Field: field,
+ Message: "role_tiers references unknown roles or invalid tiers",
+ Details: details,
+ }
+}
+
+// isValidTierValue reports whether t is one of AllTiers. Unlike
+// validateTier, it carries no field/message opinion — callers that need
+// a role_tiers-scoped error build it themselves (see validateRoleTiers).
+func isValidTierValue(t Tier) bool {
+ for _, v := range AllTiers {
+ if v == t {
+ return true
+ }
+ }
+ return false
+}
+
+// agentRoleSet returns the lookup set used by validateRoleTiers. Built
+// from AllAgentRoles so the source of truth stays single.
+func agentRoleSet() map[string]struct{} {
+ out := make(map[string]struct{}, len(AllAgentRoles))
+ for _, r := range AllAgentRoles {
+ out[string(r)] = struct{}{}
+ }
+ return out
+}
+
+// checkRoleTiersMapped returns nil when every non-empty tier in
+// role_tiers is mapped on at least one provider in cfg.ProviderOrder.
+// Mirrors checkTierPerReasonMapped's accumulation shape rather than
+// checkTierMapped's single-value shape, for the same reason
+// validateRoleTiers does: role_tiers may hold several bad entries at
+// once. Checked unconditionally (not gated on cfg.Enabled) because a
+// role tier can supply the effective tier for a launch even while
+// automatic fallback is disabled (AC-12/AC-12a).
+func checkRoleTiersMapped(m RoleTierMap, cfg *WorkspaceConfig) error {
+ var details []ValidationDetail
+ for role, tier := range m {
+ if tier == "" {
+ continue
+ }
+ if tierMappedOnAnyProvider(tier, cfg.ProviderOrder, cfg.ProviderProfiles) {
+ continue
+ }
+ details = append(details, ValidationDetail{
+ Field: role,
+ Message: fmt.Sprintf(
+ "tier %q is not mapped on any provider in the workspace provider order",
+ string(tier)),
+ })
+ }
+ if len(details) == 0 {
+ return nil
+ }
+ return &ValidationError{
+ Field: "role_tiers",
+ Message: "role tier overrides reference unmapped tiers",
+ Details: details,
+ }
+}
+
// validateTier returns an error when t is not in AllTiers. Empty tier
// is rejected because every config that reaches the validator should
// already have a default applied.
diff --git a/apps/backend/internal/office/scheduler/dispatch_routing.go b/apps/backend/internal/office/scheduler/dispatch_routing.go
index 6d2dafb174..902d3af09e 100644
--- a/apps/backend/internal/office/scheduler/dispatch_routing.go
+++ b/apps/backend/internal/office/scheduler/dispatch_routing.go
@@ -202,6 +202,7 @@ func (ss *SchedulerService) parkRunBlocked(
Seq: seq,
ProviderID: string(sk.ProviderID),
Tier: string(res.RequestedTier),
+ TierSource: res.TierSource,
Outcome: skipOutcome(sk.Reason),
ErrorCode: sk.ErrorCode,
RawExcerpt: sk.RawExcerpt,
@@ -252,7 +253,7 @@ func (ss *SchedulerService) tryCandidates(
candidateLaunch := continuationLaunchContext(
launch, prior, run.RouteCycleBaselineSeq, candidate,
)
- seq, err := ss.recordAttemptStart(ctx, run, candidate, res.RequestedTier)
+ seq, err := ss.recordAttemptStart(ctx, run, candidate, res.RequestedTier, res.TierSource)
if err != nil {
return false, nil, err
}
@@ -352,10 +353,14 @@ func latestFailedExecutionProfile(
}
// recordAttemptStart increments the attempt sequence and appends the
-// in-flight attempt row with outcome=launched.
+// in-flight attempt row with outcome=launched. tierSource is the
+// resolution's precedence level (routing.Resolution.TierSource) —
+// threaded in from the caller, which has the *routing.Resolution in
+// scope, rather than re-derived here (AC-20d names effectiveTier as
+// the sole producer).
func (ss *SchedulerService) recordAttemptStart(
ctx context.Context, run *models.Run,
- candidate routing.Candidate, tier routing.Tier,
+ candidate routing.Candidate, tier routing.Tier, tierSource string,
) (int, error) {
seq, err := ss.repo.IncrementRouteAttemptSeq(ctx, run.ID)
if err != nil {
@@ -369,6 +374,7 @@ func (ss *SchedulerService) recordAttemptStart(
ProviderID: string(candidate.ProviderID),
Model: candidate.Model,
Tier: string(tier),
+ TierSource: tierSource,
Outcome: RouteAttemptOutcomeLaunched,
StartedAt: time.Now().UTC(),
}
diff --git a/apps/backend/internal/office/scheduler/tier_source_dispatch_test.go b/apps/backend/internal/office/scheduler/tier_source_dispatch_test.go
new file mode 100644
index 0000000000..0d6789401f
--- /dev/null
+++ b/apps/backend/internal/office/scheduler/tier_source_dispatch_test.go
@@ -0,0 +1,295 @@
+package scheduler_test
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ settingsmodels "github.com/kandev/kandev/internal/agent/settings/models"
+ "github.com/kandev/kandev/internal/common/logger"
+ "github.com/kandev/kandev/internal/events"
+ "github.com/kandev/kandev/internal/events/bus"
+ officemodels "github.com/kandev/kandev/internal/office/models"
+ "github.com/kandev/kandev/internal/office/routing"
+ "github.com/kandev/kandev/internal/office/scheduler"
+)
+
+// makeAgentWithRole mirrors makeAgent but sets the organisational Role so
+// role_tiers precedence tests can exercise the resolver's role level.
+func makeAgentWithRole(role settingsmodels.AgentRole) *officemodels.AgentInstance {
+ agent := makeAgent()
+ agent.Role = role
+ return agent
+}
+
+// AC-20d/AC-20e: a launch that resolves at the workspace-default level
+// persists tier_source="workspace" on the recorded attempt via
+// recordAttemptStart.
+func TestDispatch_TierSource_WorkspaceDefault(t *testing.T) {
+ repo := newTestRepoSched(t)
+ seedRoutingConfig(t, repo, []routing.ProviderID{"claude-acp", "codex-acp"})
+ ss := buildScheduler(t, repo, newFakeTaskStarter())
+ run := seedRun(t, repo, `{"task_id":"t-1"}`)
+
+ launched, _, err := ss.DispatchWithRouting(context.Background(), run, makeAgent(), scheduler.LaunchContext{})
+ if err != nil {
+ t.Fatalf("dispatch: %v", err)
+ }
+ if !launched {
+ t.Fatalf("expected launched=true")
+ }
+ attempts, err := repo.ListRouteAttempts(context.Background(), run.ID)
+ if err != nil || len(attempts) != 1 {
+ t.Fatalf("attempts = %v err=%v, want one attempt", attempts, err)
+ }
+ if attempts[0].TierSource != "workspace" {
+ t.Errorf("TierSource = %q, want workspace", attempts[0].TierSource)
+ }
+}
+
+// The new role level: an agent whose role has a role_tiers entry
+// launches at that tier, and the attempt records tier_source="role".
+func TestDispatch_TierSource_RoleTier(t *testing.T) {
+ repo := newTestRepoSched(t)
+ profiles := map[routing.ProviderID]routing.ProviderProfile{
+ "claude-acp": {
+ TierMap: routing.TierMap{Balanced: "sonnet", Economy: "haiku"},
+ ExecutionProfileIDs: routing.ExecutionProfileIDs{
+ Balanced: "claude-sonnet-profile",
+ Economy: "claude-haiku-profile",
+ },
+ Mode: "default",
+ },
+ }
+ cfg := &routing.WorkspaceConfig{
+ Enabled: true,
+ DefaultTier: routing.TierBalanced,
+ ProviderOrder: []routing.ProviderID{"claude-acp"},
+ ProviderProfiles: profiles,
+ RoleTiers: routing.RoleTierMap{"qa": routing.TierEconomy},
+ }
+ if err := repo.UpsertWorkspaceRouting(context.Background(), testWorkspaceID, cfg); err != nil {
+ t.Fatalf("upsert routing: %v", err)
+ }
+ starter := newFakeTaskStarter()
+ ss := buildScheduler(t, repo, starter)
+ run := seedRun(t, repo, `{"task_id":"t-1"}`)
+
+ launched, _, err := ss.DispatchWithRouting(
+ context.Background(), run, makeAgentWithRole(settingsmodels.AgentRoleQA), scheduler.LaunchContext{})
+ if err != nil {
+ t.Fatalf("dispatch: %v", err)
+ }
+ if !launched {
+ t.Fatalf("expected launched=true")
+ }
+ got := starter.lastCall().route
+ if got.Tier != string(routing.TierEconomy) || got.Model != "haiku" {
+ t.Errorf("route = %+v, want economy/haiku", got)
+ }
+ attempts, err := repo.ListRouteAttempts(context.Background(), run.ID)
+ if err != nil || len(attempts) != 1 {
+ t.Fatalf("attempts = %v err=%v, want one attempt", attempts, err)
+ }
+ if attempts[0].TierSource != "role" {
+ t.Errorf("TierSource = %q, want role", attempts[0].TierSource)
+ }
+}
+
+// The top level: a matching wake-reason policy resolves the tier and the
+// attempt records tier_source="wake_reason", outranking a role entry.
+func TestDispatch_TierSource_WakeReasonBeatsRole(t *testing.T) {
+ repo := newTestRepoSched(t)
+ profiles := map[routing.ProviderID]routing.ProviderProfile{
+ "claude-acp": {
+ TierMap: routing.TierMap{Frontier: "opus", Balanced: "sonnet", Economy: "haiku"},
+ ExecutionProfileIDs: routing.ExecutionProfileIDs{
+ Frontier: "claude-opus-profile",
+ Balanced: "claude-sonnet-profile",
+ Economy: "claude-haiku-profile",
+ },
+ Mode: "default",
+ },
+ }
+ cfg := &routing.WorkspaceConfig{
+ Enabled: true,
+ DefaultTier: routing.TierBalanced,
+ ProviderOrder: []routing.ProviderID{"claude-acp"},
+ ProviderProfiles: profiles,
+ RoleTiers: routing.RoleTierMap{"qa": routing.TierFrontier},
+ TierPerReason: routing.TierPerReason{
+ routing.WakeReasonHeartbeat: routing.TierEconomy,
+ },
+ }
+ if err := repo.UpsertWorkspaceRouting(context.Background(), testWorkspaceID, cfg); err != nil {
+ t.Fatalf("upsert routing: %v", err)
+ }
+ starter := newFakeTaskStarter()
+ ss := buildScheduler(t, repo, starter)
+ run := seedRun(t, repo, `{"task_id":"t-1"}`)
+ run.Reason = routing.WakeReasonHeartbeat
+
+ launched, _, err := ss.DispatchWithRouting(
+ context.Background(), run, makeAgentWithRole(settingsmodels.AgentRoleQA), scheduler.LaunchContext{})
+ if err != nil {
+ t.Fatalf("dispatch: %v", err)
+ }
+ if !launched {
+ t.Fatalf("expected launched=true")
+ }
+ got := starter.lastCall().route
+ if got.Tier != string(routing.TierEconomy) || got.Model != "haiku" {
+ t.Errorf("route = %+v, want economy/haiku (wake reason beats role)", got)
+ }
+ attempts, err := repo.ListRouteAttempts(context.Background(), run.ID)
+ if err != nil || len(attempts) != 1 {
+ t.Fatalf("attempts = %v err=%v, want one attempt", attempts, err)
+ }
+ if attempts[0].TierSource != "wake_reason" {
+ t.Errorf("TierSource = %q, want wake_reason", attempts[0].TierSource)
+ }
+}
+
+// parkRunBlocked's skip-attempt rows also carry tier_source, propagated
+// from the resolution's block-reason path rather than a launch attempt.
+func TestDispatch_TierSource_ParkedBlockedRecordsSource(t *testing.T) {
+ repo := newTestRepoSched(t)
+ seedRoutingConfig(t, repo, []routing.ProviderID{"claude-acp"})
+ starter := newFakeTaskStarter()
+ starter.failFor["claude-acp"] = fmt.Errorf("missing API key: not authenticated")
+ ss := buildScheduler(t, repo, starter)
+ run := seedRun(t, repo, `{"task_id":"t-1"}`)
+
+ launched, parked, err := ss.DispatchWithRouting(context.Background(), run, makeAgent(), scheduler.LaunchContext{})
+ if err != nil {
+ t.Fatalf("dispatch: %v", err)
+ }
+ if launched || !parked {
+ t.Fatalf("expected parked; got launched=%v parked=%v", launched, parked)
+ }
+ attempts, err := repo.ListRouteAttempts(context.Background(), run.ID)
+ if err != nil || len(attempts) != 1 {
+ t.Fatalf("attempts = %v err=%v, want one attempt", attempts, err)
+ }
+ if attempts[0].TierSource != "workspace" {
+ t.Errorf("TierSource = %q, want workspace", attempts[0].TierSource)
+ }
+}
+
+// AC-20f/AC-20g: the terminal skipped_max_attempts row parkRunMaxAttempts
+// records carries no Tier today, and correspondingly no TierSource — the
+// cap fires before the resolver ever runs for this dispatch call.
+func TestDispatch_TierSource_MaxAttemptsRowLeavesSourceEmpty(t *testing.T) {
+ repo := newTestRepoSched(t)
+ seedRoutingConfig(t, repo, []routing.ProviderID{"claude-acp", "codex-acp"})
+ starter := newFakeTaskStarter()
+ ss := buildScheduler(t, repo, starter)
+ run := seedRun(t, repo, `{"task_id":"t-1"}`)
+
+ for i := 0; i < scheduler.MaxAttemptsPerRun; i++ {
+ seq, err := repo.IncrementRouteAttemptSeq(context.Background(), run.ID)
+ if err != nil {
+ t.Fatalf("seed seq bump: %v", err)
+ }
+ if err := repo.AppendRouteAttempt(context.Background(), &officemodels.RouteAttempt{
+ RunID: run.ID,
+ Seq: seq,
+ ProviderID: "claude-acp",
+ Model: "claude-acp-bal",
+ Tier: "balanced",
+ TierSource: "workspace",
+ Outcome: scheduler.RouteAttemptOutcomeFailedProviderUnavail,
+ StartedAt: time.Now().UTC(),
+ }); err != nil {
+ t.Fatalf("seed prior attempt: %v", err)
+ }
+ }
+
+ launched, parked, err := ss.DispatchWithRouting(context.Background(),
+ run, makeAgent(), scheduler.LaunchContext{})
+ if err != nil {
+ t.Fatalf("dispatch: %v", err)
+ }
+ if launched || !parked {
+ t.Fatalf("expected parked at cap; got launched=%v parked=%v", launched, parked)
+ }
+ attempts, err := repo.ListRouteAttempts(context.Background(), run.ID)
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ last := attempts[len(attempts)-1]
+ if last.Outcome != scheduler.RouteAttemptOutcomeMaxAttempts {
+ t.Fatalf("last outcome = %q, want skipped_max_attempts", last.Outcome)
+ }
+ if last.TierSource != "" {
+ t.Errorf("TierSource = %q, want empty on the max-attempts terminal row", last.TierSource)
+ }
+}
+
+// The route_attempt_appended WS event carries the same TierSource the
+// persisted attempt row does — the payload is the raw models.RouteAttempt,
+// not a re-derived value, so this pins the wiring rather than the value.
+func TestDispatch_TierSource_PublishedOnRouteAttemptAppended(t *testing.T) {
+ repo := newTestRepoSched(t)
+ profiles := map[routing.ProviderID]routing.ProviderProfile{
+ "claude-acp": {
+ TierMap: routing.TierMap{Balanced: "sonnet", Economy: "haiku"},
+ ExecutionProfileIDs: routing.ExecutionProfileIDs{
+ Balanced: "claude-sonnet-profile",
+ Economy: "claude-haiku-profile",
+ },
+ Mode: "default",
+ },
+ }
+ cfg := &routing.WorkspaceConfig{
+ Enabled: true,
+ DefaultTier: routing.TierBalanced,
+ ProviderOrder: []routing.ProviderID{"claude-acp"},
+ ProviderProfiles: profiles,
+ RoleTiers: routing.RoleTierMap{"qa": routing.TierEconomy},
+ }
+ if err := repo.UpsertWorkspaceRouting(context.Background(), testWorkspaceID, cfg); err != nil {
+ t.Fatalf("upsert routing: %v", err)
+ }
+ ss := buildScheduler(t, repo, newFakeTaskStarter())
+
+ log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
+ if err != nil {
+ t.Fatalf("logger: %v", err)
+ }
+ eb := bus.NewMemoryEventBus(log)
+ ss.SetEventBus(eb)
+
+ var captured *officemodels.RouteAttempt
+ if _, err := eb.Subscribe(events.OfficeRouteAttemptAppended, func(_ context.Context, ev *bus.Event) error {
+ data, ok := ev.Data.(map[string]interface{})
+ if !ok {
+ t.Fatalf("event data type = %T, want map[string]interface{}", ev.Data)
+ }
+ attempt, ok := data["attempt"].(officemodels.RouteAttempt)
+ if !ok {
+ t.Fatalf("attempt field type = %T, want officemodels.RouteAttempt", data["attempt"])
+ }
+ captured = &attempt
+ return nil
+ }); err != nil {
+ t.Fatalf("subscribe: %v", err)
+ }
+
+ run := seedRun(t, repo, `{"task_id":"t-1"}`)
+ launched, _, err := ss.DispatchWithRouting(
+ context.Background(), run, makeAgentWithRole(settingsmodels.AgentRoleQA), scheduler.LaunchContext{})
+ if err != nil {
+ t.Fatalf("dispatch: %v", err)
+ }
+ if !launched {
+ t.Fatalf("expected launched=true")
+ }
+ if captured == nil {
+ t.Fatal("route_attempt_appended was not published")
+ }
+ if captured.TierSource != "role" {
+ t.Errorf("published attempt TierSource = %q, want role", captured.TierSource)
+ }
+}
diff --git a/apps/web/app/office/agents/[id]/components/agent-route-strip.tsx b/apps/web/app/office/agents/[id]/components/agent-route-strip.tsx
index 1dadea2a8b..2df49e15c3 100644
--- a/apps/web/app/office/agents/[id]/components/agent-route-strip.tsx
+++ b/apps/web/app/office/agents/[id]/components/agent-route-strip.tsx
@@ -7,6 +7,7 @@ import { useAppStore } from "@/components/state-provider";
import { useAgentRoute } from "@/hooks/domains/office/use-agent-route";
import { useWorkspaceRouting } from "@/hooks/domains/office/use-workspace-routing";
import { providerLabel } from "../../../workspace/routing/components/provider-order-editor";
+import { TIER_SOURCE_LABEL_KEYS } from "../../../lib/label-keys";
import { useTranslation } from "react-i18next";
// Catalog keys, not copy — module scope freezes a `t()` at the boot locale.
@@ -76,9 +77,12 @@ function ConfiguredRow({ preview }: { preview: AgentRoutePreview }) {
))}
-
+ {t(i18nKey, { list: reasons.join(", ") })} +
+ ); +} + function computeNextOrder( on: boolean, current: string[] | undefined, diff --git a/apps/web/app/office/lib/label-keys.ts b/apps/web/app/office/lib/label-keys.ts index d841a73874..ba93ad6467 100644 --- a/apps/web/app/office/lib/label-keys.ts +++ b/apps/web/app/office/lib/label-keys.ts @@ -3,6 +3,7 @@ import type { OfficeTaskStatus, ProjectStatus, Tier, + TierSource, } from "@/lib/state/slices/office/types"; /** @@ -58,6 +59,18 @@ export const TIER_NAME_KEYS: Record+ {t("office:setADefaultTierForEveryAgentOfA")} +
+
+
+ {t("office:currentlyMapsTo")}{" "}
+