Prima Get an API key

Module kit / Service desk

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
  • alchemy_labs/backend/services/service_desk/routing.py
  • alchemy_labs/backend/api/routers/service_desk_router.py
  • alchemy_labs/backend/services/service_desk/ticket_service.py
  • alchemy_labs/backend/services/service_desk/roles.py
  • alchemy_labs/backend/services/service_desk/config_service.py
  • alchemy_labs/backend/services/service_desk/config_schema.py
  • alchemy_labs/backend/services/service_desk/config_validate.py
  • alchemy_labs/backend/services/service_desk/sla.py
  • alchemy_labs/backend/services/service_desk/task_types.py
  • alchemy_labs/backend/services/service_desk/clock.py
  • alchemy_labs/backend/services/service_desk/desk_notification.py
  • alchemy_labs/backend/models/service_desk_model.py
  • alchemy_labs/backend/migrations/versions/20260904_0001_service_desk_core.py
  • alchemy_labs/backend/tests/test_desk_routing.py
  • alchemy_labs/backend/tests/desk_support.py
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

RouteAuth depTenancyBodyCodesNotes
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_roleTicketCreate201 TicketDetail; 400 unknown_type/unknown_class/invalid_fields/assignee_not_agentcalls 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_agentTicketPatch200 TicketDetail; 400 unknown_type/unknown_class/invalid_fields/assignee_not_agenttype_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)DeskAgentws from _require_agentnone200 RoutingEventOutticket_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)ConfigAgentws 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-Matchnot 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):

  1. Build the input (build_input, lines 32 to 43): type = ticket.type_slug; class = ticket.class_key; requester_domain = the part of ticket.requester_email after @, 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 every text/textarea field value of the ticket's task type (task_types.text_values, task_types.py lines 91 to 99).
  2. Load candidates: agents = agent_user_ids(db, ticket.workspace_id) (roles.py lines 132 to 139), the intersection of desk.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 produces not_agent.
  3. Walk routing.rules in order (line 147), skipping any rule whose enabled is explicitly false (default true); a rule matches when rule_matches returns a list (possibly empty) rather than None. rule_matches (lines 46 to 69) ANDs every match key present: type_in/class_in/ai_category_in are membership checks; requester_domain is a case-insensitive equality; keywords_any needs at least one lower-cased substring hit in the haystack; keywords_all needs every one. First match wins; the loop breaks immediately (line 153).
  4. Resolve assign: the matched rule's assign, or, when no rule matched, routing.default_assignee read as {"user_id": ...} only: default_assignee in ROUTING_SCHEMA (config_schema.py lines 436-442) accepts only that one shape (null or a fixed user), never role or round_robin, unlike a per-rule assign which accepts all three (ASSIGN_SCHEMA, lines 387-403). If the ticket already has an assignee_id and either the caller set one explicitly on create (assignee_explicit=True) or the trigger is type_change/class_change/rerun, assignment is skipped with actions.skipped.assignee = "already_set" (lines 165-167); a fresh create with no explicit assignee is the only trigger that can overwrite an unset assignee via resolve_assignee. Otherwise resolve_assignee (lines 108-127) resolves: user_id as given, refused as not_agent if it is not in agents; role: owner → workspace_owner_id, role: admin → earliest_admin_id (the active admin member with the earliest joined_at), both refused as not_agent if the resolved id is not in agents; round_robin → _round_robin_pick over the listed candidates filtered to actual agents, refused as not_agent if none remain. On success, ticket.assignee_id and ticket.assigned_by = "routing" are set and status/accepted_at are left untouched: this is a pre-assignment, not an Annehmen (test_first_enabled_match_wins_and_pre_assigns, line 91, asserts status == "new" and accepted_at is None right after).
  5. _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 recent desk_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.
  6. set_class, matched-rule only, and only on trigger in ("create", "type_change") (lines 184-196), never on class_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_key is set and, if the ticket has not yet had a first response, first_response_due is recomputed (sla.compute_first_response_due) against the new class's SLA. When the target class equals the current one it is still recorded in actions.set_class (no-op, but visible). A set_class present on a class_change trigger is recorded as actions.skipped.set_class = "class_change".
  7. set_pipeline, matched-rule only, unconditional on trigger: ticket.pipeline_key = sp.
  8. notify, matched-rule only: every listed user id that resolves to an active agent is appended to actions.notified; a non-agent or malformed id is silently dropped (not logged, unlike the assignee path).
  9. Write the event (lines 208-226): one DeskRoutingEvent row, config_version = the DeskSettings.config_version this whole call was resolved against (so a routing change mid-flight is traceable to exactly which document version decided), matched/rule_id/rule_index from step 3, input and actions as built above, actor_id = the caller only on rerun (every other trigger passes actor=None), occurred_at = clock.now(). db.flush() (line 226, not db.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_events row per evaluation, matched or not, on create, type_change, class_change and rerun (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 status or accepted_at; only POST .../accept does (test_first_enabled_match_wins_and_pre_assigns, the status == "new" / accepted_at is None assertions, then the explicit accept call after).
  • set_class applies on create and type_change only, never on class_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 a logger.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 as actions.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/cancelled ticket never counts toward the load (test_round_robin_picks_the_agent_with_fewer_open_tickets).
  • rerun never invents a decision independent of the current config: it re-reads routing.rules live 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_assignee supports only {"user_id": ...}, never role or round_robin (ROUTING_SCHEMA, config_schema.py lines 436-442), while a per-rule assign supports 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 on default_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_id change through PATCH /api/desk/tickets/{id} writes its DeskRoutingEvent directly in ticket_service.patch_ticket (lines 1074-1079) rather than going through route_ticket, so this one write path duplicates route_ticket's event shape (trigger="rerun", matched=False, actions={"assignee_id", "assign_mode": "manual"}) by hand. A change to the event shape inside routing.py (a new key under actions, say) does not automatically apply to this second write site; the two are kept consistent by convention, not by sharing code, and test_rerun_never_changes_an_existing_assignee_and_logs_config_version only exercises the /route endpoint's path, not this one.
  • 2026-09-12 (measured at 62ba4fc): POST /api/desk/tickets/{id}/assign (assign_ticket, ticket_service.py line 672) is a third, entirely separate assignment path that writes no desk_routing_events row at all, only a DeskStatusEvent "assigned". A query or a UI that reads desk_routing_events as "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 :5434 test Postgres container's session-scoped drop_all/create_all in tests/conftest.py is 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 around test_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, the routing.route_ticket call at 360-361); patch_ticket (1026-1097, the two route_ticket calls 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, the routing fallback 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_assignee at 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_user at 271-277).
  • alchemy_labs/backend/services/service_desk/sla.py: compute_first_response_due (29-50, the function route_ticket calls when set_class changes 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's notify list 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_events table (152-167).
  • alchemy_labs/backend/tests/test_desk_routing.py (whole file, 294 lines) and alchemy_labs/backend/tests/desk_support.py (the rt fixture'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).

  1. 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 prints 11 passed with no ERROR lines (see Tests for the measured, contended, non-exclusive counts this session actually observed).
  2. grep -c "^def test_" tests/test_desk_routing.py prints 11; grep -c "@pytest.mark.critical" tests/test_desk_routing.py prints 6.
  3. grep -n "^def route_ticket" services/service_desk/routing.py prints line 130; grep -n "routing.route_ticket(" services/service_desk/ticket_service.py prints five call sites: two create triggers (create_ticket at line 360, create_ticket_from_email at line 519), type_change (1053), class_change (1062), rerun (1102, inside rerun_routing).
  4. grep -n "DESK_ROUTING\|ROUTING_" core/config.py | wc -l prints 0, confirming routing carries no dedicated environment configuration.
  5. With the app running against a workspace with an empty routing document: 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": null and "routing_events" an array of exactly one object whose "matched" is false.
  6. grep -n 'trigger in ("create", "type_change")' services/service_desk/routing.py prints exactly one line, 185 (the set_class gate), confirming class_change is not in the tuple set_class is 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.