Prima Get an API key

Module kit / Service desk

Ticket Lifecycle

service-desk/ticket-lifecycle

Kind backend
Domain service-desk
Maturity prod
Reusable extract
Stack fastapi-0.118, sqlalchemy-2.0-sync, postgres, pydantic-2.12, starlette-0.48
Depends on _shared/fastapi-project-layout, _shared/workspace-scoping, _shared/error-envelope, _shared/alembic-migrations, _shared/test-layout
Frontends service-desk/ui/ticket-detail
Reference implementation
  • alchemy_labs/backend/services/service_desk/ticket_service.py
  • alchemy_labs/backend/services/service_desk/sla.py
  • alchemy_labs/backend/services/service_desk/capacity.py
  • alchemy_labs/backend/services/service_desk/numbering.py
  • alchemy_labs/backend/services/service_desk/task_types.py
  • alchemy_labs/backend/services/service_desk/serializers.py
  • alchemy_labs/backend/api/routers/service_desk_router.py
Measured on alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12
Version 1

Purpose

A desk ticket is created, read, listed and driven through its status state machine (new → accepted → in_progress → waiting_on_client → solved → client_review → done/cancelled, models/service_desk_model.py 41-51) by two actors, an employee agent and the requester who asked for the work, with messages, attachments and time entries attached to it along the way. The invariant that makes the history trustworthy is that every transition writes exactly one DeskStatusEvent row (from_status, to_status, actor_role, acting_email, reason, occurred_at) in the SAME commit as the ticket's own status column, and any mail or employee fan-out that transition owes is collected into a separate Effects object and run only AFTER that commit has landed (ticket_service.py 1-8, 44-84, service_desk_router.py:_run 115-117): a ticket's status is never a silent write, and a mail that fails to send never rolls back the state change it was reporting on.

When to use

Read it before touching create_ticket, create_ticket_from_email, accept_ticket/_do_accept, change_status, add_message, patch_ticket, bind_attachments, add_time_entry, or the shape of the Effects collector; before changing what counts as a legal (from_status, to_status, actor_role) triple; and when someone asks "why didn't the client get an email", "why can't the requester see this ticket", "why is fields_complete false", or "who accepted this and when". Trigger phrases: "ticket lifecycle", "desk ticket", "accept a ticket", "waiting_on_client", "client_review", "Rückfrage", "Abnahme", "Nachbesserung", "DeskStatusEvent", "acting_email", "fields_complete".

Not this recipe: routing (which agent/class a new or reclassified ticket lands on) lives in services/service_desk/routing.py and is called FROM create_ticket/patch_ticket (route_ticket, ticket_service.py:360-362, 519-521, 1053, 1062) but owned by a neighbouring recipe; the SLA due-date math (sla.compute_first_response_due, sla_info) is a separate pure-function recipe this one only calls; the fan-out/mail wording itself (services/service_desk/notifications.py, desk_notification.py) is a separate recipe, this one only queues effects.mail(...)/effects.emit(...) calls into it; email-intake threading (create_ticket_from_email, append_inbound_email, auto_ask_client, the IMAP poll) is documented in service-desk/email-intake-channel, cited here only where the lifecycle functions it shares (_leave_waiting, the waiting_on_client → in_progress transition) are the same code; workspace configuration (WorkspaceConfig, task types, classes) is DeskSettings, read here but owned elsewhere; capacity/time-booking aggregation beyond the single add_time_entry/delete_time_entry calls (capacity.py's capacity_month) is a separate recipe.

Stack assumptions

At 62ba4fc: fastapi==0.118.2, synchronous handlers on sqlalchemy.orm.Session (SQLAlchemy==2.0.43), pydantic==2.12.0 for every request/response schema, psycopg2-binary==2.9.10, Postgres (15 in prod, 14 in the test harness per the billing-credits recipe's own measurement of the same docker-compose.*.yml files; not re-verified here). Foundations assumed: S4 (fastapi-project-layout) for the app and mount_router (the desk router is mounted twice, /api/desk and a bare config_router for /api/workspaces/{id}/config*, service_desk_router.py 72-73, app_fastapi.py 829-831); S6 (workspace-scoping) for WorkspaceID/DatabaseSession/ActingEmail (api/dependencies.py, imported service_desk_router.py:28); S7 (error-envelope) for the HTTPException(status_code, detail={"error": ...}) shape every refusal in this file uses; S8 (alembic-migrations) for the 5 chained migrations under Deploy & ops; S9 (test-layout) for the Postgres-backed pytest harness under Tests.

Every desk timestamp column is DateTime(timezone=True) and the service layer takes "now" from one seam, services.service_desk.clock.now() (always aware UTC, clock.py 10-11), specifically so a test can freeze or advance it with one monkeypatch.setattr(clock, "now", ...) (module docstring, clock.py 1-6) - nothing in this recipe reads a naive datetime.utcnow(). No dedicated environment variable gates this recipe; access is gated entirely by desk_role() (Security & tenancy) and by workspace membership, not by a feature flag or a credential.

Data model

desk_tickets, one row per ticket, the timed lifecycle denormalised onto it (models/service_desk_model.py 129-202):

desk_tickets (
  id                     uuid PRIMARY KEY,
  workspace_id           uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  number                 integer NOT NULL,               -- per-workspace, from desk_sequences
  ticket_key             varchar(20) NOT NULL,            -- "<prefix>-<number:04d>"
  title                  varchar(255) NOT NULL,
  type_slug              varchar(50) NOT NULL,
  class_key              varchar(20) NOT NULL,
  status                 varchar(30) NOT NULL DEFAULT 'new',
  blocked_by_us          boolean NOT NULL DEFAULT false,
  requester_id           uuid REFERENCES users(id) ON DELETE SET NULL,   -- WHICH ACCOUNT ordered the work
  requester_email        varchar(255) NOT NULL,
  requester_is_designated boolean NOT NULL DEFAULT false,
  acting_email           varchar(320),                    -- WHICH HUMAN, under a shared account
  assignee_id            uuid REFERENCES users(id) ON DELETE SET NULL,
  assigned_by            varchar(10),                      -- accept | routing | manual
  received_at            timestamptz NOT NULL,
  accepted_at            timestamptz, started_at timestamptz, solved_at timestamptz,
  client_review_started_at timestamptz, done_at timestamptz,
  client_wait_seconds    integer NOT NULL DEFAULT 0,
  waiting_since          timestamptz,
  first_response_due     timestamptz NOT NULL, first_response_at timestamptz,
  capacity_month         varchar(7) NOT NULL, carried_from_month varchar(7),
  pipeline_key           varchar(50), current_step varchar(50),
  fields                 jsonb NOT NULL DEFAULT '{}', fields_complete boolean NOT NULL DEFAULT false,
  intake_check           jsonb, ai_category varchar(50),
  estimated_minutes      integer,
  links                  jsonb NOT NULL DEFAULT '{}', tags jsonb NOT NULL DEFAULT '[]',
  created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()
);
UNIQUE (workspace_id, number)
CHECK ck_desk_tickets_status: status IN ('new','accepted','in_progress','waiting_on_client','solved','client_review','done','cancelled')

desk_status_events (append-only, models/service_desk_model.py 235-254): from_status (nullable, null only on the create row), to_status, actor_id (FK users, SET NULL), actor_role (employee/requester/system), acting_email, reason, occurred_at. desk_messages (205-232): direction (in/out), channel (portal/email/button/system), is_internal, is_decision, acting_email, email_sent_at. desk_attachments (306-337): ticket_id nullable (a pending upload has none yet), message_id (SET NULL), field_key, file_id (dsk_<uuid>.<ext>, bytes in render storage, never in Postgres), uploaded_by. desk_time_entries (280-303): hours Numeric(5,2) with CHECK(hours > 0 AND hours <= 24), month (YYYY-MM, string-derived from date, not from capacity_month), billable_to_capacity. desk_sequences (116-126): one row per workspace, next_number, advanced by next_ticket_number's own UPDATE ... RETURNING (Application logic).

What is NOT stored: a PSP or payment artifact of any kind (out of scope for this domain); the actual file bytes of an attachment (render storage, keyed by file_id); a second timestamp for a transition that never happened (every lifecycle timestamp column is nullable and stays null until its own transition sets it, serializers.timings_wall reads exactly these nulls to report None rather than a fabricated duration, metrics.py 26-39).

API surface

All under /api/desk unless noted; member/agent/employee name the FastAPI dependency, not a claim about the caller (service_desk_router.py 80-113).

RouteAuth depTenancyCodesNotes
POST /tickets (256-264)DeskMemberWorkspaceID header, X-Workspace-ID derived, never a body field201, 400 unknown_type/unknown_class/invalid_fields/assignee_not_agent, 403 viewer_readonlycreate_ticket; agent-only fields (requester_email, class_key, assignee_id) apply only when role == "agent"
GET /tickets/{id} (267-272)DeskMemberworkspace-scoped get_ticket200, 404 ticket_not_foundnon-agent view strips internal messages/attachments/routing/time (serializers.detail 134-177)
PATCH /tickets/{id} (275-281)DeskAgentworkspace-scoped200, 400 invalid_fields/unknown_type/unknown_class/assignee_not_agent, 404, 409 invalid_transition (only for blocked_by_us on a terminal ticket)patch_ticket; agent-only route
POST /tickets/{id}/accept (284-290)DeskAgentworkspace-scoped200, 404, 409 already_assigned/invalid_transitionaccept_ticket
POST /tickets/{id}/status (293-303)DeskMemberworkspace-scoped200, 400 message_required/reason_required, 403 viewer_readonly/employee_only/requester_only, 404, 409 invalid_transition/fields_incomplete/review_pendingchange_status; the whole state machine, Application logic
POST /tickets/{id}/assign (306-317)DeskAgentworkspace-scoped200, 400 assignee_not_agent, 404assign_ticket; status unchanged
POST /tickets/{id}/route (320-326)DeskAgentworkspace-scoped200, 404rerun_routing, a different recipe's engine
POST /tickets/{id}/messages (329-339)DeskMemberworkspace-scoped201, 403 viewer_readonly/internal_notes_agents_onlyadd_message
GET /tickets/{id}/events (342-348)DeskMemberworkspace-scoped200, 404full DeskStatusEvent history, both roles
POST /uploads, POST /tickets/{id}/attachments (494-518)DeskMemberworkspace-scoped201, 400 file_type_not_allowed/file_too_large/file_empty/invalid_field_key/unknown_file_field, 403 viewer_readonly, 500 storage_failed_store_upload; bind_attachments happens at message/ticket creation, not here
GET/DELETE /tickets/{id}/attachments/{aid} (521-580)DeskMemberworkspace-scoped200/204, 403 attachment_forbidden, 404 attachment_not_founddownload strips internal-message attachments for non-agents; delete allowed for the uploading requester only while status == "new"
POST /tickets/{id}/time (737-742), DELETE /time/{id} (745-750)DeskAgentworkspace-scoped201/204, 404, 422 (Pydantic Decimal bounds, Tests)add_time_entry/delete_time_entry

Every route above returns TicketDetail (via _detail, service_desk_router.py 120-122) except the message/event/attachment/time endpoints, which return their own *Out shape (serializers.py).

Application logic

create_ticket (ticket_service.py 265-379), in commit order:

  1. Resolve the task type and class (ds.task_type, ds.class_cfg); an agent may override class_key, a requester cannot (275-278).
  2. validate_fields against the payload; any hard error (unknown key, wrong type) is a 400 invalid_fields BEFORE the ticket row exists (282-284).
  3. Resolve requester_id/requester_email/acting_email (286-309): an agent naming requester_email creates an email-only requester with no requester_id; otherwise the requester is the acting address, resolved to an account only if it is an active member of THIS workspace (never a platform-wide lookup, 299-309).
  4. next_ticket_number (atomic, Application logic below) mints the number and key; the row is built with status="new", first_response_due from sla.compute_first_response_due, capacity_month from zurich_month(now) (311-321).
  5. Agent-supplied assignee_id is validated against agent_user_ids before it is written (322-330).
  6. An effort estimate is attempted (skipped outright for class k1, fail-open otherwise, 335-344) - this is the ONLY place estimated_minutes is set at creation.
  7. db.add(ticket); db.flush() (345-346, id now exists) then the first DeskMessage (direction="in", channel="portal") is added and flushed (348-354), THEN bind_attachments runs against that message (355), THEN recompute_fields (356), THEN the single DeskStatusEvent (from_status=None, to_status="new", reason "created", 357-358).
  8. routing.route_ticket(..., "create", ...) assigns/classifies via the routing engine (a neighbouring recipe) and flushes (360-362).
  9. Effects queued, not run: mail_ticket_created and a new_ticket fan-out to routing.notify_recipients(revent), ignore_quiet_hours forced true for class_key == "k1" (371-378). The router commits first, then runs these (service_desk_router.py:_run).

_do_accept/accept_ticket/change_status(to="accepted") (624-669, 802-806): _accept_reason decides which of three outcomes applies - new → reason "accept"; accepted with no assignee (cleared by PATCH clear_assignee) → "reaccept", back in the takeable queue; accepted held by someone else → 409 already_assigned (624-639). _do_accept sets assignee_id/assigned_by="accept", sets accepted_at and first_response_at ONLY if still null (idempotent across re-accept and takeover, 642-650), writes the one DeskStatusEvent, sets status="accepted". No mail is queued on accept - the code comment is explicit that this was removed on purpose: accepting is a status update the requester sees in the portal, not a message addressed to them, and the desk comms policy only mails a client when directly addressed (653-658; notifications.mail_accepted still exists at notifications.py:116 but nothing in this file calls it - Gotchas, Tests).

change_status (771-974) is the state machine, one function, one Effects, one row lock (get_ticket(..., for_update=True), 775). Guard order: a viewer actor is refused up front (781-782); a ticket already in a terminal status refuses every transition except done → in_progress (reopen, 783-784, the sole exception TERMINAL_STATUSES allows). Per target status:

  • accepted: employee-only, delegates to _do_accept with _accept_reason (803-806).
  • in_progress: five distinct sources, five distinct rules (809-858) - new/accepted require employee_only() AND recompute_fields reporting complete (409 fields_incomplete otherwise, 810-814), and record whoever calls this as the assignee, marking reason="takeover" when a DIFFERENT employee starts an already-accepted ticket (820-829, accepted_at/first_response_at are never touched here - only _do_accept sets them, so the response clock stays with whoever answered first); waiting_on_client → in_progress is employee_only() plus _leave_waiting (830-833); solved → in_progress is an employee-initiated rework, reason "rework" (834-836); client_review → in_progress splits by role - an employee reworking needs no message, a requester rejecting (requester_only()) MUST supply one (require_message()), which is stored as an inbound DeskMessage and the event reason is fixed to "client_rejected" regardless of any reason the caller passed (837-851); done → in_progress is a reopen, employee-only, clears done_at (852-855).
  • waiting_on_client: employee-only, only from in_progress, require_message(), the message is sent OUTBOUND (_out_message) and mailed as a question (is_question=True), waiting_since=now, and the estimate grows by one "Rückfrage" round via estimation.add_rueckfrage_minutes (861-874).
  • solved: employee-only, only from in_progress. With no message, the ticket simply becomes solved (877-883). With a non-empty message, the SAME call also writes a SECOND event (solved → client_review, actor=None, actor_role=ACTOR_SYSTEM, reason "handover") and lands the ticket on client_review in one request - the API never puts a caller through two round trips to hand work back with a note (884-899).
  • client_review: employee-only, only from solved, require_message() mandatory (this is the explicit handover path when solved was called without one) (901-919).
  • done: only from client_review. An employee may only close it after review_deadline (client_review_started_at + ds.review_timeout_working_days working days) has passed - 409 review_pending with the deadline echoed back otherwise (922-929); a requester (requester_only()) may close it any time, reason "accepted_by_client", and this is the only role/target pair that emits client_review_done (930-933). Either way, _book_estimate_on_done runs (936-937, Application logic below).
  • cancelled: an employee may cancel from ANY non-terminal status but MUST supply a reason (400 reason_required otherwise, 947-950); a requester may cancel only from new/accepted and only as the owner (requester_only(), 951-954). Cancelling out of waiting_on_client closes the wait ledger first (_leave_waiting, 955-956) so the client-wait seconds are never lost to a status change.
  • Every branch ends through the same _touch(ticket, now); db.flush() (972-973) - there is no second write path that could skip the event.

_book_estimate_on_done (171-206): no-op without an estimated_minutes or a resolvable user id; idempotent by checking for an existing DeskTimeEntry with note == "auto: geschätzter Aufwand" on the ticket (so reopening and re-completing a ticket never double-books, 183-187); the booked hours are min(round(estimated_minutes/60, 2), 24.0), clamped rather than crashing on the per-entry CHECK(hours <= 24) (188-200, a warning is logged when the estimate itself exceeded 24h and got clamped).

add_message (982-1018): a viewer is refused up front. An agent posts an OUTBOUND message (internal notes allowed, is_decision allowed) and, if not internal, queues the mail (is_question set to whether the ticket is currently waiting_on_client, 990-996). A requester posts an INBOUND message, may never set is_internal (403 internal_notes_agents_only, 998-999), and if the ticket was waiting_on_client the reply ends the wait and moves the ticket to in_progress via the SAME system-actor event pattern append_inbound_email uses (_leave_waiting + one DeskStatusEvent with actor_role=ACTOR_SYSTEM, reason "client_replied", 1008-1012) - a client's own reply is itself a state transition, not just a row in the thread. Either branch calls bind_attachments after the message is flushed, then recompute_fields.

bind_attachments (241-258): a referenced attachment must exist in this workspace, must not already belong to ANOTHER ticket, and must have been uploaded by the calling actor - otherwise 400 attachment_not_bindable. Already belonging to THIS ticket is accepted (idempotent for the composer's own upload-then-send flow, 246-250).

next_ticket_number (numbering.py 10-26): an INSERT ... ON CONFLICT DO NOTHING followed by UPDATE desk_sequences SET next_number = next_number + 1 ... RETURNING next_number - 1, inside the caller's own transaction - the row lock implicit in the UPDATE is what serialises two concurrent creates, not a MAX(number)+1 read (module docstring, line 1).

patch_ticket (1026-1097): every field is independently optional (exclude_unset); type_slug/class_key changes re-run routing (route_ticket(..., "type_change"/"class_change", ...), 1053, 1062) and a class_key change recomputes first_response_due from received_at ONLY if the ticket has not yet had a first response (1060-1061, an SLA promise already met is never rewritten under it); clear_assignee takes priority over an explicit assignee_id in the same payload (1064-1073); capacity_month writes a carried_from_month and a status-unchanged DeskStatusEvent with reason "carried_over:<old>><new>" (1081-1085, this is the literal string capacity.py's _carry_counts parses back out); blocked_by_us refuses on a terminal ticket (1087-1090).

Invariants

  • Every status change is exactly one committed DeskStatusEvent in the SAME transaction as the DeskTicket.status write, with no code path that updates status without also calling _event/writing one (change_status/_do_accept/append_inbound_email/auto_ask_client, all ticket_service.py; the whole 8×8 pair matrix is exercised by test_every_status_pair_for_both_roles, which also asserts a REFUSED transition changes nothing on the row, test_desk_lifecycle.py 1065-1091).
  • A refusal is a HTTPException raised before any write, never a partial state (_conflict, every guard in change_status raises before touching ticket.status; same test).
  • A mail or employee-fan-out effect never runs inside the same transaction as the state change it reports, and a failed effect never undoes the commit (Effects.run, ticket_service.py 61-84, catches per-mail and per-emit exceptions and logs rather than raising; service_desk_router.py:_run commits FIRST, effects.run(db) SECOND).
  • The requester-owner check for Abnahme/Nachbesserung/Storno is decided by the ACTING address, never by requester_id alone, so a shared practice account's colleagues each own the same ticket (_requester_owns, 742-762; test_abnahme_and_nachbesserung_belong_to_the_requester, test_a_colleague_on_the_same_account_does_not_own_it in test_desk_acting_email.py).
  • accepted_at and first_response_at are written exactly once, by whichever code path answers first (_do_accept's if ... is None guards, 647-650; the SAME two columns are read, never rewritten, by a takeover in change_status(to="in_progress"), 820-829; test_another_employee_takes_over_by_starting_the_work asserts both stay put across a takeover).
  • The estimate-to-capacity booking on done never double-books a reopened-and-redone ticket (_book_estimate_on_done's note-based idempotency check, 183-187; not separately covered by a named test in the cited files beyond test_high_estimate_completion_caps_autobook_at_24h, which covers the clamp, not the reopen path - a genuine gap, see Tests).
  • Ticket numbering never races: two concurrent creates in the same workspace get two different, gapless-under-lock numbers (next_ticket_number's UPDATE ... RETURNING, not MAX()+1; not exercised by a concurrency test in the cited files - the billing-credits recipe's threading.Barrier pattern is the nearest measured precedent for this KIND of claim elsewhere in the codebase, not for this table).

Frontend

service-desk/ui/ticket-detail   the single surface that renders a DeskTicket end to end - status
                                  actions gated by role and by the legal-transition matrix above,
                                  the message thread (internal notes visible only to agents), the
                                  attachment list, and the agent-only time-entry panel; folder
                                  already exists at modules/service-desk/ui/ticket-detail/ (another
                                  in-flight recipe), not authored by this one

Minimum admin/debug surface used while working a ticket: GET /api/desk/tickets/{id}/events (full status history, both roles) and GET /api/desk/tickets/{id} with TicketDetail.missing_fields (from serializers.detail's own validate_fields call, 160-161) for "why is this still not startable". No dedicated internal-only route exists beyond what DeskAgent already sees on the normal detail response.

Security & tenancy

Auth dependency: DeskMember (_member_with_role, 80-84) resolves desk_role(db, user, workspace_id) - agent (platform admin, OR an active member/owner listed in the workspace's own desk.agents config array), viewer, requester, or None → 403 workspace_forbidden (roles.py 63-76). DeskAgent additionally 403s agent_required for anyone else (87-90). Every ticket lookup filters DeskTicket.workspace_id == workspace_id in the SAME query as id == ticket_id (get_ticket, ticket_service.py 105-115) - a ticket from another workspace is a 404, not a 403, deliberately (comment: a stale portal link should read as "not found", never leak that the id exists elsewhere, 110-114). Workspace derivation is the WorkspaceID dependency (S6), never a body field.

Fail direction: closed. A viewer role is refused on every mutating endpoint it can reach (change_status, add_message) before any other guard runs (781-782, 985-986); desk_role() is None 403s before the route body executes; a non-agent's PATCH/accept/time routes are unreachable at the dependency layer (DeskAgent), not by an in-function check. _requester_owns fails closed too: an unrecognised acting address is neither the requester nor a designated contact and is refused (742-762).

Secrets/PII at rest: none of this recipe's own tables hold a credential of any kind; acting_email and requester_email are PII (a real address) but are the mechanism the whole shared-account ownership model depends on, not incidental - they are never logged in this file beyond the logger.warning calls on effect failures, which log the exception, not the row (61-84). Rate limits: none in this file; the intake-check endpoint (a different concern) has its own (service_desk_router.py:361-362), not this one. Attacker-controlled inputs: every TicketCreate/TicketPatch/StatusChange/MessageCreate/TimeEntryCreate field is Pydantic-typed and bounded (schemas/service_desk_schemas.py 79-131) - TimeEntryCreate.hours is Decimal, ge=0.01, le=24, decimal_places=2, max_digits=4 specifically because the column is Numeric(5,2) and a finer value used to round to 0.00 and trip the CHECK(hours > 0) as a 500 in Postgres, not a 422 in FastAPI (schema comment, 126-129; test below).

Tests

Run 2026-09-12 against the scratch Postgres at postgresql://test_user:test_password@localhost:5434/test_db (already running as the postgres-test service; TEST_DATABASE_URL env var), venv alchemy_labs/backend/.venv, on main at 62ba4fc:

TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/test_db \
    python -m pytest tests/test_desk_lifecycle.py tests/test_desk_sla.py tests/test_desk_capacity.py \
    tests/test_desk_acting_email.py tests/test_desk_agency.py -q --no-cov -o addopts="" -p no:cacheprovider

Run per file (the shared scratch database is also being written by other agents' test runs concurrently while this recipe was authored, which produced transient relation "users" does not exist errors on a combined run - an environment race, not a defect in this code; per-file runs below are clean, pytest --collect-only figures confirmed separately): test_desk_sla.py 12 passed (pure functions, no DB); test_desk_capacity.py 4 passed; test_desk_agency.py 7 passed; test_desk_acting_email.py 43 collected, 42 passed, 1 failed (27 def test_, one @pytest.mark.parametrize at line 362 expanding to 16 cases); test_desk_lifecycle.py 41 collected, 40 passed, 1 failed (40 def test_, one @pytest.mark.parametrize on test_every_status_pair_for_both_roles expanding role to 2 cases). Total collected across the five files: 12+4+7+43+41 = 107.

Two genuine, reproducible-in-isolation failures at this commit, both cited under Gotchas:

  • test_desk_lifecycle.py::test_accept_sets_assignee_and_stops_the_response_clock (line 277) asserts an "Angenommen" acceptance email was sent; _do_accept no longer queues one (its own comment at ticket_service.py:653-658 says the mail was "intentionally dropped"). desk.mail[-1] at assertion time is still the ticket-creation mail.
  • test_desk_acting_email.py::test_requester_owns_is_decided_by_the_acting_address fails with TypeError: DeskSettings.__init__() missing 1 required positional argument: 'client_brief' - the test constructs a bare DeskSettings(...) and has not been updated for the client_brief: str field config_service.py (line 83) added.

Hermetic vs live: fully hermetic, Postgres-backed (real transactions, real constraints - e.g. test_time_entry_precision_matches_the_column relies on the real Numeric(5,2) column rounding), no external network call anywhere in this recipe's surface (mail and the employee fan-out are captured by the mail_log/emit_log fixtures, test_desk_lifecycle.py:22-38, never actually sent). Known-answer vectors: the full 8×8 _STATES × _STATES transition table is written out literally as _AGENT_MATRIX/_REQUESTER_MATRIX (test_desk_lifecycle.py 1006-1033) and walked by test_every_status_pair_for_both_roles[agent|requester], asserting the exact HTTP code (200/403/409) and, on a 409, that the row is untouched. test_time_entry_precision_matches_the_column (1170-1186) is a known-answer vector for the Decimal rounding boundary (0.004→422, 0.01→201, 24→201, 24.01→422).

What the cited test files do NOT cover, measured by absence: no concurrency test for next_ticket_number (unlike billing-credits' test_two_concurrent_deliveries_of_one_crossing_charge_once pattern - a genuine gap, noted under Invariants); no test exercising _book_estimate_on_done's reopen-idempotency path specifically (only the 24h clamp is covered, test_desk_capacity.py::test_high_estimate_completion_caps_autobook_at_24h).

Deploy & ops

Config keys: none dedicated to this recipe (no env var gates ticket creation or the state machine; workspace-level behaviour comes from the WorkspaceConfig JSON document, a different recipe's surface). Migrations, in chain order (backend/migrations/versions/): 20260904_0001_service_desk_core.py (creates desk_tickets, desk_messages, desk_status_events, desk_routing_events, desk_time_entries, desk_attachments, desk_sequences, workspace_configs, workspace_config_versions); 20260905_0001_desk_domain_login.py; 20260905_0002_desk_telegram_reply.py; 20260906_0001_desk_estimate.py (adds estimated_minutes); 20260906_0002_desk_email_ingest.py (adds desk_email_ingest, workspace_documents, desk_notification_deliveries per the model file). Scheduler: none - every transition in this recipe is synchronous, request-driven; there is no background job in ticket_service.py itself (compare the email-intake IMAP poll, a different recipe). Argus-style check: GET /api/desk/tickets/{id}/events on any known ticket id returns 200 with at least one event (to_status="new") - proves the router is mounted and the table is reachable. Rollback: no destructive migration in the chain (all additive); a desk_tickets row is never deleted by this recipe's code path (no DELETE on DeskTicket anywhere in ticket_service.py).

Gotchas

  • 2026-09-12 (measured at 62ba4fc): _do_accept deliberately no longer sends an acceptance email - the comment at ticket_service.py:653-658 states the desk comms policy is "the client is emailed only when directly addressed" and that the old notifications.mail_accepted call was intentionally removed. notifications.mail_accepted (notifications.py:116) is now dead code: nothing in ticket_service.py calls it. test_desk_lifecycle.py::test_accept_sets_assignee_and_stops_the_response_clock was not updated for this and fails at this commit (Tests) - a rebuild should follow the CODE's behaviour (no mail on accept), not the stale test's assertion.
  • 2026-09-12 (measured at 62ba4fc): test_desk_acting_email.py::test_requester_owns_is_decided_by_the_acting_address constructs a DeskSettings(...) missing the client_brief positional argument config_service.py requires (line 83) and raises TypeError before any assertion runs - a test/constructor drift unrelated to the _requester_owns logic it is meant to cover.
  • 2026-09-12 (measured at 62ba4fc): running the five cited test files together against the shared scratch Postgres, while other agents were concurrently authoring neighbouring recipes against the same database, produced transient psycopg2.errors.UndefinedTable: relation "users" does not exist errors partway through a run; the same files run individually pass cleanly except the two failures above. This is a shared-fixture/environment hazard for anyone running this suite against a non-dedicated scratch database, not a defect in ticket_service.py.
  • 2026-09-12 (measured at 62ba4fc): a ticket 404s (never 403) when it belongs to another workspace, even for an agent of the CALLER's own workspace (get_ticket, ticket_service.py:110-114) - a deliberate choice ("a requester on a stale link used to read English in a German portal") that a rebuild must not "fix" into a 403 without re-reading that comment.

Reference implementation

Paths relative to the repo root; all at main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.

  • alchemy_labs/backend/services/service_desk/ticket_service.py (1134 lines): module docstring (1-8); Effects (44-84); lookups incl. get_ticket/recompute_fields (105-139); bind_attachments (241-258); create_ticket (265-379); create_ticket_from_email/append_inbound_email/auto_ask_client (429-616, shared with service-desk/email-intake-channel); _accept_reason/_do_accept/accept_ticket (624-669); assign_ticket (672-693); extend_deadline (696-726); _leave_waiting/_requester_owns/review_deadline (734-768); change_status (771-974); add_message (982-1018); patch_ticket (1026-1097); rerun_routing (1100-1105); add_time_entry/delete_time_entry (1113-1135).
  • alchemy_labs/backend/services/service_desk/sla.py (89 lines, whole file in scope for compute_first_response_due/sla_info as called from this recipe, not as its own subject).
  • alchemy_labs/backend/services/service_desk/capacity.py (100 lines): _book_estimate_on_done's counterpart read path, capacity_month (45-100), cited for the carried_over: reason-string contract patch_ticket writes into.
  • alchemy_labs/backend/services/service_desk/numbering.py (30 lines, whole file): next_ticket_number (10-26), make_ticket_key (29-30).
  • alchemy_labs/backend/services/service_desk/task_types.py (99 lines, whole file): validate_fields (23-88), called from create_ticket, recompute_fields, patch_ticket.
  • alchemy_labs/backend/services/service_desk/serializers.py (177 lines): summary (51-83), detail (134-177).
  • alchemy_labs/backend/api/routers/service_desk_router.py (1171 lines): dependencies (80-117); ticket routes (229-348); attachment routes (494-580); time routes (737-750).
  • alchemy_labs/backend/models/service_desk_model.py (465 lines): TICKET_STATUSES/TERMINAL_STATUSES (41-51); DeskTicket (129-202); DeskMessage (205-232); DeskStatusEvent (235-254); DeskTimeEntry (280-303); DeskAttachment (306-337).
  • alchemy_labs/backend/schemas/service_desk_schemas.py: TicketCreate (79-89), TicketPatch (92-104), StatusChange (107-110), MessageCreate (117-121), TimeEntryCreate (124-131).
  • alchemy_labs/backend/services/service_desk/roles.py (whole file in scope for desk_role/agent_user_ids/active_member_ids, called throughout this recipe).
  • alchemy_labs/backend/services/service_desk/notifications.py: mail_ticket_created (95-115, called), mail_accepted (116-127, NOT called - Gotchas), mail_outbound_message/mail_client_review/mail_deadline_extended/mail_closed (128-197, called from the cited transitions).
  • alchemy_labs/backend/services/service_desk/clock.py (18 lines, whole file): the single now() seam.
  • alchemy_labs/backend/migrations/versions/: 20260904_0001_service_desk_core.py, 20260905_0001_desk_domain_login.py, 20260905_0002_desk_telegram_reply.py, 20260906_0001_desk_estimate.py, 20260906_0002_desk_email_ingest.py.
  • alchemy_labs/backend/app_fastapi.py: router mounts (829, 831).
  • Tests as listed under Tests: alchemy_labs/backend/tests/test_desk_lifecycle.py (1186 lines), test_desk_sla.py (127 lines), test_desk_capacity.py (139 lines), test_desk_acting_email.py (614 lines), test_desk_agency.py (199 lines), and tests/desk_support.py for the shared fixtures (desk, actors, frozen_clock, mail_log, emit_log, create_ticket, set_status).

Deviations of the reference from an ideal implementation, all dated 2026-09-12: notifications.mail_accepted is dead code kept in the file but never called, a rebuild should delete it rather than copy it; the acceptance-email test and one acting-email test are stale against the current code/schema (Gotchas); no concurrency test proves next_ticket_number under real contention the way the billing-credits recipe proves its own claim primitive; _book_estimate_on_done's reopen-idempotency branch has no dedicated test.

Acceptance checks

Run from alchemy_labs/backend at 62ba4fc with the scratch Postgres already migrated to head (postgres-test service on :5434, TEST_DATABASE_URL/DATABASE_URL/SYNC_DATABASE_URL pointed at it).

  1. TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/test_db python -m pytest tests/test_desk_lifecycle.py -q --no-cov -o addopts="" -p no:cacheprovider prints 1 failed, 40 passed (the one known failure is test_accept_sets_assignee_and_stops_the_response_clock, Gotchas).
  2. The same command against tests/test_desk_sla.py prints 12 passed; against tests/test_desk_capacity.py prints 4 passed; against tests/test_desk_agency.py prints 7 passed; against tests/test_desk_acting_email.py prints 1 failed, 42 passed (the one known failure is test_requester_owns_is_decided_by_the_acting_address, Gotchas).
  3. grep -n "^ if to == \|^ elif to == " services/service_desk/ticket_service.py | grep -c 'to == "' prints 7 (the seven named target-status branches inside change_status: accepted, in_progress, waiting_on_client, solved, client_review, done, cancelled).
  4. grep -n "^def _do_accept" -A 20 services/service_desk/ticket_service.py | grep -c "mail_accepted(db" prints 0 (the function's own comment mentions mail_accepted by name at line 657, but never calls it - searching for the call form mail_accepted(db rather than the bare name confirms no call, Gotchas).
  5. grep -n "class TERMINAL_STATUSES\|^TERMINAL_STATUSES" models/service_desk_model.py shows TERMINAL_STATUSES = ("done", "cancelled") at line 51.
  6. grep -n "UPDATE desk_sequences SET next_number" services/service_desk/numbering.py shows the atomic RETURNING next_number - 1 claim, line 21-22.

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.