When our Remedyforce 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.
The effort to keep this history accessible began months before the application described here existed. What came first was a rewrite of an older desktop tool as a Python web application, worked on intermittently for months with a chat-based assistant. It reached deployment planning and never shipped. The Blazor application described here is the second attempt, and it reached production quickly because the first attempt had already established what the archive needed to do.
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 to files nobody can practically search. A purpose-made read-only archive was cheaper than either and being read-only by design meant it needed none of the complexity that made the original system expensive to maintain. A few of its choices run against the grain of the other applications on this site, each explained on the detail page: no object-relational mapper at all, indexed computed columns layered over the exported text so the archive data itself stays exactly as exported, and queue permissions filtered inside the query, before the row cap, so a permission boundary never looks like missing data.
The archive runs in production with full-text search across the historical record, parallel loading of ticket detail, response compression, and an audit trail of access. The original platform is fully retired. The project is stable and in maintenance, with the most recent work being documentation reorganization rather than functional change.
The technical detail
This application keeps a decade of retired support-ticket history searchable after the platform holding it was decommissioned: full-text search over the ticket text, attachments served on demand, per-ticket history, and an audit log tying every search and download to the person who made it. Being read-only by design, it could drop everything 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 built after the retirement: a Python application with a Tkinter desktop interface, written quickly by an external consultant, installed per machine, launched from a command prompt, and querying the archived data directly.
That tool worked as delivered, 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. Changing any of that meant buying consulting hours for a stack we had no in-house developer free to maintain, which is the constraint the whole replacement story runs on.
An intermediate attempt shaped this one. Well before this application existed, 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 mapped: what the archive 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, not 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 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 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”. It looks like a bug the first time someone gets a result without their literal search term, so it is documented.
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 color-coded indicator per row for that, a small feature that changes how the tool feels to use.
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.
Instead of 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 is that an administrator with no queue rows assigned gets access to all queues. That preserves the behavior of the previous tool for existing administrators, without 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, not 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
The display name lookup failed after deployment, under the application pool identity. Reading a user’s full name from Active Directory returned a file not found error, and the library it named was sitting in the application directory the whole time.
The cause was a reference that looked sufficient and was not. I had picked up the directory services library indirectly, through the account management package that depends on it. Because the reference was indirect, the build treated the library as something the framework supplies at runtime and left it out of the deployment manifest. The file was copied to the server. The manifest did not list it, so the runtime did not load it.
I could have kept working on the manifest to understand why the indirect reference was being discounted. I chose the direct route instead. The library is now referenced explicitly, and the lookup uses the directory searcher directly, not through the account management wrapper.
One detail mattered more than it looked. The error handling had to wrap the call, not just sit inside the method being called. An assembly load failure happens when the method is first compiled, before any code inside it runs, so a handler inside that method never gets the chance to catch it.
The lookup now falls back to the username when it fails. A directory problem costs a full name and nothing else.
Hosting under a virtual directory, done twice
An earlier approach simulated a sub-path using a configuration value plus a manual redirect. When the application was later hosted as a genuine sub-application, that logic double-applied, because the web server’s own module already sets the path correctly. The fix was to delete the manual handling and read the value the server already provides, which works at a site root and under any virtual directory with no per-environment configuration.
Activity logging that cannot break the application
Login, search, and download events are all recorded, but the logging path is deliberately designed to fail silently. An audit trail is valuable; an audit trail that can take down the application it is auditing is not.
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, not 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 is live in production: searchable across the whole historical record, with parallel detail loading, response compression, and an access audit trail. The original platform is fully retired.
The most recent work has been documentation reorganization, no 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.