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()).
- Link:
failedmeans Zammad could not be reached and is retried after 15 minutes;conflictmeans a person must act (the email belongs to another Pharos user, or Zammad refused our data) and is retried after 24 hours.awaiting_contactmeans 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.unresolvedSinceis 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 (contactStatussyncing, 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.contactStatusis 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.
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.
- Filing searches Zammad for the ticket's
pp_ticket_uuidbefore 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:
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.