# Support Support links each Pharos user to their customer in the support service (today: Zammad) and keeps their support tickets. Pharos records tickets and messages first and sends them to Zammad afterwards; Zammad's changes come back through a webhook and a re-read. The flows are modelled on the Miro board "Pharos Platform" (link, ticket and sync frames). Support follows decision 0046: the API reaches it over RabbitMQ, and every change is one command on one aggregate. Support answers only the callers its `contracts/support/messaging/broker.json` declares (the API's broker user, for queries and commands), checked on every request as Project and Party Governance do. ## Aggregates Each lives in `domain/`: a persisted state with its rules (`*State`) and an aggregate that records a fact for every change (`Snapshot()`, `Events()`). | Aggregate | Kind | Identity | States | |---|---|---|---| | `Customer` (SupportCustomer) | `support_customer` | Pharos user ID | `linkStatus` pending, linked, failed, conflict, awaiting_contact; `contactStatus` synced, syncing, failed, conflict (linked only) | | `Ticket` | `support_ticket` | chosen by the browser | `ticketStatus` open, closing, closed; `creationStatus` pending, delivered, failed (final); `withdrawn` | | `Message` (TicketMessage) | `support_ticket_message` | chosen by the browser; derived for opening messages and agent replies | `deliveryStatus` pending, sending (claimed), delivered, failed (final) | | `TicketSync` | `support_ticket_sync` | the ticket's ID | requests, imported Zammad articles, last Zammad time | - **Link**: `failed` means Zammad could not be reached and is retried after 15 minutes; `conflict` means a person must act (the email belongs to another Pharos user, or Zammad refused our data) and is retried after 24 hours. `awaiting_contact` means no verified email is known: Support searches and creates nothing, because Zammad customers are found and claimed by email and an address is never guessed. Nobody needs to act and the retry job skips it; a contact with an email resumes it (`pending`). A retry whose contact has lost its email waits the same way. `unresolvedSince` is kept across retries and waiting, so the 24-hour alert sees a link that never resolves. - **Contact** (`domain/customer_contact.go`): the link keeps the user's contact with its source revision. A newer, different contact is given to the linked customer (`contactStatus` syncing, then synced); an older or repeated revision changes nothing. A customer just linked is given the contact once, because it may have changed during linking or a found customer may hold older details. `contactStatus` is separate from the link: a failed update (retried after 15 minutes) or a conflict (an email another Zammad user holds, retried after 24 hours) never undoes the link, and tickets stay with the user and their customer; Zammad emails the new address from then on. A contact without a verified email leaves Zammad's email as it was. - **Ticket and Message** are separate aggregates (a business decision). Only a linked user opens a ticket; only its owner reads, answers or closes it. A message copies its ticket's owner, so its updates reach that user alone. - A result for something that is no longer waiting for it is stale and changes nothing, so every redelivery is harmless. - The Zammad user, ticket and article IDs are opaque bindings, never Pharos identities. Imported replies get message IDs derived from the ticket and their position, never from Zammad. ## Requests Contract: [`contracts/support/messaging`](../../../../contracts/support/messaging/README.md); browser operations: [`contracts/support/http_api`](../../../../contracts/support/http_api). The API's [mapping manifest](../../../api/adaptors/http/mappings/support.json) generates Support's HTTP request/response structs and Protobuf conversions. The handwritten bindings retain actor selection, command identity and HTTP outcomes; mapping verification rejects missing schemas and owner enrolment. | Request | Handler | Notes | |---|---|---| | `CreateTicket` | `application/commands/create_ticket.go` | reads the actor's link before its transaction | | `CloseTicket` | `close_ticket.go` | asking again while closing or after closing changes nothing | | `AddMessage` | `add_message.go` | reads the ticket before its transaction; waits while the ticket is pending | | `RequestCustomerLink` | `request_customer_link.go` | only `SUPPORT_OPERATOR_ACTOR_ID` (the API's CLI actor) | | `ListTickets`, `GetConversation`, `GetCustomerLink` | `application/queries` | withdrawn tickets are hidden; another user's ticket is `not_found` | ## Reactions Each reaction (`application/eventhandlers`) runs outside any transaction, may call Zammad, and queues one private command (the private part of `contracts/support/messaging/proto/pharos/support/v1/commands.proto`) for one aggregate through a durable hand-off; the paired consumer runs it. Support's own events are private (`adaptors/messaging/proto/.../events.proto`); it also consumes three of Party Governance's integration events, on its own queues with its own receipts. The graph is `services/beto/contracts/subscriptions.json`. | Event | Reaction → command | |---|---| | `support.ticket_created` | file in Zammad → `RecordFilingResult`; record the opening message → `RecordOpeningMessage` | | `support.ticket_delivered` | `MarkOpeningMessageDelivered`; dispatch | | `support.ticket_delivery_failed`, `support.message_written`, `support.message_released`, `support.sync_requested` | dispatch | | `support.message_delivered`, `support.message_failed` | dispatch; close when ready | | `support.message_claimed` | post to Zammad → `RecordMessageResult` | | `support.close_requested` | close when ready → `RecordCloseResult` | | `support.sync_requested` | re-read the ticket in Zammad → `RecordSupportServiceRead` | | `support.support_service_state_observed` | `ApplySupportServiceState` (closed, reopened, withdrawn) | | `support.agent_reply_imported` | `ReceiveAgentMessage` | | `support.customer_link_requested` | find or create the Zammad customer → `RecordLinkResult`; keep the contact (projection `support_user_contacts`) | | `support.customer_contact_changed` | give the linked customer the contact → `RecordContactResult` | | `party_governance.PartyRegistered` | `ApplyPartyContact` at revision 0: start the Party's link, awaiting a verified email | | `party_governance.PartyIdentityAttributesChanged` | `ApplyPartyContact` at the event's `attributes_revision`: the verified email only, the given and family names (else the display name) | | `party_governance.PartyAuthenticationChanged` | `ApplyPartyAccess` at the event's `auth_version`: only `enabled` may sign in | | `support.customer_access_changed` | allow or stop the linked customer's Zammad sign-in → `RecordAccessResult` | - **Filing** searches Zammad for the ticket's `pp_ticket_uuid` before creating it, so a retry after a timeout never files it twice; a settled ticket is not filed again. - **Dispatch** (`domain.NextDispatch`) takes the ticket's oldest waiting message and claims it, or fails it when the ticket never reached Zammad. A message being sent holds the others back, so messages keep their order; every run starts from the oldest, so one written meanwhile is picked up when the one before it settles. A timed-out post is sent again; a duplicate is accepted (12 October 2026). - **Close** (`domain.ReadyToClose`) goes once the ticket is in Zammad and none of its messages waits. A failed close reopens the ticket. - **Sync** is "store first": Zammad's webhook only records `RequestSync`; one re-read records the ticket's state and its agents' public replies on TicketSync, which turns what is new into facts. Internal notes and system messages stay out; an agent's address is never shown as the author. A ticket Zammad no longer shows to Pharos is withdrawn only while the integration can still read its intake group. Syncs of a ticket run one at a time (one consumer, one Beto replica); scaling Beto out needs a fence first. - **Find or create**: by Pharos user ID; else by email (claimed if unowned, otherwise a conflict); else created. Unreachable fails the link for the retry job; refused data is a conflict; the race for an email is retried. - Zammad timeouts, 429 and 5xx are retried within the adaptor's budget, then the delivery fails for good. Our own faults return to RabbitMQ, which retries three times and then dead-letters the message for replay. ## Zammad adaptor `adaptors/delivery/zammad` implements `application/ports.TicketDesk` and `SupportService` with the Zammad REST API. Its token is an agent of the Pharos groups with no administrator permission (decision 0063); the adaptor sends only the requests listed in [CONTRACT.md](adaptors/delivery/zammad/CONTRACT.md) (`routes.go`), with the customer pinned, and checks every ticket it reads belongs to the customer. It needs the user attribute `pp_user_uuid`, the ticket attribute `pp_ticket_uuid` and Elasticsearch. The webhook (`webhook.go`) checks Zammad's `X-Hub-Signature` with `ZAMMAD_WEBHOOK_SECRET` and runs `RequestSync`, whose command identity comes from `X-Zammad-Delivery`. It is served on Beto's separate webhook listener (`WEBHOOK_LISTEN_ADDR`, default `:8081`), which only the composition root may add a route to. ## Live updates Every Ticket and Message command publishes its committed snapshot explicitly, targeted at its owner (`PrincipalTarget(userID)`); `adaptors/messaging/realtime` encodes it as `SupportTicketProjection` or `SupportMessageProjection`. Links and syncs are never published. The realtime gateway admits Support's publisher only with `SUPPORT_REALTIME_KEY` and only for principal targets, and derives the channel; the API grants each session its own principal channel ([CLIENT-SUBSCRIPTIONS.md](../../../../CLIENT-SUBSCRIPTIONS.md)). The browser reads its tickets once the connection is live, then applies only newer revisions through `useRealtimeMessage`; nothing polls. ## Composition and configuration `services/beto/services/beto/di/support.go` registers the requests, the reactions and the webhook. Configuration: `SUPPORT_DATABASE_URL`, `SUPPORT_BROKER_URL` (restricted `pharos_support`), `SUPPORT_REALTIME_KEY`, `SUPPORT_OPERATOR_ACTOR_ID`, `ZAMMAD_URL`, `ZAMMAD_TOKEN`, `ZAMMAD_WEBHOOK_SECRET` (at least 32 characters) and the Zammad profile `ZAMMAD_GROUP`, `ZAMMAD_TICKET_TYPE`, `ZAMMAD_CLOSE_STATE`, `ZAMMAD_CLOSED_STATES` from the `beto-zammad` ConfigMap (`gitops/manifests/pharos/base/beto/zammad.env`, `gitops/manifests/zammad/README.md`). Beto refuses to start without them. Without a reachable Zammad, links fail and are retried, filings fail after their retries and syncs wait; nothing else breaks. ## Retry job `support-retry` (`gitops/manifests/pharos/base/beto/support-retry.yaml`, every 5 minutes) runs the command handlers directly and reaches only PostgreSQL: - retries every link that is due, with the contact last recorded, and logs links unresolved for over 24 hours (`support links unresolved for over 24h`) until the platform has a notification channel; - gives a linked customer its contact again when that update is due; - for every filed ticket that is not closed, releases messages claimed for over 10 minutes and requests a sync, making up for lost webhooks and stopped work. It pages through every link and ticket, which suits development scale only. ## Linking users Every registered Party is linked from Party Governance's facts (issue #770). `PartyRegistered` starts a link that awaits a verified email; `PartyIdentityAttributesChanged` gives it the contact at the Party's own attributes revision, so an older or repeated fact changes nothing, and the contact may arrive before the registration. A later contact updates the linked Zammad customer. The user ID is the Party ID, which a signed-in session names. A disabled Party (`PartyAuthenticationChanged`, by `auth_version`) cannot open tickets, and its Zammad customer is made inactive: it can no longer sign in to Zammad, while its tickets stay, agents still see them and Zammad still emails it. Enabled again, it may sign in. A customer starts active, so only a change is sent; a Party disabled before its link is made is made inactive once linked. A failed change is retried by the retry job after 15 minutes, a refused one after 24 hours. The operator can still link a user through the API with its CLI credential (the operator-only `requestSupportCustomerLink`), for the local operator, who is not a Party, and for development. It numbers contact revisions itself, so it should not be used for a Party, whose revisions come from Party Governance: ```sh curl -X POST "$API/api/v1/support/customer-links/" \ -H "Authorization: Bearer $API_KEY" -H 'Content-Type: application/json' \ -H "Idempotency-Key: $(uuidgen | tr A-Z a-z)" -H 'If-Match: 0' \ -d '{"email":"ada@example.org","firstName":"Ada","lastName":"Lovelace"}' curl "$API/api/v1/support/customer-links/" -H "Authorization: Bearer $API_KEY" ``` An empty `email` links nothing yet: the link awaits a contact. Sending the request again with the link's revision (`If-Match`, from the GET) and a different contact records it as the next revision: it resumes a waiting link or updates the linked customer's email and names in Zammad. The same contact again changes nothing. The GET reports `contactStatus` and `contactReason`. On a dev box, `make support-link` does this for the local operator (`00000000-0000-4000-8000-000000000001`): it runs on the VM over SSH, so the CLI key never leaves it, and waits until the link settles. Another user needs `USER_ID` (their sign-in ID, which their live updates are addressed to), `FIRST_NAME`, `LAST_NAME` and, once verified, `USER_EMAIL`. An existing link is reported; with `USER_EMAIL` a different contact is sent (resuming a waiting link or updating the Zammad customer), and the command waits until Zammad has it. ## Not handled yet Recorded for a later iteration (issue #770): - **People registered before Support.** Support learns about a Party only from Party Governance's events, which reach only a Support that is already listening. Support is deployed when the platform is initialised, before anyone signs in, so nobody is missed today. A Party registered before Support (or a rebuilt Support database) would be loaded from `ListPartyContactSnapshots` (#791), which Support does not read yet. - **A Party leaving or erased.** Party Governance has no retirement or erasure fact yet. Until it has, a person who must be removed is anonymised or deleted by hand in Zammad, and their link and tickets stay in Support. ## Network `support-retry` may reach PostgreSQL only. `beto-zammad-egress` lets Beto reach port 8080 in the `zammad` namespace; `beto-zammad-ingress` admits the `zammad` namespace on the webhook port 8081 only. The API reaches Beto over RabbitMQ. ## Tests - Domain rules, handlers, reactions, queries and adaptors in memory (`go test ./contexts/support/...`). - PostgreSQL with the restricted credentials: atomic state, events and the owner-targeted browser messages, idempotent retries, owner-scoped reads and the durable filing hand-off (`adaptors/persistence/postgres/support_integration_test.go`, `pnpm test:integration`). - The adaptor against a fake Zammad, and its contract test against a live one (`adaptors/delivery/zammad/CONTRACT.md`). - The browser lane opens a ticket in one window and watches it live in another (`e2e/support-live.spec.ts`). Not yet: attachments; closed tickets are not reconciled (an agent's reopen relies on the webhook); a ticket whose filing event was dead-lettered stays pending until replayed.