Every card below has a registered route or a mounted screen and a real caller. The label Live means it works end to end. The label Partial means the surface is live and a seam inside it is honest-off — the body says which seam and why, because that is the part a demo would hide. The file path under each card is there so you can hold the claim to something.
Ticket lifecycle
File, read, list, transition, assign — on durable rows, with the legal-move table at the server
Five routes carry a ticket from filed to closed. A requester files one; the OWNER is read from the authenticated session server-side, never from the request body, so a forged payload can never own somebody else’s ticket. A lifecycle move is checked against a single legal-transition table before anything is written: an illegal move (closing something twice, reopening by the wrong verb) returns a 409 with the from-state and the attempted action, and the row is not touched. Rows are durable in the support-ticket table, not a queue in memory that a restart forgets. This is the foundation everything else on this page reads.
Live · 5 registered routes
apps/api/src/routes/help-desk-tickets.ts
Owner isolation
A requester sees their own tickets and nothing else — and the denial is a uniform 404
A requester’s list is resolved from their own owner-scoped read and then passed through the engine’s owner filter a second time, belt and braces. Staff hold the queue scope; a requester never does. The detail worth the paragraph is the SHAPE of the denial: a same-tenant ticket owned by another requester returns exactly what a nonexistent ticket returns — a plain 404, no “forbidden”, no different latency, no hint. A 403 tells you the row exists. That is an enumeration oracle, and we do not ship one. For a REQUESTER that wall goes all the way down: their list and their detail read both run under the tenant select policy at the database, narrowed again to their own owner column. The STAFF path is where we have to be precise, because the honest answer is not the flattering one. The staff QUEUE read runs under the same tenant policy and is scope-walled above it. But the staff PER-TICKET read — fetching one ticket by id, and the transition and assignment that reuse it — goes through a repository helper that opens a SYSTEM-lane transaction, and the select policy admits a system lane unconditionally. So on that one path a cross-tenant row is NOT invisible at the database, and no scope claim is re-checked above it either. For the platform support role that is the intended reach. For the narrower account-manager role, which the queue does wall, it is wider than the queue implies, and we are not going to describe it as a wall while it is a hole. It is logged as a defect, not a feature.
Live · requester fail-closed; staff per-ticket read is system-scoped
apps/api/src/routes/help-desk-tickets.ts
Operator triage queue
The scoped queue, with a count that refuses to identify anyone
The operator queue derives the viewer’s scope from the session — district, school, studio, rep, or platform — and admits a ticket only if one of its own scope keys is in that allowed set. An unknown or empty claim sees an EMPTY queue rather than everything. Then the part no general help desk does: every aggregate count runs through small-cell suppression. A cell holding one, two, three or four tickets renders as the literal string “<5”, not as its number. In a district where one small school has two open tickets and everybody knows that school has one nurse, the count IS the identifier. Suppressing it is not decoration.
Live · scope-walled + small-cell suppressed
apps/api/src/routes/help-desk-ops.ts
SLA policy grid
Twelve business-hour targets, a clock that pauses, and a fail-closed fallback — with the stamps written but not yet read
The policy is code, not a config table you have to populate before the product works: three escalation tiers by four priorities, twelve cells, each a pair of BUSINESS-hour budgets for first response and resolution. A ticket whose tier or priority does not parse falls back to the STRICTEST cell, so a mistyped ticket is never quietly handed a lax target. The clock runs on an injected business calendar, so a weekend does not breach anything, and it PAUSES while the ticket is waiting on the reporter — the ball is in their court and the budget should not burn. A live clock flips to at-risk at 80 percent consumed. HONEST-OFF, and this is load-bearing — but the precise reason changed and the page has to change with it. The first-response, resolved and closed stamps ARE persisted now: the lifecycle write records them on the ticket row, keyed off the landing status, and the ticket read returns all three. What is still missing is the READER. The SLA projection that feeds the roll-up hands the engine a hard-coded null for first-response and resolution, so the clocks still run on the opened anchor alone and the endpoint still returns provisioned:false. So this is not an absent column any more; it is a written column with zero production readers. That is a smaller, duller job than a migration, and stating it as “not persisted” would send somebody to build a schema change that already shipped.
Live · honest-off: stamps persisted, SLA projection still reads null
packages/shared/src/help-desk-sla.ts
Assignment
Manual assignment is wired. The least-loaded auto-pick is not, and says so.
A staff member assigns a ticket to an owner handle; the write is the same helper the lifecycle route uses, so there is one assignment path and not two that can disagree. The response also reports the tier the routing rule would target. What is NOT wired: the least-loaded automatic pick, because it needs an agent roster and no roster source is provisioned. The endpoint returns autoPick with provisioned:false and the reason. We could have shipped a round-robin over whoever last touched a ticket and called it auto-assignment. It would have looked identical in a demo and been wrong in week two.
Live · manual only; auto-pick unprovisioned
apps/api/src/routes/help-desk-ops.ts
Support analytics
Volume and backlog age are real numbers. Latency, SLA compliance and CSAT trend report unavailable.
Ticket volume bucketed by day, week or month, and backlog age in bands, both computed from the persisted rows and both scope-filtered and small-cell safe. Those are real. Response latency, SLA compliance percentage and CSAT trend each return a flag reading available:false. For CSAT the input genuinely does not exist — there is no store. For latency and compliance the input DOES exist now: the response and resolution stamps are written on the ticket row. The analytics projection simply does not read them — it omits both fields on the way to the engine. No injected function is passed either, so the engines cannot fabricate a number even by accident. Every help desk we compared draws these charts unconditionally. A median-first-response chart computed from stamps nothing reads is not a chart; it is a picture of a zero.
Live · 2 of 5 metrics provisioned
apps/api/src/routes/help-desk-ops.ts
Agent workspace
The staff cockpit — queue, detail, assign, move, and a canned reply you fill yourself
A mounted staff screen: the scoped queue, ticket detail, owner reassignment, a legal lifecycle move, and a canned-reply composer over four human-authored templates. The composer’s honesty rule is small and worth stating: a placeholder you did not fill stays on screen as a literal {{key}}, and the render is flagged incomplete. It is never blanked out, because a half-filled reply that LOOKS finished is the one that gets sent. The API re-checks the staff role on every request; the screen mirrors that gate client-side only to choose what to mount. A restricted or unreachable read degrades to an honest empty state, never a fabricated queue.
Live · mounted staff route
apps/web/src/routes/HelpDeskAgentWorkspacePage.tsx
Family request portal
Deflect first, then file — with real published articles or an honest nothing
A parent, guardian or student opens the portal, starts typing a subject, and sees suggestions drawn from the PUBLISHED knowledge base — audience-filtered on the server, retrieval only. No generated answer: this surface faces families, so text generation is held. If the knowledge base is unprovisioned or matches nothing, the portal says it has no suggestions. It does not invent an article to fill the space. Filing goes through the same owner-isolated route as everything else, so the requester sees their own request and its status afterwards and no one else’s.
Live · mounted family route
apps/web/src/supportportal/SupportRequestPortalPage.tsx
Status probe
A status pill that signs a token, reads through the store, and writes-then-reads a key
The /status endpoint is a synthetic probe along the real user path, explicitly not a health check that returns 200 because the process is up. Three steps: mint and verify an access token, read a fixture through the repository layer, and write then read back a throwaway key-value marker. A green pill therefore MEANS you can sign in, load your work, and have an edit save. A sick dependency reports as a failed step and a degraded verdict; the route itself does not 500 and hide the fault. No tenant data is touched at any step.
Live · synthetic, not a health check
apps/api/src/routes/support.ts
Escalation & CSAT
Both compute correctly and neither writes. That is the honest state, printed in the response.
Manual escalation walks the priority and tier ladders one notch, computes the supervisor-notify intent, and fails closed on a cross-tenant actor — and then returns persisted:false, because there is no tier-persistence helper yet. The operator sees exactly what WOULD change. A satisfaction response is validated for eligibility, tenancy and rating range, then returned with persisted:false, because there is no CSAT store. Neither of these is a stub that pretends. Both are complete engines with the last mile deliberately unbuilt, and the response body says which mile.
Live surface · persisted:false on both
apps/api/src/routes/help-desk-ops.ts