An email is not a ticket
Affects: lib/helpdesk.ts, app/api/support/route.ts, components/support/ticket-form.tsx, lib/email/templates.ts, types/support.ts, app/api/health/route.ts, scripts/check-secret-modules.mjs, package.json, .env.example
What was decided
A submission through the form on /support is only described as a *ticket* when a helpdesk has returned an identifier for it. lib/helpdesk.ts files against Freshdesk or Zammad and returns a discriminated union; the filed arm carries the vendor's id, and nothing else in the codebase can produce one.
This is D-013 applied to a second claim. The uptime figure was a number nothing measured. "Your ticket is in the queue below" was a queue nothing implemented. The support route validated the submission, wrote a row to support_tickets, and emailed a shared alias — three real things — and then the receipt said the ticket was *filed*, that it was *in the queue*, and that *the on-call engineer for that module has been notified*. Nothing read that table on a schedule. routedTo named a rotation that exists on a page and in no rota. The mail went to an address somebody opens when they open their mail.
The difference is not pedantic and it is not about wording. A ticket in a helpdesk has an owner, a state, and a clock somebody's dashboard is counting down. Mail in a shared inbox has a reader, if someone is reading. The site was selling the first and operating the second, on the page where a customer goes during an outage.
Two identifiers, two panels, never one field
The receipt renders the helpdesk ticket number and our NS-SUP- reference as separate labelled panels, and the ticket body sent to the vendor says of our reference, in words, that it is not the helpdesk ticket number.
They look identical on screen — short, uppercase, hyphenated — and mean entirely different things: one is a string we generated, the other is a row an agent can open. The failure mode is a customer quoting the wrong one and being told their ticket cannot be found, which is a conversation that happens at the worst possible moment in the relationship.
The type makes the collapse unavailable rather than discouraged. There is no id: string | null for a caller to write id ?? reference against; the arm without a ticket has no id field at all.
unconfigured and misconfigured are different states
TicketNotFiled.unconfigured is true only when no HELPDESK_* variable is set. A partial configuration, or a provider name that is not one of ours, is false — so it logs at error rather than info and the receipt turns amber rather than green.
This was wrong in the first implementation, in the way that looks correct: the natural test is "did we end up with a config?", and both cases answer no. But somebody who set a key and a provider and mistyped the domain variable intends this deployment to file tickets and it is silently not filing them. That belongs on the incident side of the line. It was caught by running the module against a stubbed fetch before it shipped, which is the second time that exercise has found something reading the code did not — see D-013's window bug.
GET /api/health reports the same three states and deliberately does not fold the helpdesk into its status code. Without a database a submission is lost; without a transport nobody is told it arrived; without a helpdesk it is stored, emailed and answered by a human. Worse, but not a failure — and a permanent 503 on a deployment doing its job is how a health check stops being read.
Filing never fails the submission
fileTicket does not throw and does not retry. A credential problem of ours is not a reason to hand a 500 to somebody whose form was valid, so every failure comes back as a reason string and the route continues to store and to notify.
No retry is the less obvious half. The caller is inside a request a person is waiting on, so a second attempt turns an eight-second wait into sixteen for the same answer — and the failures that most look transient, a 429 or a gateway timeout after the ticket was created, are exactly the ones where retrying files a duplicate. An outage that produces two tickets per customer is an outage with twice the triage.
Filing happens before the write and before the mail, because the row carries the helpdesk id as its join key and because if only one of the three completes, the one worth having is the one that put the ticket in front of a human.
What is not yet true
No helpdesk account exists. Both request shapes are written from published API documentation and have never seen a real response, so a 2xx whose body has no usable identifier is treated as *not filed* rather than reported off the status code — a ticket number we invented is worse than an honest failure. Everything except the vendor-shape assumptions was exercised against a stubbed fetch before this was committed.
Until an account exists, the form says "Ticket received", the receipt shows one identifier rather than two, and the SLA panel is driven by the email having sent. That is weaker than what the page said last week and it is true.