What it is
When a long-running support ticketing platform was retired, the historical record it held still mattered, for reference, for audit, and occasionally for reconstructing why a decision was made years earlier. This application keeps that archive searchable: full-text search across ticket content, attachment retrieval, ticket history, and an audit log recording who searched for and downloaded what.
Why it mattered
Decommissioning a system is straightforward. Decommissioning it without losing its history is not. The usual outcomes are keeping the old platform running indefinitely at real licensing and maintenance cost, or exporting everything into files that nobody can practically search, which is a technically complete archive and a functionally useless one.
Building a purpose-made read-only archive was cheaper than either option, and being read-only by design meant it needed none of the complexity that made the original platform expensive to operate. No workflow engine, no notification system, no permissions model beyond read access and audit.
What it replaced, and the two attempts before it
The retired platform was Remedyforce, and the archive did not replace it directly. It replaced the stopgap that grew up afterward: a Python application with a Tkinter desktop interface, installed per machine, launched from a command prompt, querying the archived data directly.
That tool worked, and it had the problems desktop tools have. It needed installing and updating on every machine. It had no access control beyond whoever could run it. It produced no record of who searched for or downloaded what. And its interface was a developer’s interface, not something a non-technical colleague would open willingly.
There was an intermediate attempt worth naming, because it shaped this one. Starting around September 2025, I rewrote that desktop tool as a FastAPI web application, working with Microsoft Copilot as a chat-based assistant, and took it as far as a working backend, a browser interface built on Jinja2 templates, and planning for hosting behind IIS. That effort ran on and off for months around other work, and is described in more detail in the journey behind these projects.
It did not become the production system. When agentic tooling made it realistic to build on our internal standard stack instead, with the shared authentication, layout, and audit conventions every other internal application already used, that was clearly the better destination than continuing a one-off Python service that only I could maintain.
The FastAPI work was not wasted. It is where the problem got properly mapped: what the archive actually needed to do, which queries mattered, and why the desktop model had to go. The production rebuild moved quickly because that thinking was already done.
Deliberately not using an ORM
The application uses raw data access with parameterized queries throughout, no object-relational mapper. That is a departure from every other application on this site, and it was intentional.
An ORM earns its complexity by managing change tracking, relationship navigation, and unit-of-work semantics across writes. This application never writes. It has no domain model to speak of, no state transitions, and no business rules; it has queries and a rendering layer. Adding a mapper would have introduced configuration, migration machinery, and a translation layer between the query I want and the query that runs, in exchange for benefits that only apply to writes.
The tradeoff is that query construction is manual, which puts the burden on parameterization discipline rather than on a framework enforcing it. Every query is parameterized, and the read-only surface means the blast radius of a mistake is narrower than it would be in a system that writes.
Making a decade of free text actually searchable
The core capability is full-text search across ticket summaries, resolution text, and the full history thread. Three problems had to be solved to make that work.
The search predicate cannot be used where I first tried to use it. Full text predicates in this database engine are valid only as search conditions, not as scalar expressions, so they cannot appear inside a conditional expression in the select list. That matters because the natural way to build a “where did this match” indicator is a conditional expression per column, and that approach simply does not compile. Searching the summary and resolution columns works directly in the filter clause. Searching the history thread, which lives in a separate table, needed a join against a derived subquery, because the correlated existence form proved unreliable here.
Word stemming is on, and that is a feature. A search for “resolve” also returns “resolved” and “resolving”. Worth documenting explicitly, because it looks like a bug the first time someone gets a result that does not contain their literal search term.
The result needed to tell you where it matched. A search across three different text sources returns a mixed list, and knowing whether a hit came from the summary, the resolution, or deep in the history thread changes how you read it. The ticket list shows a colour-coded indicator per row for exactly that, which is a small feature that changes the experience of using the tool substantially.
A performance problem inherited from the archive’s shape
Every filter and sort column in the archived data arrived as unbounded text. That is a reasonable outcome of a bulk export and a poor foundation for querying, because unbounded text columns cannot be indexed usefully.
Rather than rewriting the imported data, the fix adds persisted computed columns derived from the originals with bounded types, and indexes those. The source data stays exactly as exported, which preserves it as an archive, while queries resolve against something indexable.
Alongside that, result sets are capped and command timeouts extended deliberately, because an archive query legitimately can be slow and the right behavior is to let it finish rather than fail at a default timeout tuned for interactive transactional work.
Access control enforced in the query, not after it
Queue-level permissions filter results at the database level, inside the query and before the row cap is applied. That ordering is the part that matters. A cap applied before the permission filter would return the first N rows overall and then remove the ones the user cannot see, producing a short and inconsistent result set that looks like missing data rather than a permission boundary.
One backward-compatibility rule: an administrator with no queue rows assigned gets access to all queues. That preserves the behavior of the previous tool for existing administrators rather than requiring a data migration to grant everyone explicit rows.
The identity problem, and why the obvious approach fails
Windows authentication in this hosting model has a non-obvious failure that cost real time.
The standard way to get the current user’s identity is to read it from the incoming HTTP request context. Inside components of this framework, that context is null, because components execute over a persistent socket connection rather than in the HTTP request pipeline. The request context only exists during the initial handshake, and by the time a component runs, it is gone.
The working pattern is indirect: the browser makes a request to a small endpoint with negotiate credentials, the server resolves the Windows identity during that real HTTP request, and caches it in a service scoped to the connection. Components read from that service.
One additional detail was needed. Pre-rendering had to be disabled, because the double render otherwise wipes the resolved identity between the first static pass and the interactive one.
I would not have predicted any of this from the framework’s documentation. It is the kind of thing you learn from a null reference in production and then write down.
The assembly that was present but invisible## Timeline and effort
Development spanned roughly 17 weeks, with 6 distinct working sessions recorded. The concentrated build happened in the opening weeks, with later sessions covering hosting corrections, memory scaffolding, and documentation reorganization rather than functional change. The long tail relative to the session count reflects a project that reached working state quickly and then needed only occasional attention.
Where it stands now
The archive runs in production with full-text search across the historical record, parallel detail loading, response compression, and an access audit trail. The original platform is fully retired.
The most recent work has been documentation reorganization rather than functional change, separating durable architectural guidance from dated status history so that the project’s own reference material stays trustworthy. That distinction matters more than it sounds: a guidance document that mixes standing architecture with months-old status notes gradually becomes something nobody trusts, and an untrusted document is functionally the same as no document.