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 |
|
| 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).
| Route | Auth dep | Tenancy | Codes | Notes |
|---|---|---|---|---|
POST /tickets (256-264) | DeskMember | WorkspaceID header, X-Workspace-ID derived, never a body field | 201, 400 unknown_type/unknown_class/invalid_fields/assignee_not_agent, 403 viewer_readonly | create_ticket; agent-only fields (requester_email, class_key, assignee_id) apply only when role == "agent" |
GET /tickets/{id} (267-272) | DeskMember | workspace-scoped get_ticket | 200, 404 ticket_not_found | non-agent view strips internal messages/attachments/routing/time (serializers.detail 134-177) |
PATCH /tickets/{id} (275-281) | DeskAgent | workspace-scoped | 200, 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) | DeskAgent | workspace-scoped | 200, 404, 409 already_assigned/invalid_transition | accept_ticket |
POST /tickets/{id}/status (293-303) | DeskMember | workspace-scoped | 200, 400 message_required/reason_required, 403 viewer_readonly/employee_only/requester_only, 404, 409 invalid_transition/fields_incomplete/review_pending | change_status; the whole state machine, Application logic |
POST /tickets/{id}/assign (306-317) | DeskAgent | workspace-scoped | 200, 400 assignee_not_agent, 404 | assign_ticket; status unchanged |
POST /tickets/{id}/route (320-326) | DeskAgent | workspace-scoped | 200, 404 | rerun_routing, a different recipe's engine |
POST /tickets/{id}/messages (329-339) | DeskMember | workspace-scoped | 201, 403 viewer_readonly/internal_notes_agents_only | add_message |
GET /tickets/{id}/events (342-348) | DeskMember | workspace-scoped | 200, 404 | full DeskStatusEvent history, both roles |
POST /uploads, POST /tickets/{id}/attachments (494-518) | DeskMember | workspace-scoped | 201, 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) | DeskMember | workspace-scoped | 200/204, 403 attachment_forbidden, 404 attachment_not_found | download 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) | DeskAgent | workspace-scoped | 201/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:
- Resolve the task type and class (
ds.task_type,ds.class_cfg); an agent may overrideclass_key, a requester cannot (275-278). validate_fieldsagainst the payload; any hard error (unknown key, wrong type) is a 400invalid_fieldsBEFORE the ticket row exists (282-284).- Resolve
requester_id/requester_email/acting_email(286-309): an agent namingrequester_emailcreates an email-only requester with norequester_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). next_ticket_number(atomic, Application logic below) mints the number and key; the row is built withstatus="new",first_response_duefromsla.compute_first_response_due,capacity_monthfromzurich_month(now)(311-321).- Agent-supplied
assignee_idis validated againstagent_user_idsbefore it is written (322-330). - An effort estimate is attempted (skipped outright for class
k1, fail-open otherwise, 335-344) - this is the ONLY placeestimated_minutesis set at creation. db.add(ticket); db.flush()(345-346, id now exists) then the firstDeskMessage(direction="in",channel="portal") is added and flushed (348-354), THENbind_attachmentsruns against that message (355), THENrecompute_fields(356), THEN the singleDeskStatusEvent(from_status=None, to_status="new", reason"created", 357-358).routing.route_ticket(..., "create", ...)assigns/classifies via the routing engine (a neighbouring recipe) and flushes (360-362).- Effects queued, not run:
mail_ticket_createdand anew_ticketfan-out torouting.notify_recipients(revent),ignore_quiet_hoursforced true forclass_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_acceptwith_accept_reason(803-806).in_progress: five distinct sources, five distinct rules (809-858) -new/acceptedrequireemployee_only()ANDrecompute_fieldsreporting complete (409fields_incompleteotherwise, 810-814), and record whoever calls this as the assignee, markingreason="takeover"when a DIFFERENT employee starts an already-accepted ticket (820-829,accepted_at/first_response_atare never touched here - only_do_acceptsets them, so the response clock stays with whoever answered first);waiting_on_client → in_progressisemployee_only()plus_leave_waiting(830-833);solved → in_progressis an employee-initiated rework, reason"rework"(834-836);client_review → in_progresssplits by role - an employee reworking needs no message, a requester rejecting (requester_only()) MUST supply one (require_message()), which is stored as an inboundDeskMessageand the event reason is fixed to"client_rejected"regardless of any reason the caller passed (837-851);done → in_progressis a reopen, employee-only, clearsdone_at(852-855).waiting_on_client: employee-only, only fromin_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 viaestimation.add_rueckfrage_minutes(861-874).solved: employee-only, only fromin_progress. With no message, the ticket simply becomessolved(877-883). With a non-emptymessage, the SAME call also writes a SECOND event (solved → client_review,actor=None,actor_role=ACTOR_SYSTEM, reason"handover") and lands the ticket onclient_reviewin 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 fromsolved,require_message()mandatory (this is the explicit handover path whensolvedwas called without one) (901-919).done: only fromclient_review. An employee may only close it afterreview_deadline(client_review_started_at +ds.review_timeout_working_daysworking days) has passed - 409review_pendingwith 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 emitsclient_review_done(930-933). Either way,_book_estimate_on_doneruns (936-937, Application logic below).cancelled: an employee may cancel from ANY non-terminal status but MUST supply areason(400reason_requiredotherwise, 947-950); a requester may cancel only fromnew/acceptedand only as the owner (requester_only(), 951-954). Cancelling out ofwaiting_on_clientcloses 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
DeskStatusEventin the SAME transaction as theDeskTicket.statuswrite, with no code path that updatesstatuswithout also calling_event/writing one (change_status/_do_accept/append_inbound_email/auto_ask_client, allticket_service.py; the whole 8×8 pair matrix is exercised bytest_every_status_pair_for_both_roles, which also asserts a REFUSED transition changes nothing on the row,test_desk_lifecycle.py1065-1091). - A refusal is a
HTTPExceptionraised before any write, never a partial state (_conflict, every guard inchange_statusraises before touchingticket.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.py61-84, catches per-mail and per-emit exceptions and logs rather than raising;service_desk_router.py:_runcommits FIRST,effects.run(db)SECOND). - The requester-owner check for Abnahme/Nachbesserung/Storno is decided by the ACTING address, never by
requester_idalone, 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_itintest_desk_acting_email.py). accepted_atandfirst_response_atare written exactly once, by whichever code path answers first (_do_accept'sif ... is Noneguards, 647-650; the SAME two columns are read, never rewritten, by a takeover inchange_status(to="in_progress"), 820-829;test_another_employee_takes_over_by_starting_the_workasserts both stay put across a takeover).- The estimate-to-capacity booking on
donenever 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 beyondtest_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'sUPDATE ... RETURNING, notMAX()+1; not exercised by a concurrency test in the cited files - the billing-credits recipe'sthreading.Barrierpattern 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_acceptno longer queues one (its own comment atticket_service.py:653-658says 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_addressfails withTypeError: DeskSettings.__init__() missing 1 required positional argument: 'client_brief'- the test constructs a bareDeskSettings(...)and has not been updated for theclient_brief: strfieldconfig_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_acceptdeliberately no longer sends an acceptance email - the comment atticket_service.py:653-658states the desk comms policy is "the client is emailed only when directly addressed" and that the oldnotifications.mail_acceptedcall was intentionally removed.notifications.mail_accepted(notifications.py:116) is now dead code: nothing inticket_service.pycalls it.test_desk_lifecycle.py::test_accept_sets_assignee_and_stops_the_response_clockwas 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_addressconstructs aDeskSettings(...)missing theclient_briefpositional argumentconfig_service.pyrequires (line 83) and raisesTypeErrorbefore any assertion runs - a test/constructor drift unrelated to the_requester_ownslogic 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 existerrors 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 inticket_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 withservice-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 forcompute_first_response_due/sla_infoas 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 thecarried_over:reason-string contractpatch_ticketwrites 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 fromcreate_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 fordesk_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 singlenow()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), andtests/desk_support.pyfor 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).
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:cacheproviderprints1 failed, 40 passed(the one known failure istest_accept_sets_assignee_and_stops_the_response_clock, Gotchas).- The same command against
tests/test_desk_sla.pyprints12 passed; againsttests/test_desk_capacity.pyprints4 passed; againsttests/test_desk_agency.pyprints7 passed; againsttests/test_desk_acting_email.pyprints1 failed, 42 passed(the one known failure istest_requester_owns_is_decided_by_the_acting_address, Gotchas). grep -n "^ if to == \|^ elif to == " services/service_desk/ticket_service.py | grep -c 'to == "'prints7(the seven named target-status branches insidechange_status:accepted,in_progress,waiting_on_client,solved,client_review,done,cancelled).grep -n "^def _do_accept" -A 20 services/service_desk/ticket_service.py | grep -c "mail_accepted(db"prints0(the function's own comment mentionsmail_acceptedby name at line 657, but never calls it - searching for the call formmail_accepted(dbrather than the bare name confirms no call, Gotchas).grep -n "class TERMINAL_STATUSES\|^TERMINAL_STATUSES" models/service_desk_model.pyshowsTERMINAL_STATUSES = ("done", "cancelled")at line 51.grep -n "UPDATE desk_sequences SET next_number" services/service_desk/numbering.pyshows the atomicRETURNING next_number - 1claim, 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.