Queue Routing and Assignment
service-desk/queue-routing-assignment
| Kind | backend |
|---|---|
| Domain | service-desk |
| Maturity | prod |
| Reusable | extract |
| Stack | fastapi-0.118, sqlalchemy-2.0-sync, postgres, pydantic-2.12 |
| Depends on | _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/workspace-scoping, _shared/alembic-migrations, _shared/test-layout |
| Frontends | service-desk/ui/ticket-detail |
| Reference implementation |
|
| Measured on | alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12 |
| Version | 1 |
Purpose
Every ticket that is created, retyped, reclassified by an agent, or explicitly asked to be rerouted is checked against the workspace's own ordered routing rules and comes out pre-assigned (or deliberately left unassigned), classed, pipelined and notified according to the first rule that matches, with round-robin fairness when a rule names several candidate agents. The invariant that makes it trustworthy is that route_ticket never raises into the ticket transaction, a rule naming a departed agent is skipped and logged rather than failing the request, and exactly one desk_routing_events row is written per evaluation whether a rule matched or not, so every assignment decision, and every reason one did not happen, is on the record (routing.py module docstring, lines 1 to 6; test_every_ticket_gets_exactly_one_create_routing_event_even_without_rules, test_desk_routing.py line 289).
When to use
Read it before touching route_ticket, rule_matches, resolve_assignee, _round_robin_pick, or build_input; before changing what counts as a rule match, what a pre-assignment does versus an accept, or which triggers are allowed to overwrite class_key; and when someone asks "why did this ticket go to the wrong person", "why is it unassigned", "why didn't reclassifying it change the class", or "who gets notified when a Störung rule fires". Trigger phrases: "routing rules", "pre-assign", "round robin", "desk_routing_events", "rule_index", "config_version", "assign_mode", "keywords_any/keywords_all", "requester_domain routing", "ai_category_in", "already_set", "not_agent".
Not this recipe: POST /api/desk/tickets/{id}/assign (service_desk_router.py line 306, ticket_service.assign_ticket, line 672), the agent-initiated manual (re)assignment that changes assignee_id unconditionally, writes a DeskStatusEvent ("assigned") and never touches desk_routing_events at all, a different action from everything in this recipe, which only ever pre-assigns; the generic config-document layer (PATCH /api/workspaces/{id}/config/{section}, config_service.patch_section), which this recipe's own tests use to write routing but which is shared by every config section (desk, task_types, routing, integrations, notifications, dashboards, home) and belongs to its own recipe; the routing-rule JSON Schema and its cross-document validator (config_schema.py RULE_SCHEMA/ROUTING_SCHEMA, config_validate.py lines 268 to 312, which also checks referential integrity this recipe assumes already holds: type_in/class_in/ai_category_in naming real task types, classes and categories, set_class naming a real class, unique rule ids, a non-empty round_robin) which this recipe's Security & tenancy section cites but does not own; the notification fan-out itself (desk_notification.DeskNotification.emit, resolve_recipients) that a rule's notify list and a fresh pre-assignment feed into, read here only as far as notify_recipients() hands it a user-id list; the ticket lifecycle state machine (§4.3, change_status) that pre-assignment deliberately leaves untouched (status stays new, accepted_at stays null).
Stack assumptions
At 62ba4fc: fastapi==0.118.2, synchronous handlers on a sqlalchemy.orm.Session (SQLAlchemy==2.0.43), psycopg2-binary==2.9.10, pydantic==2.12.0, alembic==1.16.5; Postgres 15-alpine in prod (docker-compose.prod.yml line 9), Postgres 14 in the test harness (docker-compose.test.yml line 115). No Redis, no scheduler, no HTTP call: route_ticket runs synchronously inside the same request/transaction as the ticket write that triggered it (create_ticket, patch_ticket, or the dedicated /route endpoint), never on a background job. Foundations assumed: S4 (fastapi-project-layout) for the app and its router mounting; S5 (auth-dependency-pattern) for the DeskAgent/ConfigAgent dependencies that gate every route this recipe's tests drive; S6 (workspace-scoping) because every query in routing.py is filtered by workspace_id and the module never reads X-Workspace-ID directly, only the ticket.workspace_id already resolved by the caller; S8 (alembic-migrations) for the one migration that creates desk_routing_events; S9 (test-layout) for the transactional db_session fixture and the dependency-override TestClient pattern desk_support.py documents itself as copying.
Environment: none. routing.py reads no setting from core/config.py: grep for DESK_ROUTING and ROUTING_ over core/config.py at this commit returns nothing; every routing decision is data in workspace_configs.config.routing (the ROUTING_SCHEMA document), never an environment flag. Fail behaviour: route_ticket is designed to never raise (module docstring, line 3); the two internal failure points are a rule that resolves to no valid agent, which the function turns into actions.skipped.assignee plus a logger.warning (routing.py lines 170 to 173) rather than an exception, and a _round_robin_pick over an empty candidate set, which returns None and is only reached after resolve_assignee has already filtered candidates to agents that exist (routing.py lines 72 to 74, 122 to 126).
Data model
No table of its own beyond the one this recipe writes to and reads from; desk_tickets (models/service_desk_model.py lines 129 to ~256) is Ticket Detail's table, cited here only for the columns routing touches.
-- models/service_desk_model.py lines 257 to 277; migrated in
-- backend/migrations/versions/20260904_0001_service_desk_core.py lines 152 to 167
desk_routing_events (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
ticket_id uuid NOT NULL REFERENCES desk_tickets(id) ON DELETE CASCADE,
trigger varchar(20) NOT NULL, -- create | type_change | class_change | rerun
config_version integer NOT NULL, -- the workspace_configs.version routing was read at
rule_index integer, -- position in routing.rules of the matched rule, else NULL
rule_id varchar(50), -- the matched rule's own "id", else NULL
matched boolean NOT NULL DEFAULT false,
input jsonb NOT NULL DEFAULT '{}', -- {type, class, keywords_hit, requester_domain, ai_category}
actions jsonb NOT NULL DEFAULT '{}', -- {notified: [...], skipped: {...}, assignee_id?, assign_mode?,
-- set_class?, set_pipeline?}
actor_id uuid REFERENCES users(id) ON DELETE SET NULL, -- NULL on create/type_change/class_change; the
-- agent who requested a rerun otherwise
occurred_at timestamptz NOT NULL
);
CREATE INDEX ix_desk_routing_events_ticket ON desk_routing_events(ticket_id, occurred_at);
Columns on desk_tickets this recipe writes (never migrates on its own; all part of the core migration above): assignee_id (nullable FK to users, ON DELETE SET NULL), assigned_by (varchar(10): accept | routing | manual, set to "routing" only by this module, models/service_desk_model.py line 155), class_key, pipeline_key (varchar(50), nullable, line 173), first_response_due (recomputed when a rule's set_class actually changes the class, never otherwise). What is NOT stored: the routing rule document itself is not duplicated onto the event row, only rule_id/rule_index/config_version point back at the workspace_configs version that produced the decision; no PII beyond user ids (input.requester_domain is an email domain, never the full address).
API surface
| Route | Auth dep | Tenancy | Body | Codes | Notes |
|---|---|---|---|---|---|
POST /api/desk/tickets (service_desk_router.py line 256, create_ticket) | DeskMember (any workspace member/owner with a resolved desk role) | ws from _member_with_role | TicketCreate | 201 TicketDetail; 400 unknown_type/unknown_class/invalid_fields/assignee_not_agent | calls routing.route_ticket(db, ticket, ds, "create", actor=None, ..., assignee_explicit=<bool>) (ticket_service.py lines 360-361) after the ticket and its first message are flushed |
PATCH /api/desk/tickets/{ticket_id} (line 275, patch_ticket) | DeskAgent (role must be agent) | ws from _require_agent | TicketPatch | 200 TicketDetail; 400 unknown_type/unknown_class/invalid_fields/assignee_not_agent | type_slug change → routing.route_ticket(..., "type_change", ...) (line 1053); class_key change → routing.route_ticket(..., "class_change", ...) (line 1062); a manual assignee_id change writes a DeskRoutingEvent directly (not through route_ticket) with trigger="rerun", matched=False, actions={"assignee_id":..., "assign_mode":"manual"} (lines 1074-1079) |
POST /api/desk/tickets/{ticket_id}/route (line 320, reroute_ticket) | DeskAgent | ws from _require_agent | none | 200 RoutingEventOut | ticket_service.rerun_routing → routing.route_ticket(..., "rerun", actor=<the calling agent>, ...); the one endpoint whose sole job is to run this recipe's engine on demand, with no other ticket mutation |
PATCH /api/workspaces/{workspace_id}/config/routing (line 1147, patch_config_section, section-generic) | ConfigAgent | ws from _config_agent (404 if the workspace does not exist, 403 if the caller is not a desk agent) | ConfigPatch ({"patch": {...}}, RFC 7386 merge) | 200 config body with ETag; 422 config_invalid with per-field errors; 428 if_match_required when a guarded key would move without an If-Match | not owned by this recipe (see "Not this recipe") but is how every test here writes routing.rules/routing.default_assignee before creating a ticket |
route_ticket(db, ticket, desk_settings, trigger, actor=None, first_message_body="", assignee_explicit=False) -> DeskRoutingEvent (routing.py lines 130 to 226) is the actual reusable surface, called from three of the four routes above; it is not itself a route.
Application logic
route_ticket, in commit order (routing.py lines 139 to 226):
- Build the input (
build_input, lines 32 to 43):type=ticket.type_slug;class=ticket.class_key;requester_domain= the part ofticket.requester_emailafter@, lower-cased, or""when there is no@;ai_category=ticket.ai_category(set earlier from the AI intake check, out of this recipe's scope);haystack= the lower-cased join of the ticket title, the first message body, and everytext/textareafield value of the ticket's task type (task_types.text_values,task_types.pylines 91 to 99). - Load candidates:
agents = agent_user_ids(db, ticket.workspace_id)(roles.pylines 132 to 139), the intersection ofdesk.agents(a list of user ids in the config document) and active membership/ownership of the workspace; a listed-but-departed or listed-but-never-a-member id is silently excluded here, which is what later producesnot_agent. - Walk
routing.rulesin order (line 147), skipping any rule whoseenabledis explicitlyfalse(defaulttrue); a rule matches whenrule_matchesreturns a list (possibly empty) rather thanNone.rule_matches(lines 46 to 69) ANDs every match key present:type_in/class_in/ai_category_inare membership checks;requester_domainis a case-insensitive equality;keywords_anyneeds at least one lower-cased substring hit in the haystack;keywords_allneeds every one. First match wins; the loop breaks immediately (line 153). - Resolve
assign: the matched rule'sassign, or, when no rule matched,routing.default_assigneeread as{"user_id": ...}only:default_assigneeinROUTING_SCHEMA(config_schema.pylines 436-442) accepts only that one shape (nullor a fixed user), neverroleorround_robin, unlike a per-ruleassignwhich accepts all three (ASSIGN_SCHEMA, lines 387-403). If the ticket already has anassignee_idand either the caller set one explicitly on create (assignee_explicit=True) or the trigger istype_change/class_change/rerun, assignment is skipped withactions.skipped.assignee = "already_set"(lines 165-167); a freshcreatewith no explicit assignee is the only trigger that can overwrite an unset assignee viaresolve_assignee. Otherwiseresolve_assignee(lines 108-127) resolves:user_idas given, refused asnot_agentif it is not inagents;role: owner→workspace_owner_id,role: admin→earliest_admin_id(the active admin member with the earliestjoined_at), both refused asnot_agentif the resolved id is not inagents;round_robin→_round_robin_pickover the listed candidates filtered to actual agents, refused asnot_agentif none remain. On success,ticket.assignee_idandticket.assigned_by = "routing"are set andstatus/accepted_atare left untouched: this is a pre-assignment, not an Annehmen (test_first_enabled_match_wins_and_pre_assigns, line 91, assertsstatus == "new"andaccepted_at is Noneright after). _round_robin_pick(lines 72-105): counts each candidate's currently-open tickets in this workspace (status NOT IN ('done','cancelled')), picks the fewest; ties break on the candidate's most recentdesk_routing_events.actions->>'assignee_id'timestamp (least-recently routed wins, a candidate never routed sorts as"", i.e. first); a further tie breaks on the string form of the user id, which is arbitrary but deterministic.set_class, matched-rule only, and only ontrigger in ("create", "type_change")(lines 184-196), never onclass_change, because that trigger is by definition the agent's own explicit class choice and a rule overwriting it would fight the person who just made the decision. When the target class exists and differs from the current one,class_keyis set and, if the ticket has not yet had a first response,first_response_dueis recomputed (sla.compute_first_response_due) against the new class's SLA. When the target class equals the current one it is still recorded inactions.set_class(no-op, but visible). Aset_classpresent on aclass_changetrigger is recorded asactions.skipped.set_class = "class_change".set_pipeline, matched-rule only, unconditional on trigger:ticket.pipeline_key = sp.notify, matched-rule only: every listed user id that resolves to an active agent is appended toactions.notified; a non-agent or malformed id is silently dropped (not logged, unlike the assignee path).- Write the event (lines 208-226): one
DeskRoutingEventrow,config_version= theDeskSettings.config_versionthis whole call was resolved against (so a routing change mid-flight is traceable to exactly which document version decided),matched/rule_id/rule_indexfrom step 3,inputandactionsas built above,actor_id= the caller only onrerun(every other trigger passesactor=None),occurred_at = clock.now().db.flush()(line 226, notdb.commit()): the event is part of the caller's own transaction.
notify_recipients(event) (lines 229-235) is a thin reader of event.actions["notified"] back into a UUID list; create_ticket and create_ticket_from_email feed it straight into the new_ticket notification fan-out's recipients (ticket_service.py lines 377-378, 523), where resolve_recipients further unions it with the freshly pre-assigned ticket.assignee_id and, only if still empty, with each agent's own class/type notification preferences (desk_notification.py lines 189-216); a rule's notify list is additive attention, not a filter.
Invariants
- Exactly one
desk_routing_eventsrow per evaluation, matched or not, oncreate,type_change,class_changeandrerun(test_every_ticket_gets_exactly_one_create_routing_event_even_without_rules; every other test in the file asserts an event count off_events()). - The first enabled rule that matches wins; a disabled rule is never evaluated even when it would otherwise match first (
test_first_enabled_match_wins_and_pre_assigns). - A pre-assignment never touches
statusoraccepted_at; onlyPOST .../acceptdoes (test_first_enabled_match_wins_and_pre_assigns, thestatus == "new"/accepted_at is Noneassertions, then the explicit accept call after). set_classapplies oncreateandtype_changeonly, never onclass_change(test_set_class_on_create_and_type_change_but_not_class_change).- A rule naming a departed (no-longer-active-member) agent never raises; it is skipped with
actions.skipped.assignee = "not_agent"and alogger.warning, and the ticket is left unassigned (test_departed_agent_is_skipped_with_not_agent). - An assignee already set, whether by an agent's explicit choice on create or by an earlier routing decision, is never silently overwritten by
type_change/class_change/rerun; it is recorded asactions.skipped.assignee = "already_set"(test_explicit_assignee_on_create_is_kept,test_rerun_never_changes_an_existing_assignee_and_logs_config_version). - Round robin picks the fewest currently-open tickets among the named candidates, ties broken by least-recent routing assignment; a
done/cancelledticket never counts toward the load (test_round_robin_picks_the_agent_with_fewer_open_tickets). rerunnever invents a decision independent of the current config: it re-readsrouting.ruleslive and stamps the event with the config version in force at that moment, distinct from the version the ticket was originally created under (test_rerun_never_changes_an_existing_assignee_and_logs_config_version).
Frontend
The module that would render this, by id:
service-desk/ui/ticket-detail the agent-facing ticket page: shows the current assignee/class/pipeline a
routing decision produced, and the ticket's routing_events list (each row's
trigger, matched rule, and actions) via GET /api/desk/tickets/{id}
(TicketDetail.routing_events, serializers.routing_event_out); the "Route again"
action on that page is what calls POST .../route. No dedicated routing-rule
editor UI is identified at this commit; the rule document itself is edited
through the generic workspace-config layer this recipe does not own.
Minimum admin and debug surface (no dedicated route exists for this at 62ba4fc; these are the query and the one log line a human runs by hand): SELECT trigger, rule_id, matched, actions FROM desk_routing_events WHERE ticket_id = :id ORDER BY occurred_at (the same rows Ticket Detail renders, useful when the UI is not open); the WARNING line desk routing: rule %s names no active agent (%s) (routing.py lines 172-173), the one thing this module logs on its own, emitted every time a rule cannot be honoured.
Security & tenancy
Auth dependency: route_ticket itself takes no request and enforces nothing; every caller is gated upstream: DeskMember on ticket create (_member_with_role, any resolved desk role, viewer excluded by create_ticket's own check), DeskAgent on patch and reroute (_require_agent, role must be agent), ConfigAgent on the routing document PATCH (S5, S6). Workspace derivation: ticket.workspace_id, already resolved and locked (get_ticket(..., for_update=True) in patch_ticket/rerun_routing) by the caller before route_ticket runs; the module never reads a header or a request itself.
Fail direction: closed on identity, open on routing failure. Closed: resolve_assignee's agents set is always the config-listed ∩ actively-a-member intersection, so a rule can never assign to someone who is merely still listed in desk.agents after leaving the workspace, even though the config document itself would need a separate write to notice (config_validate.py agent_not_member/routing_user_not_agent, lines 189-198 and 271-277, catches this on the next config write, not retroactively on a ticket already routed under the stale rule). Open, by design: routing never blocks the ticket transaction; an unresolvable rule degrades to an unassigned ticket plus a logged skip reason, never a 4xx or 5xx, because a broken routing rule must never stop a client from being able to submit a ticket (module docstring, routing.py lines 3-5).
Secrets at rest: none; desk_routing_events.input/actions carry only type/class strings, keyword hits (the config's own configured keyword list, not free text), an email domain and user ids, never a message body or a field value verbatim. Rate limits: none dedicated to routing; it rides whatever rate limit, if any, guards the ticket-mutating route that triggered it. Attacker-controlled inputs, all mediated by whatever created the ticket: ticket.title, the first message body, and every task-type text field feed the keyword haystack (build_input), so a requester can influence which rule matches by choice of words, but only within the rule vocabulary an agent already configured (keywords_any/keywords_all are workspace-defined terms, not caller-supplied patterns); requester_email's domain is likewise requester-controlled and only ever compared for equality against a configured domain, never used to construct a query.
Tests
backend/tests/test_desk_routing.py (294 lines) against the :5434 test Postgres, using alchemy_labs/backend/.venv:
TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/test_db \
python -m pytest tests/test_desk_routing.py -q --no-cov -o addopts="" -p no:cacheprovider
11 def test_ functions (grep -c "^def test_"), 6 marked @pytest.mark.critical (test_rule_matches_anded_keys_and_keyword_hits, test_first_enabled_match_wins_and_pre_assigns, test_type_and_keywords_are_anded, test_round_robin_picks_the_agent_with_fewer_open_tickets, test_departed_agent_is_skipped_with_not_agent, test_set_class_on_create_and_type_change_but_not_class_change). One test, test_rule_matches_anded_keys_and_keyword_hits, is a pure function over rule_matches with no database and always runs; the other ten build real users, a workspace and tickets through the rt fixture (db_session, test_workspace_id, a frozen clock, dependency-override TestClients, desk_support.py's helpers) and exercise the engine through the real HTTP routes, not by calling route_ticket directly. Not independently reproducible as a clean run in this environment: this authoring session shares the :5434 deap-postgres-test container with sibling sessions running their own suites in parallel, and tests/conftest.py's test_engine fixture is session-scoped and does Base.metadata.drop_all(bind=engine) then create_all(bind=engine) once per pytest process (lines 99-101); two such processes racing against the same container produce sqlalchemy.exc.ProgrammingError: (psycopg2.errors.UndefinedTable) relation "<some other suite's table>" does not exist on the DROP CONSTRAINT step of one process's drop_all. Measured 2026-09-12, four consecutive runs of the command above: 1 passed, 10 errors; 4 passed, 7 errors; 10 passed, 1 error; 2 passed, 9 errors, all failures the identical drop/create race, never a routing assertion failing on its own logic. A single-tenant run of this suite (an exclusive :5434, or a dedicated scratch database per S9's own guidance) is expected to print 11 passed; this recipe does not claim to have observed that clean number at this commit because the shared container made it unreproducible during authoring.
Hermetic vs live: fully hermetic, no PSP, no external call; the clock is frozen (frozen_clock fixture) so SLA-due assertions are exact. Known-answer vectors: test_rule_matches_anded_keys_and_keyword_hits (lines 71-84) pins the matcher directly: an empty match returns [] (matches, no keyword hits); a type_in + keywords_any miss on the type returns None; a keywords_all requiring two terms returns both as hits only when both are present; requester_domain is compared case-insensitively; ai_category_in against a None category never matches.
Deploy & ops
Config keys per app: none (Stack assumptions). Migration: 20260904_0001_service_desk_core.py (revision = "service_desk_core_001", down_revision = "commerce_second_image_001"), creates desk_routing_events alongside eight sibling desk tables in one linear revision; downgrade() drops them in reverse. No data migration: a workspace with no routing key in its config document reads routing.get("rules") or [] as empty and default_assignee as None via resolved_desk's fallback (config_service.py line 225), so an un-configured workspace routes nothing and every ticket lands unassigned with matched=False, never an error. Scheduler: none; this recipe has no background job, unlike the desk reminder job (DESK_REMINDERS_ENABLED) which is a different recipe.
Argus-style check: POST /api/desk/tickets on a workspace with no routing rules configured returns 201 with assignee: null and a routing_events array of length 1 whose matched is false, proving the seam runs and writes even when there is nothing to decide. Rollback: code-only; a desk_routing_events row is append-only and never deleted by this module (ON DELETE CASCADE from desk_tickets/workspaces only), so rolling the code back does not need to unwind any data it wrote.
Gotchas
- 2026-09-12 (measured at 62ba4fc):
routing.default_assigneesupports only{"user_id": ...}, neverroleorround_robin(ROUTING_SCHEMA,config_schema.pylines 436-442), while a per-ruleassignsupports all three (ASSIGN_SCHEMA, lines 387-403). A workspace that wants "round robin when nothing else matches" has to write an unconditional catch-all rule (match: {}) rather than relying ondefault_assignee; nothing in the schema or the engine enforces or documents this asymmetry beyond the schema's own shape. - 2026-09-12 (measured at 62ba4fc): a manual
assignee_idchange throughPATCH /api/desk/tickets/{id}writes itsDeskRoutingEventdirectly inticket_service.patch_ticket(lines 1074-1079) rather than going throughroute_ticket, so this one write path duplicatesroute_ticket's event shape (trigger="rerun",matched=False,actions={"assignee_id", "assign_mode": "manual"}) by hand. A change to the event shape insiderouting.py(a new key underactions, say) does not automatically apply to this second write site; the two are kept consistent by convention, not by sharing code, andtest_rerun_never_changes_an_existing_assignee_and_logs_config_versiononly exercises the/routeendpoint's path, not this one. - 2026-09-12 (measured at 62ba4fc):
POST /api/desk/tickets/{id}/assign(assign_ticket,ticket_service.pyline 672) is a third, entirely separate assignment path that writes nodesk_routing_eventsrow at all, only aDeskStatusEvent"assigned". A query or a UI that readsdesk_routing_eventsas "the full assignment history" of a ticket will miss every manual assign made through that endpoint. This recipe's scope note ("a logged routing event either way") holds for creation, type/class change and rerun; it does not hold for/assign. - 2026-09-12 (measured at this session, environmental, not a code defect): the shared
:5434test Postgres container's session-scopeddrop_all/create_allintests/conftest.pyis not safe under concurrent pytest processes; see Tests. A rebuild that wants a reliably green CI run for this suite needs either a database-per-worker scheme or a lock aroundtest_engine's setup, neither of which exists at this commit.
Reference implementation
Paths relative to the repo root; all at main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.
alchemy_labs/backend/services/service_desk/routing.py(235 lines, whole file in scope): module docstring (1-6);_uuid(25-29);build_input(32-43);rule_matches(46-69);_round_robin_pick(72-105);resolve_assignee(108-127);route_ticket(130-226);notify_recipients(229-235).alchemy_labs/backend/api/routers/service_desk_router.py:create_ticket(256-264);patch_ticket(275-281);reroute_ticket(320-326);assign_ticket(306-317, cited under "Not this recipe");_config_agent/ConfigAgent(1012-1023);patch_config_section(1147-1153).alchemy_labs/backend/services/service_desk/ticket_service.py:create_ticket(265-379, therouting.route_ticketcall at 360-361);patch_ticket(1026-1097, the tworoute_ticketcalls at 1053 and 1062, the manual-assign event write at 1074-1079);rerun_routing(1100-1105);assign_ticket(672-693, "Not this recipe").alchemy_labs/backend/services/service_desk/roles.py(whole file in scope):agent_user_ids(132-139),workspace_owner_id(142-146),earliest_admin_id(149-159),agents_in_doc/_membership/desk_role(31-77, the source of who counts as an active agent).alchemy_labs/backend/services/service_desk/config_service.py:DeskSettings(71-107,class_cfg/task_type);resolved_desk(209-228, theroutingfallback at line 225);patch_section(284-308).alchemy_labs/backend/services/service_desk/config_schema.py:ASSIGN_SCHEMA(387-403);RULE_SCHEMA(404-431);ROUTING_SCHEMA(432-449,default_assigneeat 436-442).alchemy_labs/backend/services/service_desk/config_validate.py: agent/member cross-check (189-198); routing user-is-agent check (268-312,check_userat 271-277).alchemy_labs/backend/services/service_desk/sla.py:compute_first_response_due(29-50, the functionroute_ticketcalls whenset_classchanges the class).alchemy_labs/backend/services/service_desk/task_types.py:text_values(91-99, the keyword haystack source).alchemy_labs/backend/services/service_desk/desk_notification.py:resolve_recipients(189-216, where a rule'snotifylist is unioned with the assignee and preference-matched agents).alchemy_labs/backend/models/service_desk_model.py:DeskTicket(129-256, cited columns only);DeskRoutingEvent(257-277).alchemy_labs/backend/migrations/versions/20260904_0001_service_desk_core.py:desk_routing_eventstable (152-167).alchemy_labs/backend/tests/test_desk_routing.py(whole file, 294 lines) andalchemy_labs/backend/tests/desk_support.py(thertfixture's dependencies:make_user,add_member,make_desk_agents,create_ticket,actors,frozen_clock).alchemy_labs/docs/service-desk/SPEC.md§4.10 "Routing engine" (lines 701-732), the design note the code was built from; measured against the code at this commit and found to match exactly (no drift).
Deviations of the reference from an ideal implementation, dated 2026-09-12: default_assignee's narrower shape than a per-rule assign (Gotchas); the manual-PATCH assignment event write duplicating route_ticket's event shape by hand instead of sharing a constructor (Gotchas); no dedicated admin/debug route for the one SQL query under Frontend; no rate limit specific to routing (Security & tenancy).
Acceptance checks
Run from alchemy_labs/backend at 62ba4fc (or an app built to this recipe) with the :5434 test Postgres up and exclusive (no sibling pytest process racing the same container; see Tests and Gotchas).
TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/test_db python -m pytest tests/test_desk_routing.py -q --no-cov -o addopts="" -p no:cacheproviderprints11 passedwith noERRORlines (see Tests for the measured, contended, non-exclusive counts this session actually observed).grep -c "^def test_" tests/test_desk_routing.pyprints11;grep -c "@pytest.mark.critical" tests/test_desk_routing.pyprints6.grep -n "^def route_ticket" services/service_desk/routing.pyprints line130;grep -n "routing.route_ticket(" services/service_desk/ticket_service.pyprints five call sites: twocreatetriggers (create_ticketat line 360,create_ticket_from_emailat line 519),type_change(1053),class_change(1062),rerun(1102, insidererun_routing).grep -n "DESK_ROUTING\|ROUTING_" core/config.py | wc -lprints0, confirming routing carries no dedicated environment configuration.- With the app running against a workspace with an empty
routingdocument:curl -s -X POST http://127.0.0.1:8000/api/desk/tickets -H "Authorization: Bearer $T" -H "X-Workspace-ID: $WS" -H "Content-Type: application/json" -d '{"type_slug":"question","title":"x","fields":{},"body":"x"}'returns 201 with"assignee": nulland"routing_events"an array of exactly one object whose"matched"isfalse. grep -n 'trigger in ("create", "type_change")' services/service_desk/routing.pyprints exactly one line,185(theset_classgate), confirmingclass_changeis not in the tupleset_classis allowed to fire on: the Application-logic step 6 / Invariants guarantee that an agent's explicit class choice is never overwritten by a rule.
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.