For AI agents: the complete documentation index is available at /llms.txt, the full documentation bundle is available at /llms-full.txt, and this page is available as Markdown at /reference/services/beto/contexts/support/README.md.

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()).

AggregateKindIdentityStates
Customer (SupportCustomer)support_customerPharos user IDlinkStatus pending, linked, failed, conflict, awaiting_contact; contactStatus synced, syncing, failed, conflict (linked only)
Ticketsupport_ticketchosen by the browserticketStatus open, closing, closed; creationStatus pending, delivered, failed (final); withdrawn
Message (TicketMessage)support_ticket_messagechosen by the browser; derived for opening messages and agent repliesdeliveryStatus pending, sending (claimed), delivered, failed (final)
TicketSyncsupport_ticket_syncthe ticket's IDrequests, 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; browser operations: contracts/support/http_api. The API's mapping manifest 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.

RequestHandlerNotes
CreateTicketapplication/commands/create_ticket.goreads the actor's link before its transaction
CloseTicketclose_ticket.goasking again while closing or after closing changes nothing
AddMessageadd_message.goreads the ticket before its transaction; waits while the ticket is pending
RequestCustomerLinkrequest_customer_link.goonly SUPPORT_OPERATOR_ACTOR_ID (the API's CLI actor)
ListTickets, GetConversation, GetCustomerLinkapplication/querieswithdrawn 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.

EventReaction → command
support.ticket_createdfile in Zammad → RecordFilingResult; record the opening message → RecordOpeningMessage
support.ticket_deliveredMarkOpeningMessageDelivered; dispatch
support.ticket_delivery_failed, support.message_written, support.message_released, support.sync_requesteddispatch
support.message_delivered, support.message_faileddispatch; close when ready
support.message_claimedpost to Zammad → RecordMessageResult
support.close_requestedclose when ready → RecordCloseResult
support.sync_requestedre-read the ticket in Zammad → RecordSupportServiceRead
support.support_service_state_observedApplySupportServiceState (closed, reopened, withdrawn)
support.agent_reply_importedReceiveAgentMessage
support.customer_link_requestedfind or create the Zammad customer → RecordLinkResult; keep the contact (projection support_user_contacts)
support.customer_contact_changedgive the linked customer the contact → RecordContactResult
party_governance.PartyRegisteredApplyPartyContact at revision 0: start the Party's link, awaiting a verified email
party_governance.PartyIdentityAttributesChangedApplyPartyContact at the event's attributes_revision: the verified email only, the given and family names (else the display name)
party_governance.PartyAuthenticationChangedApplyPartyAccess at the event's auth_version: only enabled may sign in
support.customer_access_changedallow 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 (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). 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:

curl -X POST "$API/api/v1/support/customer-links/<user-uuid>" \
  -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/<user-uuid>" -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.

Page source

Source snapshot · Revision f928834c8.