Counting the ways an internal note can reach a customer
A helpdesk enforces note privacy through one caller-specific projection across counts, previews, search and exports, and narrows what customer-bound drafts can use.

A support agent opens ticket ACME-1 and writes, “INTERNAL: customer is a VIP.” That sentence comes from a test fixture in a helpdesk I built in August 2026, and it makes a good fixture because it is exactly the sort of thing a support team writes down and the customer should never read.
Stating the rule takes one sentence and enforcing it takes three lines of code: a message is visible if it is public, or if the caller is an agent of the ticket’s tenant, and an agent of another tenant is not an agent here. What takes the rest of the work is everything the rule has to reach. The helpdesk sits on an application framework that serves one declared surface to a React console, a plain HTTP API and MCP agents, and its tickets also feed search, list previews, exports, reply drafts, merges and splits. Each of those reads messages, and any one of them that forgets the difference between a public reply and a note can hand that sentence to the person it describes.
The obvious way to build this is to have each surface remember the rule. I don’t trust that arrangement, and the same codebase showed why that month at a lower level. Its applications had grown eight hand-rolled query-string decoders that disagreed on every edge, and two of the disagreements shipped as defects. One of them shows how quiet this class of bug can be: a browser sent a stream cursor’s colon as %3A, the undecoded value failed to parse, the caller read the failed parse as no cursor supplied, and a reconnecting client resumed at the wrong offset with no error anywhere. The repair was one shared decoder. Separate implementations of a rule drift apart, and when the rule concerns privacy, the first report of the drift may come from a customer.
So the helpdesk computes what a caller receives before any surface gets involved. Each projection is made for one caller in one place, and the page loader, the action reply, the HTTP read, the MCP tool result, the export and the search corpus all use the same visibility rule. A caller who may not read a ticket at all gets the same answer as if it didn’t exist. Another tenant’s ticket and another requester’s ticket both come back as not found, because whether a ticket exists is also information.
The projection has to do more than drop messages from a list, and this is where the count of exits starts to grow. A ticket view carries a count of internal notes, computed over the visible messages, so a customer sees zero and not a number announcing that the agents have been talking. A row in a ticket list shows a preview taken from the last visible message, so when the newest thing on a ticket is a note, the agent’s row starts with “INTERNAL” and the customer’s shows the message before it. Search is the subtlest of these. The text a query matches against is built from what the caller can see, and the excerpt comes from the first visible match, because if notes were searchable and merely hidden from display, a customer could work out what they say by trying words and watching whether their own ticket came back. Reports and the audit trail are agent-only, and the export maps every ticket through the same view.
The test for this follows the same logic. It puts the VIP note on ACME-1, encodes what the customer receives as JSON and asserts that the string “INTERNAL” appears nowhere in it, which covers every field in the payload, including fields added later. It also asserts that the agent’s preview starts with “INTERNAL”, and that second half matters, since a test that only checked for absence would be just as happy if notes were disappearing for everyone. What it can’t see is a handler that skips the view. The loader for a single ticket returns the whole stored record, notes included, once the read check passes, and the notes only disappear when the record goes through the view, so a new handler that serialized the stored record directly would get around the chokepoint without any test of the view noticing. A single projection makes the rule easy to keep and easy to review, and keeping it still takes the review.
Reply drafts raise a version of the problem that anyone adding AI to a support product should look at closely. The support agent asking for a draft is allowed to read the notes, but the text is on its way to the customer. If the caller’s access decided what a draft could draw on, the VIP note would be fair game, one careless send away from its subject. The local draft operation returns a templated reply with an accompanying redacted context built only from public messages. Here visibility depends on where the text is going as well as on who asked. The same reasoning applies when a language model works on someone’s behalf. Connected over MCP it receives the view its caller is entitled to, and whatever it receives it can repeat, so the material for anything addressed to a customer has to be narrowed before the model sees it.
Writes get the same treatment as reads. Whether a caller may add an internal note is checked in the mutation itself, so the answer does not depend on whether the request came from the console, the API or an agent. Idempotency keys are scoped to the caller, so a retried request can only be answered with that caller’s own result. Merge and split are where a note changes address. They are agent-only and load their tickets through the same not-found path, so naming another tenant’s ticket gets an agent nowhere, and since visibility is a property of the message and the view is recomputed on every read, a note stays a note wherever it lands. What the projection can’t judge is whether two conversations belong together, so that stays a decision restricted to the support-agent role, written to the audit trail.
Erasure is where the rule runs out. A customer’s erasure requires confirmation, removes their requester details and the messages they wrote, and keeps the audit trail. It finds those messages by comparing each author with the customer’s email address, and the VIP note was written by an agent, so it survives the erasure of the person it describes, still visible to the tenant’s agents. That may be the right behavior for some deployments and the wrong one for others, and it should be a deliberate policy decision by whoever runs the desk. The visibility rule can’t make that decision, because it governs who may read a message and has no opinion on whether the message should still exist.
Chris Chabot, November 2026