> For AI agents: the complete documentation index is available at /llms.txt, the full documentation bundle is available at /llms-full.txt.

# 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`](/reference/contracts/support/messaging/README.md);
browser operations: <a href="https://github.com/pharos-ai-factory/pharos-platform/tree/f928834c82b9d99e12575f9bd3f311c16c8f138b/contracts/support/http_api">`contracts/support/http_api`</a>.
The API's <a href="/source/services/api/adaptors/http/mappings/support.json.txt">mapping manifest</a> 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 <a href="/source/services/beto/contexts/support/adaptors/delivery/zammad/CONTRACT.md.txt">CONTRACT.md</a>
(`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](/reference/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/<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.

<details class="page-source"><summary>Page source</summary>

<a href="/source/services/beto/contexts/support/README.md.txt">Source snapshot</a> · Revision `f928834c8`.

</details>
