Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
57 KiB
status, created, owner, umbrella, extends
| status | created | owner | umbrella | extends |
|---|---|---|---|---|
| draft | 2026-08-20 | nova28 | office | docs/specs/office/routing.md |
Office per-agent and per-role tier selection
Office agents in one workspace must be able to run on different model tiers. Today
they can — a per-agent tier override already exists end to end — but the capability is
undiscoverable, the org has no way to express a tier as a property of a role, and the
Office agent record still carries a model field that routing ignores. The result is
an operator who configures a Critic on opus[1m], watches it run on sonnet, and has
nothing in the product that explains the difference.
This spec extends docs/specs/office/routing.md. That spec remains authoritative for
tiers, provider order, execution profiles, provider health, and wake-reason policy.
Nothing here changes those contracts except where explicitly named in
Precedence.
Verified current state
Verified 2026-08-20 against ~/.kandev/data/kandev.db (workspace
95542bf3-37e9-4fbc-9dbe-e2c774a5e7f6) and the tree at 0b10edfa2.
The reported symptom reproduces
office_run_route_attempts holds ten attempts; every one resolved
tier=balanced, and the five most recent claude-acp launches used
execution_profile_id=b139adcd-… (profile "Sonnet", model=sonnet).
office_workspace_routing for that workspace:
enabled = 1
default_tier = balanced
provider_order = ["claude-acp","codex-acp"]
tier_per_reason = {"budget_alert":"economy","heartbeat":"economy","routine_trigger":"economy"}
Why it happened — the override exists and was never set
| Agent | role |
agent_profiles.model |
settings |
Ran as |
|---|---|---|---|---|
| CEO | ceo |
opus[1m] |
{"routing":{"provider_order_source":"inherit","tier_source":"inherit"}} |
sonnet |
| Critic | specialist |
opus[1m] |
{} |
sonnet |
| Analyst | specialist |
sonnet |
{} |
sonnet |
| Tech Lead | specialist |
sonnet |
{} |
sonnet |
| Product Manager | assistant |
sonnet |
{} |
sonnet |
No agent carried tier_source: "override". Every run therefore fell through to the
workspace default, which is the documented and correct behaviour.
A per-agent tier override is already implemented on every layer:
- Type + validation —
routing.AgentOverrides{TierSource, Tier}andValidateAgentOverridesAgainstWorkspace(internal/office/routing/types.go). - Resolution —
effectiveTier()(internal/office/routing/resolver.go) already honoursov.TierSource == "override"above the workspace default. - Persistence —
routing.WriteAgentOverridesontoagent_profiles.settings. - API —
PATCH /office/agents/:idwith aroutingbody key (internal/office/agents/handler.go→applyRoutingOverride). - UI — "Override workspace tier" toggle plus a tier toggle group in
apps/web/app/office/agents/[id]/components/agent-routing-card.tsx.
The card's premise that no per-agent override exists is incorrect. The defect is
discoverability, the absent role lever, and the misleading model field.
A role → tier map cannot solve the reported problem
The card proposes a role -> tier map and calls it the better option. The verified
org makes that insufficient on its own:
role |
agents |
|---|---|
ceo |
CEO |
assistant |
Product Manager |
specialist |
Analyst, Tech Lead, Critic |
Analyst and Critic share role = specialist. The card's motivating case is exactly
"the Critic must not run the same model as the Analyst it checks", and a role → tier
map cannot express that. Roles are a fixed seven-value enum
(ceo, worker, specialist, assistant, security, qa, devops;
internal/agent/settings/models/agent_attributes.go:16-24) and are not user-extensible.
Per-role is therefore specified here as a bulk default, never as the mechanism that satisfies the Critic case. Per-agent remains the precise lever.
The model field really is ignored
models.AgentInstance is a type alias for settingsmodels.AgentProfile
(internal/office/models/models.go:57) — Office identities and execution profiles are
rows in the same agent_profiles table, separated by role != '' and
workspace_id != ''.
At launch the resolver takes the tier's execution-profile ID and reads that row's
Model. It explicitly refuses an Office identity in that position:
resolveExecutionProfile returns profile %q is an Office agent identity, not an execution profile when profile.Role != "" (resolver.go). So an Office agent's own
model column can never reach a launch.
Scope of the misleading surface, verified:
- Not shown in Settings → Agents.
filterGlobalProfiles(internal/agent/settings/controller/agent_crud.go:99) drops rows withWorkspaceID != "", and all five Office identities are workspace-scoped. - Not shown on the Office agent page.
agent-route-strip.tsxandagent-card.tsxrenderpreview.current_model/preview.primary_model— the resolved model. - Is exposed by the API.
AgentResponsereturns*models.AgentInstanceverbatim, andAgentProfile.Modelcarriesjson:"model", soGET /office/agents/:idadvertises"model": "opus[1m]"for the Critic.PATCHalready rejectsagent_profile_idwith "agent_profile_id no longer selects an Office runtime", but says nothing aboutmodel.
The accurate defect statement is therefore: the Office agent API advertises a model
field that Office routing ignores, not "the UI displays it".
Precedence contract
Deviation from the card's stated acceptance — read this first
The card's acceptance asks for per-agent > per-role > tier_per_reason > workspace.
This spec deliberately specifies tier_per_reason > per-agent > per-role > workspace
instead. Reasons, in order of weight:
- The card's own goal does not need the reversal. The Critic and Analyst both ran
with reason
task_assigned, for which notier_per_reasonkey exists. The frozen spec already guarantees that case falls through to the agent's effective tier (docs/specs/office/routing.md, final wake-reason AC). Per-agent already wins where the card needs it to win. - The reversal breaks a shipped, documented guarantee.
docs/specs/office/routing.mdstates: "the resolver picks the Economy tier model regardless of the agent's default tier" fortier_per_reason.heartbeat = economy. Reversing precedence silently voids that line. - It would regress cost control.
tier_per_reasonexists to cheap-out predictable background work. Under the card's order, one agent pinned tofrontierrunsopus[1m]on every heartbeat forever — the exact blowup the feature prevents. The verified workspace maps all three wake reasons toeconomy. - The card's intent already has a supported expression. An agent that must stay on
Frontier even for heartbeats sets its own
tier_per_reasonoverride (TierPerReasonSource = "override"), which is shipped, validated, and documented.
This is a contract decision made under the board's "prefer a defensible decision" rule rather than parking the card. Spec Review: if this reasoning is rejected, the correct disposition is NEEDS RETHINK back to Spec, not a Build-time edit.
The contract
For one run, the effective tier is the first of these that yields a non-empty tier:
- Wake-reason policy — agent
tier_per_reasonoverride whentier_per_reason_source == "override", otherwise workspacetier_per_reason, keyed by the run's reason. Skipped entirely when the run reason is empty or absent from the map. (unchanged) - Per-agent tier —
routing.tierwhentier_source == "override"andtieris non-empty. (unchanged) - Per-role tier — the workspace
role_tiersentry for the agent'srole. (new) - Workspace
default_tier. (unchanged)
Only step 3 is added. Steps 1, 2 and 4 keep their current order and semantics.
Data model
One new column on the existing office_workspace_routing row. No new table, and no
column on agent_profiles — keeping routing policy in one place, per the card's own
recommendation and consistent with how tier_per_reason is stored.
office_workspace_routing
role_tiers TEXT NOT NULL DEFAULT '{}' -- JSON map: role -> tier
- Keys are restricted to the seven
AgentRolevalues. Any other key is rejected. - Values are restricted to
frontier | balanced | economy. - An entry whose value is the empty string is treated as absent and is dropped before persistence.
{}means "no role policy" and is the default for every existing and new workspace.- The Go field is
WorkspaceConfig.RoleTiers(internal/office/routing/types.go, besideTierPerReason), taggedjson:"role_tiers,omitempty". The workspace routing PUT binds directly intoWorkspaceConfig— there is no separate request DTO — so the wire contract and the in-memory contract are the same type.
Migration is additive with a default, so existing rows need no backfill and behaviour is unchanged until an operator writes a map.
Acceptance criteria
Written EARS-style. Each is observable through the API, the database, or the UI.
Per-role resolution
- AC-1 — GIVEN workspace
role_tiers = {"specialist":"frontier"}and an agent withrole = specialistand no tier override, WHEN a run launches with a reason carrying no wake-reason policy, THEN the resolved tier isfrontierandoffice_run_route_attempts.tierrecordsfrontier. - AC-2 — GIVEN the same config and an agent with
role = assistantabsent fromrole_tiers, WHEN a run launches, THEN the resolved tier is the workspacedefault_tier. - AC-3 — GIVEN
role_tiers = {"specialist":"economy"}and aspecialistagent whose settings carrytier_source = "override",tier = "frontier", WHEN a run launches with no wake-reason policy, THEN the resolved tier isfrontier— the per-agent override outranks the role entry. - AC-4 — GIVEN
role_tiers = {"specialist":"frontier"}, workspacetier_per_reason = {"heartbeat":"economy"}, and aspecialistagent, WHEN a heartbeat run launches, THEN the resolved tier iseconomy— the wake-reason policy outranks the role entry. - AC-5 — GIVEN
role_tiers = {"specialist":"frontier"}and aspecialistagent carryingtier_source = "override",tier = "balanced", and workspacetier_per_reason = {"heartbeat":"economy"}, WHEN a heartbeat run launches, THEN the resolved tier iseconomy, demonstrating the full four-level order in one case. - AC-6 — GIVEN an agent whose
roleis the empty string, WHEN a run launches, THENrole_tiersis not consulted and resolution proceeds todefault_tier. - AC-7 — GIVEN
role_tiers = {}, WHEN any run launches, THEN the resolved tier is identical to the tier resolved before this feature existed, for every agent in the workspace.
Validation
-
AC-8 — WHEN a workspace routing write carries a
role_tierskey outside the sevenAgentRolevalues, THEN the write is rejected with HTTP 400 and aValidationErrorwhoseFieldisrole_tiersand whoseDetailscarry oneValidationDetailper offending key. -
AC-8a — The rejection's wire shape is the structured 400 that
respondRoutingValidation(internal/office/agents/handler.go) and the dashboard routing handler already emit, and no other:{"error": "<ValidationError.Message>", "field": "<ValidationError.Field>", "details": [...]}ValidationDetailis{ProviderID, Field, Message}(internal/office/routing/types.go) — there is no role member. The offending role therefore goes inValidationDetail.Fieldand the reason inValidationDetail.Message, withProviderIDleft empty forrole_tiersentries. This is pinned because "Detailsname the offending key" does not by itself say which member carries it, and a builder cannot read that off the struct. -
AC-9 — WHEN a write carries a
role_tiersvalue outsidefrontier | balanced | economy, THEN the write is rejected with HTTP 400 in the AC-8a shape, and the offending role and value both appear in the response — the role inValidationDetail.Field, the value quoted inValidationDetail.Message. The message must contain neither the stringdefault_tiernor the prefixrouting config invalid:; AC-10a explains why both are live risks rather than stylistic notes. -
AC-10 — WHEN a write carries a
role_tiersentry whose tier is mapped by no provider in the workspaceprovider_order, THEN the write is rejected with HTTP 400 rather than failing at launch. The structural precedent ischeckTierPerReasonMapped, notcheckTierMapped:role_tiersis a map and may hold several bad entries at once, so it needs the[]ValidationDetailaccumulationcheckTierPerReasonMappedperforms.checkTierMappedvalidates a single value (ov.Tier) and returnsField: "routing.tier"with noDetailsat all — copying it collapses N bad entries into one message and reports the wrong field. Both are called fromValidateAgentOverridesAgainstWorkspace, so naming that function alone does not pick the right one; this AC names the callee deliberately. -
AC-10a —
role_tiersvalidation requires a new field-parameterised validator, because the obvious existing one cannot satisfy AC-8.validateTier(t Tier)(internal/office/routing/types.go) hardcodesField: "default_tier"in theValidationErrorit returns and carries neither the role nor anyDetails. It is already reused for the per-agent override (validateTier(ov.Tier)), so the wrong-field pattern is established in this repo — reusing it forrole_tierslooks correct and is not. Two consequences:- The
role_tiersvalue validator takes the field name as a parameter, exactly asvalidateTierPerReason(m TierPerReason, field string)does, and emitsField: "role_tiers". validateTier's error must not be embedded in the message verbatim.ValidationError.Error()renders asrouting config invalid: default_tier: invalid tier "x", andvalidateTierPerReasonwraps exactly that string into its ownMessage— so even the correct structural sibling leaksdefault_tierinto user-facing text today. Arole_tiersmessage that inherits that wording violates AC-9.
- The
-
AC-11 — WHEN a write carries a
role_tiersentry with an empty-string value, THEN the entry is dropped and the persisted map omits that key; the write succeeds. -
AC-12 — GIVEN routing is disabled for the workspace (
enabled = 0), WHEN arole_tierswrite arrives, THEN it is validated and persisted exactly as when enabled;enabledgates automatic fallback, not tier selection. -
AC-12a — GIVEN
enabled = 0and a non-emptyrole_tiers, WHEN a run launches, THEN the role level still participates in tier resolution.Resolvecomputes the effective tier before it branches onEnabled, and the single evaluated provider (provider_order[0]) is looked up at the role-supplied tier.
The ignored model field
-
AC-13 — WHEN
GET /office/agents/:idreturns an Office identity (role != ''), THEN themodelfield is omitted from the response body. It is not emitted as an empty string, and it is not emitted alongside a marker flag. Omission is chosen over a marker so no consumer can read a value that has no effect. For a row withrole == ''(an execution profile) the field is unchanged. -
AC-13a — The omission is implemented as a dedicated response DTO in
internal/office/agentsthat embedsmodels.AgentInstanceand shadows themodelkey with its own field:type agentResponseBody struct { *models.AgentInstance Model *string `json:"model,omitempty"` }Go resolves the JSON name collision by depth: the outer
Model(depth 0) wins over the embeddedAgentProfile.Model(depth 1), so the embedded field never reaches the wire. Embedding a type alias compiles, and the embedded field is referred to by the alias name as written —agentResponseBody{AgentInstance: p}.The projection sets
Modelonly whenrole == ''(pointing it at the row's value) and leaves it nil for an Office identity, soomitemptydrops the key. This is what lets AC-13's two halves hold in one type, without the spec having to assert which rows reach which handler.The shadow field is
*string, notstring, and this is load-bearing. With a plainstring, an execution profile (role == '') whosemodelis the empty string would have itsmodelkey dropped byomitempty— a silent shape change on exactly the rows AC-13 promises are unchanged, because the shared struct tagsModelasjson:"model"with noomitemptyand therefore emits"model":""today. A*stringdistinguishes "absent" (nil) from "present and empty" (non-nil), so the DTO is shape-preserving by construction whether or not an emptymodelis reachable in practice. Verified against the real tag set: with*string,role != ''emits nomodelkey androle == ''emits"model":""for an empty value, matching the unwrapped struct.Embedding is mandated over a field-by-field projection.
settingsmodels.AgentProfilecarries 43 JSON-tagged fields, several computed ordb:"-", with inconsistentomitemptyusage. Hand-mirroring them is 43 chances to drop a field or mistype a tag, and every such slip is a silent response-shape change for execution-profile and kanban consumers. Embedding copies zero tags and so cannot drift when a field is later added to the shared struct — which theRouteAttemptDTO/routeAttemptToDTOpattern ininternal/office/dashboard/routing_dto.godoes not give us here, because that DTO mirrors a 17-field model this feature owns, not a 43-field struct shared with two other subsystems.Two alternatives remain explicitly forbidden: a custom
MarshalJSONonsettingsmodels.AgentProfile(a type alias shared with execution profiles and kanban — it would silently change their JSON too), and amap[string]anypost-process (it drops compile-time field checking). The shared struct and itsjson:"model"tag are not edited.The existing wrappers change element type, not shape.
AgentResponse.Agentbecomes*agentResponseBodyandAgentListResponse.Agentsbecomes[]*agentResponseBody(internal/office/agents/dto.go). Their JSON keys stayagentandagents; no new wrapper type is introduced and no envelope key is renamed. A nil agent stays a nil wrapper field, serialising as"agent": nullexactly as today — do not substitute a zero-valued DTO, which would serialise as{}because a nil embedded pointer contributes no promoted fields.Key ORDER changes and that is accepted. Embedding emits the shadowing
Modelwhere the outer field is declared, so forrole == ''themodelkey moves to the end of the object. The key SET and every value are identical. JSON object member order is not semantically significant, no in-repo consumer depends on it, and this is why AC-13c compares decoded maps rather than bytes. Earlier revisions of this AC said "byte-identical"; that was never achievable by either mechanism and is corrected here. -
AC-13c — The shape-preservation property AC-13a asserts is observable, not merely asserted. A Go test in
internal/office/agentsmarshals the samemodels.AgentInstancevalue twice — once directly, once through the response DTO — decodes both intomap[string]any, and asserts:- for a row with
role != '': the DTO map equals the direct map with themodelkey deleted (so no other key is added, dropped, or altered), and the DTO map contains nomodelkey; - for a row with
role == '': the DTO map equals the direct map exactly, including amodelkey whose value is""when the row's model is empty.
The comparison is over decoded maps, never raw bytes, per AC-13a's key-order note. This AC exists because a mechanism whose whole job is "change one key and nothing else" is worthless without a test that can see "nothing else"; it is the standing guard for AC-13, AC-13a and AC-13b together.
- for a row with
-
AC-13b — The omission applies to every Office-identity agent payload the
internal/office/agentshandler emits, not onlyGET /office/agents/:id. Five handlers emit the agent struct:listAgents(viaAgentListResponse),createAgent(201),getAgent,updateAgent, andupdateAgentStatus. All five return the DTO. A response from any of the five that still carries amodelkey for a row withrole != ''fails this AC. -
AC-14 — WHEN
PATCH /office/agents/:idcarries amodelfield for an Office identity, THEN the request is rejected with HTTP 400 and a message directing the caller to workspace tier profiles or the agent routing override. The rejection is worded like the existingagent_profile_idrejection, but is not implemented the same way — see AC-14a for why the mechanisms necessarily differ, and AC-14c for the response shape, which AC-14a does not cover. -
AC-14c — The rejection's wire shape is the AC-8a structured 400, produced by
respondRoutingValidationwith&routing.ValidationError{Field: "model", Message: "<the AC-14 wording>"}— and not the baregin.H{"error": ...}form the neighbouringagent_profile_idrejection uses. Two rejections in one handler function therefore carry different envelopes, and that is intended rather than an oversight: theagent_profile_idbare form is the older shape,respondRoutingValidationis already called byapplyRoutingOverridea few lines away in the same file, and AC-8/AC-9/AC-10 commit the rest of this feature to the structured form. A caller that receives this 400 can then readfield == "model"instead of pattern-matching prose.AC-14's "worded like" governs the sentence only, never the envelope. The existing
agent_profile_idrejection is not changed to match: rewriting it is a separate API change this feature is not authorised to make. -
AC-14a —
UpdateAgentRequesthas nomodelmember, and Gin'sShouldBindJSONuses stdlibencoding/json, which ignores unknown keys — so today amodelkey in a PATCH body is silently discarded. Detection is therefore implemented by buffering the request body once withio.ReadAll(c.Request.Body), then decoding those same bytes twice: once intomap[string]json.RawMessageto test for presence of the"model"key specifically, and once intoUpdateAgentRequest. The handler must therefore stop callingc.ShouldBindJSON, which consumes the body and would leave the second decode empty; binding from the buffered bytes replaces it, and the existing malformed-JSON 400 behaviour is preserved.On precedent — read this before going looking for one. Three handlers already buffer a Gin request body with
io.ReadAll(c.Request.Body):internal/office/routines/handler.go,internal/office/channels/handler.goandinternal/system/frontenderrors/handler.go. They are precedent for the buffering step only. None of them does key-presence detection, and copying any of them wholesale produces the wrong thing:routines/handler.gobuffers for HMAC verification, then unmarshals tomap[string]interface{}to build webhook variables — no typed struct, no presence test.channels/handler.gobuffers for signature verification and treats the body as raw text; it never JSON-decodes it at all.frontenderrors/handler.godecodes into a typed struct withDisallowUnknownFields(), then decodes a second time expectingio.EOF— a trailing-garbage guard, and it uses the very technique forbidden two paragraphs below. It is the closest-looking and the most misleading of the three.
The
map[string]json.RawMessagepresence check exists nowhere in this backend today. It is new code, and the spec says so rather than sending a builder to find a pattern that is not there.json.Decoder.DisallowUnknownFieldsis explicitly forbidden: it would reject every unknown key on this endpoint, a far broader breaking API change than this feature is authorised to make. Thatfrontenderrors/handler.gouses it is not a licence — that endpoint accepts one closed payload shape; this one does not. The existingagent_profile_idcheck stays a plain nil-check on the declaredAgentProfileID *stringfield; that path is unchanged. -
AC-14b — Presence, not value, triggers the rejection.
{"model": "opus[1m]"},{"model": ""}and{"model": null}are all rejected with the same HTTP 400, because each proves the caller believes the field is honoured. A body that omits themodelkey entirely is accepted. For a row withrole == ''the key is not rejected. -
AC-15 — GIVEN an existing Office identity row with a non-empty
modelvalue, WHEN this feature ships, THEN that stored value is left untouched in the database and continues to have no effect on routing. No destructive migration runs.
Discoverability
-
AC-16 — GIVEN an agent inherits its tier (no per-agent override, no role entry), WHEN the operator views that agent's routing card, THEN the card names the tier in force and the level that supplied it. The four wire values are exactly
wake_reason | override | role | workspace(AC-18); the card renders their translated display labels. Wire values and display labels are distinct — the wire value is never shown raw and is never translated. -
AC-16a — The same rule binds the workspace routing preview table (
app/office/workspace/routing/components/agent-preview-table.tsx), which today renders{a.tier_source}raw in a user-facing cell. WHEN that table renders a row, THEN it shows the translated display label for the row'stier_source, never the raw wire value. Without this AC the widening ships the new literalsroleandworkspaceuntranslated into a shipped table, violating AC-16's own principle and AC-19's five-locale requirement on a surface AC-16 does not reach. -
AC-17 — GIVEN a per-agent tier override is shadowed by a wake-reason policy for some reason, WHEN the operator views the agent's routing card, THEN the card states that the override does not apply to those reasons and names them.
-
AC-17a — The reasons named are the keys of the agent's effective wake-reason map, which is the same map
wakeReasonTierconsults: WHENtier_per_reason_source == "override", the effective map is the agent's owntier_per_reasonand the workspace map is not consulted at all (an override replaces it entirely, perdocs/specs/office/routing.md); OTHERWISE the effective map is the workspacetier_per_reason. The card never shows the union of the two, and never shows the workspace keys when an override is in force. Keys whose value is empty are excluded, since they do not shadow anything. GIVEN the effective map is empty, THEN the card shows no shadowing notice at all. -
AC-17b — The shadowing notice is not limited to a per-agent override. A role-supplied tier is shadowed by wake-reason policy identically (AC-4), and AC-18b guarantees a preview never reports
wake_reason, so without this AC an agent on a role tier would see a card asserting the role tier is in force while its heartbeat runs actually take Economy, with nothing said. WHEN the agent's effective tier comes from the role level or the workspace default, and the effective wake-reason map is non-empty, THEN the card states that the named reasons do not use the displayed tier, using the same notice and the same key set as AC-17a. No new data path is required:agent-routing-card.tsxalready holds the workspace map viauseWorkspaceRoutingand the agent's own map via the persistedoverridesblob. -
AC-18 — WHEN the routing preview (
PreviewItem) is produced for an agent, THENTierSourcereports exactly one ofwake_reason | override | role | workspace, widening the current two-valuedoverride | inherit. -
AC-18a — The value
overridekeeps its current meaning and spelling, so an existing consumer testingtier_source === "override"is unaffected. The valueinheritis removed from the computed preview field only and replaced byroleorworkspace.The widening is scoped by TYPE, not by file. Two unrelated fields are both spelled
tier_source, and exactly one of them changes. Several files contain both, so "update file X" is not a safe instruction:Type Widens? Computed preview — CHANGES Go routing.PreviewItem.TierSource→ Godashboard.AgentRoutePreview.TierSource→ TSAgentRoutePreview.tier_sourceYES — becomes wake_reason | override | role | workspacePersisted override — UNCHANGED Go routing.AgentOverrides.TierSource(json:"tier_source,omitempty") → TSAgentRoutingOverrides.tier_sourceNO — stays inherit | override | ""Sites that MUST change (the computed chain, in dataflow order):
internal/office/routing/provider.go#tierSourceForAgent— today the only site that emits the literal"inherit"; it must instead yieldrole/workspace, which requires the agent's role and the workspacerole_tiersmap. Per AC-20d it does not decide this itself: it delegates to the widenedeffectiveTier, so that preview and audit share one producer. "Sole producer" describes the situation before this feature, not after it.internal/office/routing/provider.gowhere the result is set onPreviewItem.internal/office/dashboard/handler_routing.gowhere it is copied intoAgentRoutePreview(previewItemsToDTOs).internal/office/dashboard/routing_dto.go— the doc comment onAgentRoutePreviewcurrently states the two-valued contract and becomes false.- TS
AgentRoutePreview.tier_sourceinlib/state/slices/office/types.ts. agent-preview-table.tsx, per AC-16a.- Tests fixturing the computed value — both of them, Go and TypeScript:
internal/office/dashboard/handler_routing_test.go, andapps/web/lib/state/slices/office/office-routing.test.ts, whosesetRoutingPreviewcase fixtures a preview row withtier_source: "inherit". That TS fixture is the computed preview, not the persisted override, so it stops compiling the moment item 5 lands. It is easy to mistake for a MUST-NOT-CHANGE site because the same file name pattern appears in that list below; it is not one.
This list is maintained by hand and has been wrong before. It is a starting point, not a proof of completeness — AC-18c is the proof. Work the list, then run the typecheck gate and treat whatever it reports as in scope.
Sites that MUST NOT change (persisted state — changing these is a regression):
routing.AgentOverrides.TierSourceand TSAgentRoutingOverrides.tier_source.agent-routing-card.tsxwhere it writestier_source: on ? "override" : "inherit"and readsoverrides.tier_source !== "override"— that is the persisted override blob being PATCHed, not the preview.e2e/helpers/office-api-client.ts, whosetier_sourcesits insideoverrides.internal/office/onboarding/service.go#writeAgentInheritMarkers, which stampsTierSource: "inherit"as the CEO's onboarding marker.e2e/tests/office/office-routing-disabled.spec.ts, which assertsroute.overrides.tier_source === "inherit".
A stored settings blob containing
tier_source: "inherit"keeps its persisted spelling and continues to mean "not an override". -
AC-18b — GIVEN a preview is produced through
Provider.Preview/Provider.PreviewAgent, WHEN resolution runs, THEN it runs with an empty run reason, so the wake-reason level cannot apply andTierSourcenever reportswake_reasonin a preview. AC-17 (override case) and AC-17b (role / workspace case) are what surface wake-reason shadowing in that view; between them every level a preview can report carries a shadowing notice. -
AC-18c — WHEN the
tier_sourcewidening has been applied, THENpnpm --filter @kandev/web typecheckpasses with no error mentioningtier_source.This AC exists because AC-18a's site list is maintained by hand and has been incomplete in successive reviews — the union of "files that mention
tier_source" is not knowable by reading, and the type checker knows it exactly. NarrowingAgentRoutePreview.tier_sourcefrom"inherit" | "override"to"wake_reason" | "override" | "role" | "workspace"makes every stale"inherit"fixture or comparison a compile error, so the gate is exhaustive by construction where a list can only ever be a best effort.Any site the gate reports is in scope for this feature, whether or not AC-18a names it — with one exception that is a genuine failure, not a site to update: an error on a MUST-NOT-CHANGE site from AC-18a means the persisted override type was widened by mistake. Fix that by reverting the persisted type, never by widening the fixture to match.
This mirrors the shape AC-19 already uses — delegate completeness to a checker that can see everything, rather than to a list that cannot. AC-13c is the same idea for the agent-payload half.
-
AC-19 — WHEN new user-facing copy is added for AC-16 through AC-18, THEN it is routed through
t()/<Trans>and present in all five locales (en,pt-pt,zh-cn,zh-hk,zh-tw), withpnpm run i18n:checkpassing.
Observability
-
AC-20 — WHEN a run resolves its tier, THEN the supplying level is persisted on the route attempt in a new
office_run_route_attempts.tier_sourcecolumn (TEXT NOT NULL DEFAULT ''), holding one ofwake_reason | override | role | workspace. -
AC-20c —
''means "the supplying level is not recorded", and it is never interpreted asworkspace. It arises from exactly two causes, which a consumer separates by reading the siblingtiercolumn:tiertier_sourcemeaning non-empty ''row written before this column shipped ''''the attempt never resolved a tier non-empty non-empty normal post-migration row The second case is real and is not a pre-migration artefact: the
max_attempts_exceededattempt appended byinternal/office/scheduler/dispatch_routing.gosets noTiertoday and will likewise set noTierSource. The invariant this AC fixes is thereforetier_sourceis non-empty only whentieris non-empty; the converse does not hold, because legacy rows carry a tier and no source. A migration that back-fillstier_sourcefor legacy rows is forbidden — the level that produced those tiers was never recorded and cannot be reconstructed.The widened
effectiveTierpreserves this invariant at its own fallthrough. Its last step returnscfg.DefaultTier; when that value is empty it returns an empty tier and an empty source, neverworkspace. A source is reported only when a level actually supplied a tier. The case is defensive rather than live —validateTierrejects an empty tier and the column is declareddefault_tier TEXT NOT NULL DEFAULT 'balanced', so no supported write reaches it — but AC-20c states its invariant without qualification, so the fallthrough is pinned here rather than left for the builder to decide. -
AC-20b — The stored value is surfaced, not write-only.
tier_sourceis added todashboard.RouteAttemptDTOasjson:"tier_source,omitempty", mapped inrouteAttemptToDTObeside the existingTierfield. It therefore reaches both payloads that already carryRouteAttemptDTO—GET /runs/:id/attempts(RouteAttemptsResponse) and the run-detail response (RunRouting.Attempts) — with no new endpoint. Theomitemptytag means a row whose level is unrecorded ('', either cause in AC-20c) omits the key rather than reporting a false level.The TypeScript client contract widens with it:
RouteAttemptinapps/web/lib/state/slices/office/types.tsgainstier_source?: "wake_reason" | "override" | "role" | "workspace". That one type backs every consumer the field must reach —RouteAttemptsResponseforGET /runs/:id/attemptsand the run-detailattemptsarray (both inapps/web/lib/api/domains/office-runs-api.ts), and the WS payload (apps/web/lib/ws/handlers/office.ts). Without this the field is on the wire but unreadable from TypeScript, and "surfaced, not write-only" is false in practice. Note this is the route-attempttier_source, a third type spelled the same way; AC-18a's table governs the other two and is unaffected.The
route_attempt_appendedWS event gains the field too, and that is intended.publishRouteAttemptAppended(internal/office/scheduler/routing_events.go) serialisesmodels.RouteAttemptwhole aspayload["attempt"], so the field AC-20f adds to that struct appears on the event automatically, at all three publish sites. This is additive andomitempty-guarded, so no existing consumer breaks. Build must not suppress it: the two suppression routes available — dropping the JSON tag AC-20f specifies verbatim, or introducing a separate WS-only DTO — are both forbidden here, the first because it contradicts AC-20f and the second because it would create the second producer AC-20d exists to prevent.Explicitly out of scope for this AC: any new UI rendering of the value — no control, no column, no badge. The field becomes available to the run-detail UI and to WS consumers; presenting it is a later change.
-
AC-20d — The value has one producer, shared by the resolve path and the preview path, so an agent's audit record and its preview can never disagree about the level.
effectiveTier(internal/office/routing/resolver.go) is widened to return the tier and its source — it already receivescfgandov, and gains the agent'srolefor the new level — andtierSourceForAgent(provider.go, today the preview-only producer of the literal"inherit") is reimplemented to delegate to it rather than deciding independently. Two independent producers are explicitly forbidden: that is the defect this AC exists to prevent. -
AC-20e — The source is carried out of resolution on
routing.Resolutionas a newTierSource stringfield beside the existingRequestedTier(resolver.go).Resolutionis the only carrier; the source is not threaded throughResolveOptions, not recomputed by the scheduler, and not re-derived from the agent at write time. -
AC-20f — The persistence path is, in order:
-
internal/office/models/models.go—RouteAttemptgains aTierSource stringfield besideTier, carrying the JSON tagtier_source,omitemptyand the db tagtier_source:TierSource string `json:"tier_source,omitempty" db:"tier_source"` -
internal/office/repository/sqlite/base_migrations.go— the additive column is added with the replayable form already used one line above for this same table:r.migrate.Apply("office_run_route_attempts.tier_source", "ALTER TABLE office_run_route_attempts ADD COLUMN tier_source TEXT NOT NULL DEFAULT ''"). The migration for this column does not live inworkspace_routing.go— that file holds theoffice_workspace_routingread/write SQL only, and therole_tiersmigration likewise belongs inbase_migrations.go. -
internal/office/repository/sqlite/route_attempts.go—tier_sourceis added to theAppendRouteAttemptINSERT column list and its parameter list, and to theListRouteAttemptsSELECT list soStructScanpopulates it. -
internal/office/scheduler/dispatch_routing.go— the threeAppendRouteAttemptcall sites setTierSourcewherever they already setTier— and they do not divide evenly. Each of the three needs a different edit:site resin scope?sets Tiertoday?edit parkRunMaxAttemptsno no none — sets neither field, per AC-20c parkRunBlockedyes ( res *routing.Resolutionparameter)yes, Tier: string(res.RequestedTier)add TierSource: res.TierSourcebeside itrecordAttemptStartno — receives a bare tier routing.Tieryes, from that parameter thread a source parameter, supplied at the call site So the split is one no-op, one direct read, one threaded parameter — there is no second direct-read site, and a builder who goes looking for one is looking for something that does not exist.
parkRunMaxAttemptsin particular takes no*routing.Resolutionat all; threading one into it to "complete the pattern" is forbidden by this AC and by AC-20g.For the threaded site:
recordAttemptStart's signature today ends in a baretier routing.Tierparameter and gains a source parameter alongside it, supplied fromres.TierSourceat its call site, whereresis already in scope. Deriving the source insiderecordAttemptStartinstead is forbidden by AC-20d.
-
-
AC-20g — Two nearby sites are explicitly NOT write sites and must not be changed: the
prior = append(prior, models.RouteAttempt{…})indispatch_routing.gois an in-memory mirror used for fallback bookkeeping bylatestFailedExecutionProfile, not a persistence call; and the attempt-finalisation path updates outcome columns on an existing row and does not writetier_source. -
AC-20a —
office_run_route_attemptscarries noagent_idcolumn; attribution is viarun_idto the owning run. This feature adds no agent column, so a "which tier did agent X get" query remains a join throughruns.
Forced-to-invent pass
Decisions a builder would otherwise have to guess. Each is a contract.
Ordering and tiebreak
- AC-21 — The four precedence levels are evaluated in the fixed order in The contract. The order is not configurable.
- AC-22 —
role_tiersis a map keyed by a unique role, so no two entries can apply to one agent and no tiebreak is required. An agent has exactly onerole(a single non-null column), so at most one entry matches. - AC-23 — WHEN
role_tiersis serialised to JSON for persistence or API response, THEN keys are emitted sorted ascending by the role string using byte order, so a round-trip is byte-stable and diffs are reviewable.mapiteration order must not reach the wire. - AC-24 — WHEN the routing settings UI lists roles, THEN they are ordered by the
declaration order of the
AgentRoleconstants (ceo, worker, specialist, assistant, security, qa, devops), not alphabetically and not by map order.
Idempotency and retry
- AC-25 — WHEN the same
role_tierspayload is written twice, THEN the second write succeeds and leaves the persisted value byte-identical.updated_atstill advances; it records the write, not a value change. - AC-26 — A
role_tierswrite is a whole-map replacement, not a merge. WHEN a payload omits a role that is currently mapped, THEN that role's entry is deleted. This matches howtier_per_reasonandprovider_orderalready behave. - AC-26a — An absent
role_tierskey on a routing write means the same as{}: the stored map is cleared. It does not mean "preserve the current value". This follows the endpoint's existing whole-config semantics —TierPerReasonis likewiseomitempty, unmarshals to nil when the key is absent, and is written unconditionally by theoffice_workspace_routingupsert — and making one field alone absent-means-preserve would be a new and surprising rule on a PUT where every other field is replaced. The consequence is that a client which does not yet know aboutrole_tierserases it, so both in-repo writers are updated to round-trip the field: the workspace routing page, and thecfgparameter type of the routing-config PUT helper inapps/web/e2e/helpers/office-api-client.ts. (This is distinct from that file'stier_source, which sits insideoverridesand stays untouched per AC-18a.) - AC-27 — WHEN a routing write fails validation, THEN no part of it is persisted;
role_tiersand every other field are written in one transaction. - AC-28 — Tier resolution is a pure read. Retrying a failed launch re-resolves from current config; a tier is never cached on the run and never frozen at enqueue time. A config change between attempt N and attempt N+1 therefore takes effect on N+1, and the two attempts may legitimately record different tiers.
Concurrency
-
AC-29 — GIVEN two callers write
role_tiersfor the same workspace concurrently, WHEN both succeed, THEN the last committed write wins in full and the row is never left holding a mixture of the two maps.office_workspace_routingis keyed byworkspace_id, so this is a single-row update. -
AC-30 — GIVEN a
role_tierswrite commits while a run is mid-resolution, WHEN that resolution completes, THEN it uses whichever value its own read observed and is never retried on that basis, and the run is not aborted. Both halves are observable: the resolved tier is recorded on the route attempt, and a retry would appear as a second attempt row.Design note, not an acceptance criterion: no lock is taken across the launch. That is a statement about the implementation, not an outcome any test, API response, or DB query can witness — this section's preamble promises every AC is observable, and as an AC clause it would not have been.
-
AC-31 — GIVEN an agent's
rolechanges while it has queued runs, WHEN those runs launch, THEN each resolves against the role in force at its own resolution time. Role changes are not applied retroactively to already-resolved runs.
Nil, empty and error behaviour
-
AC-32 — WHEN the
role_tierscolumn holds'','{}', or SQLNULL, THEN it decodes to an empty map and resolution proceeds todefault_tier. None of the three is an error. -
AC-32a — AC-32 outranks AC-33 on the empty string.
''is simultaneously "empty" under AC-32 and "JSON that fails to decode" under AC-33, becausejson.Unmarshal([]byte(""), ...)returns unexpected end of JSON input. AC-32 wins because it names''explicitly; AC-33 governs only non-empty bytes that fail to parse ({,not json, a truncated object).The adjacent
tier_per_reasondecode does NOT behave this way, and copying it is the failure mode.loadWorkspaceRouting(internal/office/repository/sqlite/workspace_routing.go) selectsCOALESCE(tier_per_reason, '{}')and unmarshals the result.COALESCEsubstitutes for SQLNULLonly — a literal''passes through untouched and then errors injson.Unmarshal. The pattern sitting one line from whererole_tierswill be read therefore implements AC-33's behaviour on AC-32's input. Therole_tiersdecode needs an explicit empty-string short-circuit the precedent lacks: treat a zero-length raw value as{}before unmarshalling, then unmarshal.This is a defect in the precedent as a template, not in
tier_per_reasonas shipped — that column is alsoNOT NULL DEFAULT '{}'and no supported write puts''in it.role_tiersinherits the same protection, so this too is defensive. It is specified because AC-32 states its contract unconditionally, and a builder extending the neighbouring line would break it without ever seeing a failure. -
AC-33 — WHEN the
role_tierscolumn holds JSON that fails to decode, THEN loading the workspace routing config returns an error and the launch is refused with the existing blocked-route path. It does not silently fall back todefault_tier: a corrupt policy must be visible, not quietly ignored. -
AC-34 — WHEN an agent's
roleholds a value not in the seven-value enum (a row written by an older build or by hand), THENrole_tiersis not consulted for it, resolution proceeds todefault_tier, and the launch is not refused. -
AC-34a — WHEN a persisted
role_tiersmap holds an entry with an empty-string tier (bypassing AC-11, e.g. written by hand), THEN that entry is treated as absent at resolution time and the agent falls through todefault_tier. It is not an error and does not refuse the launch; AC-33's refusal is reserved for JSON that fails to decode. -
AC-34b — WHEN a persisted
role_tiersmap holds a key outside the seven-value enum, THEN that entry is ignored at resolution time rather than refusing the launch. AC-8 keeps such keys out on the write path; the read path stays permissive so a role removed from the enum in a future build cannot brick every launch in the workspace. -
AC-35 — WHEN the role-supplied tier is mapped by no provider in the effective order at launch time, THEN the existing
missing_model_mappingskip path applies unchanged; this feature adds no new blocked-route status.
Defaults and boundaries
- AC-36 — The default value of
role_tiersis{}for every workspace, existing and new. Onboarding does not seed it. - AC-37 —
role_tiersholds at most seven entries, bounded by the enum. A payload with more keys than the enum has values is rejected by AC-8 before size matters, so no separate length limit is specified. - AC-38 — Writing
role_tiersfor a role that currently has no agents is valid and persists; the map is a policy, not a join, and it applies when such an agent is later created. - AC-39 — WHEN an agent's per-agent tier override is cleared, THEN its effective
tier falls to the role entry if one exists, and to
default_tierotherwise, with no further operator action.
Precedent citations
This spec cites in-repo code as precedent about a dozen times. Three review rounds have
now each found at least one citation where copying the named precedent produces
behaviour that violates an AC in this same spec: AC-14a's three body-buffering
handlers (round 3), then AC-32a's tier_per_reason decode and AC-10a's validateTier
(round 4). Those instances are fixed above. This section exists to close the class.
The standing rule. Every precedent named in this spec is cited for a named step, never wholesale, and each citation states what copying it produces. A citation reading only "matching X" or "as Y already does" is incomplete — treat it as a defect in this spec to be routed back, not as licence to copy X or Y.
Why prose alone is not enough: all three rounds' traps were already adjacent to text telling the builder to be careful, and a warning is only read by a builder who suspects there is something to look for. The precedents in question look correct, compile, and pass their own existing tests. So the guarantee is delegated to checks that go red.
-
AC-40 — For every AC whose behaviour a cited precedent would violate, there is a test that the precedent fails. This feature ships at minimum these four:
- a decode test feeding the
role_tierscolumn a literal''and asserting an empty map and no error (AC-32/AC-32a) — fails if theCOALESCE-only pattern is copied; - a validation test asserting the rejection for a bad
role_tiersvalue carriesfield == "role_tiers"and a message containing neitherdefault_tiernorrouting config invalid:(AC-9/AC-10a) — fails ifvalidateTier's error is returned or wrapped verbatim; - a validation test asserting that two bad
role_tiersentries produce twoDetailsentries (AC-8/AC-10) — fails ifcheckTierMapped's single-value shape is copied; - a handler test asserting the PATCH
modelrejection body carriesfield == "model"(AC-14c) — fails if the baregin.H{"error": ...}form is copied from theagent_profile_idrejection.
Four is a floor, not a ceiling: any further precedent the builder chooses to follow earns the same treatment. AC-40 is the same delegation AC-18c and AC-13c already make — move the guarantee off a list a human maintains and onto a check a machine runs.
- a decode test feeding the
Out of scope
Named exclusions. Each is a deliberate contract, not an omission.
- Reversing the wake-reason / per-agent precedence. Justified in
Deviation. If it is
wanted, it is a separate change to
docs/specs/office/routing.md. - User-defined roles.
AgentRolestays a fixed seven-value enum. Making roles extensible would give the Critic case a role-based answer, but it is a much larger change to Office identity and is not attempted here. - A
tiercolumn onagent_profiles. The card offers it as an alternative; rejected so routing policy stays inoffice_workspace_routing, matchingtier_per_reason. - Removing or backfilling the
modelcolumn on Office identity rows. AC-15 keeps the stored value. Dropping the column touches the sharedagent_profilestable used by execution profiles and kanban, and is not in this scope. - Per-agent or per-role provider order defaults. Only tier selection gains a role
level.
provider_orderkeeps its existing two levels. - Per-project, per-task, or per-skill tier selection.
- Automatic tier suggestions, or any heuristic that picks a tier for a role without the operator saying so.
- Changing which model IDs the tiers map to.
provider_profilesis untouched. - Cost reporting or budget enforcement changes arising from role-differentiated tiers.
Surfaces touched (E2E decision input)
- Backend —
internal/office/routing/{types,resolver,provider}.go(incl.provider.go#tierSourceForAgent, which after AC-20d delegates to the widenedeffectiveTierrather than deciding the source itself),internal/office/repository/sqlite/workspace_routing.go(role_tiersread/write SQL),internal/office/repository/sqlite/base_migrations.go(both additive migrations —office_workspace_routing.role_tiersandoffice_run_route_attempts.tier_source; this is where the replayableALTERprecedent for these tables lives),internal/office/models/models.go(RouteAttempt.TierSource, AC-20f),internal/office/repository/sqlite/route_attempts.go(INSERT + SELECT, AC-20f),internal/office/scheduler/dispatch_routing.go(threeAppendRouteAttemptsites, which need three different edits — see AC-20f's table),internal/office/dashboard/handler_routing.go, the AC-40 precedent-trap tests (decode ininternal/office/repository/sqlite, validation ininternal/office/routing, the PATCH rejection ininternal/office/agents),internal/office/dashboard/routing_dto.go(AgentRoutePreviewdoc comment +RouteAttemptDTOper AC-20b),internal/office/agents/handler.goand a new embedding response DTO ininternal/office/agents(AC-13a) with its shape-preservation test (AC-13c). Not edited but affected:internal/office/scheduler/routing_events.goneeds no change yet publishes the new field automatically, because it serialisesmodels.RouteAttemptwhole (AC-20b). - API — workspace routing GET/PUT gains
role_tiers; all five Office-identity agent payloads dropmodel(AC-13b);PATCH /office/agents/:idrejects amodelkey (AC-14);GET /runs/:id/attemptsand the run-detail payload gaintier_source(AC-20b); theroute_attempt_appendedWS event gains it additively as a consequence, which AC-20b accepts rather than suppresses. - Frontend —
app/office/workspace/routing/gains a role-tier card;agent-preview-table.tsxrenders a translated source label (AC-16a);app/office/agents/[id]/components/agent-routing-card.tsxandagent-route-strip.tsxgain the four-level source label; TSAgentRoutePreviewinlib/state/slices/office/types.tswidens and TSRouteAttemptin the same file gainstier_source?(AC-20b) — TSAgentRoutingOverridesdoes not (AC-18a);lib/state/slices/office/office-routing.test.tsupdates itssetRoutingPreviewfixture (AC-18a item 7); TSWorkspaceRoutinggainsrole_tiersand the routing-config PUT helper ine2e/helpers/office-api-client.tsgains it too (AC-26a). This list is indicative; AC-18c's typecheck gate is authoritative for thetier_sourcewidening. - Explicitly NOT touched (persisted-state sites, per AC-18a) —
internal/office/onboarding/service.go#writeAgentInheritMarkers; thetier_sourceinside theoverridesblob ine2e/helpers/office-api-client.ts; and the persisted-override reads/writes inagent-routing-card.tsx. Note the same e2e helper is edited elsewhere, for an unrelated field: its routing-config PUTcfgtype gainsrole_tiersper AC-26a. The untouched thing is the overridetier_source, not the file. - i18n — new copy in five locales (AC-19), including the four source labels and the AC-16a table label.
User-visible UI changes in the Office routing settings and the agent routing card mean
E2E coverage is warranted, scoped to the existing Office routing specs
(apps/web/e2e/tests/office/office-routing-*.spec.ts) rather than a new suite.