-
Notifications
You must be signed in to change notification settings - Fork 15
Full Text Search
Status: phase 1 is PART BUILT. Searching inside ticket messages and notes works today. Attachment text does not exist yet, and nothing indexes new tickets as they arrive.
| Shipped | |
|---|---|
| #991 | Database Verification understands full-text indexes |
| #992 |
search_documents β the corpus table |
| #993 | Debug tool D007 β Search corpus health |
| #994 | the one search function (includes/search/) |
| #995 |
scripts/search_backfill.php β index existing content |
| #996 | Tickets β search β "Anywhere in the ticket" |
| Not built | |
|---|---|
| β¬ | attachment text β the whole of Β§3.2, Β§6.4 and the extractor tiers in Β§8.2 |
| β¬ | indexing as tickets arrive β the backfill must be run by hand for now |
| β¬ | anything in Β§7 (the ruled-out options) or Β§8.5 (a second backend) |
Prompted by discussion #53, where an evaluator asked for full-text search over ticket descriptions, replies, internal notes and history, plus indexing of PDF, Word, Excel and text attachments. Β§5 answers that request point by point.
β οΈ This remains a DESIGN document, and design documents rot. Where it describes something now built, the code is the authority. Its lasting value is Β§7 β the options considered and rejected, with the reasons β because that is the part nobody can reconstruct from the code.
Worth stating plainly, because two of the three things the discussion asked for genuinely don't exist, and the third exists but is easy to miss.
| Surface | What it searches | What it does not |
|---|---|---|
Ticket search modal (api/tickets/search_tickets.php) |
LIKE on ticket number, from/to address, subject |
any message body, any note |
Command palette (api/system/global_search.php) |
7 modules β titles, names, references | any body content |
Knowledge AI search (includes/knowledge/kb_ai.php) |
article text, semantically, via embeddings | needs an API key; articles only |
There is no FULLTEXT index anywhere in the product, and email_attachments stores filename, path, size and mime β no extracted text.
So: global search exists, but it's jump-to-a-record, not find-content-inside-records. That distinction is the whole feature.
Extraction is the hard half. Indexing is not.
Turning a PDF or a Word document into plain text is genuinely difficult β encodings, scanned pages, legacy formats, hostile files. Searching text you already have is a solved problem, and MySQL 8.0 (the current floor) does it natively with no new dependency.
Almost every wrong turn below comes from reaching for a tool that solves the easy half.
Bodies and notes are already in the database: emails.body_content and ticket_notes.note_text, both LONGTEXT, both InnoDB/utf8mb4. A FULLTEXT index over them plus a query path is most of what was asked for, with no new services and no new dependencies.
Two traps decide whether it feels good or broken:
-
body_contentis HTML. Indexed raw, every ticket "contains"div,span,styleβ searching table returns everything. It needs stripped plaintext, produced once at ingest, and indexed instead. That text also gives you sane result snippets. -
innodb_ft_min_token_sizedefaults to 3, so two-character terms find nothing β and service desks search short codes constantly. It's a server variable requiring a restart and a full index rebuild, and on shared hosting it often can't be changed. Likewise InnoDB's stopword list, which eats "no", "not" and "can" β and "cannot print" is a real query. Document both up front; discovering them later feels like a bug.
| Format | How | Needs |
|---|---|---|
.txt .csv .log .md
|
read the file | nothing |
.docx |
includes/rfp_docx_parser.php already does this, dependency-free |
nothing |
.xlsx .pptx
|
same zip-of-XML shape; that parser is the template | nothing |
.pdf, legacy .doc/.xls, scanned documents |
an external extractor (see Β§8.2) | optional service |
.docx parser cannot be reused as-is. It was written for RFP source documents β chosen and uploaded by a signed-in member of staff. Ticket attachments arrive from anyone who can email the service desk, and api/tickets/check_mailbox_email.php has no authentication at all. Same code, completely different threat model. See Β§6.4.
Extraction writes plain text into the FreeITSM database. That text is the durable record. The index β whether that's a MySQL FULLTEXT index over it, or documents in some future engine β is a derived artefact that can always be rebuilt from it.
This is the single most important structural decision on this page, and Β§9.2 explains what it protects against.
Every extracted attachment carries a status: pending Β· extracted Β· truncated Β· too_large Β· unsupported Β· failed. That status is shown in the UI. A search that silently returns nothing because a file was never readable is worse than one that admits it.
Extraction runs off the request thread (there is already cron infrastructure). Otherwise one large attachment stalls the mailbox poller and delays every ticket queued behind it.
Does phase 1 index existing tickets and attachments, or only new ones from switch-on? Backfilling is a long-running job over potentially years of data and every archived file; not backfilling means search quietly has a horizon that users will trip over. This needs answering before build, not during β and note that the discussion explicitly asked for "historical conversations", so the honest answer to that request depends on it.
This is the part that makes ordinary rows and extracted document text searchable as one thing rather than as two features bolted together.
The obvious build is a FULLTEXT index on emails, another on ticket_notes, another on attachment text, then UNION the results. It falls apart:
- Relevance scores from different full-text indexes are not on a common scale β each is computed against its own index's corpus statistics. You cannot meaningfully sort a note hit against an attachment hit.
- Pagination then has nothing coherent to page by, so page 2 is guesswork.
- Every new searchable thing later β knowledge articles, change descriptions, problems β is another branch in the query and another place to re-express the permission rules.
A row is one searchable unit, whatever it came from:
search_documents
id BIGINT PK
source_type VARCHAR(32) -- 'ticket' (subject) | 'email' | 'note'
-- | 'attachment' | 'kb_article' | β¦
source_id BIGINT -- the origin row
ticket_id INT NULL -- what it hangs off, where that applies
tenant_id INT NULL -- company scope, mirrored from the source
is_internal TINYINT(1) -- 1 = never visible to a portal user
visibility VARCHAR(16) -- third-party-message privacy rule
title VARCHAR(500) -- subject, or filename
body MEDIUMTEXT -- stripped plaintext
created_at DATETIME -- recency ranking and date filters
indexed_at DATETIME
FULLTEXT KEY ft (title, body)
FULLTEXT KEY ft_title (title) -- needed to search filenames/subjects alone (Β§4.5)
KEY (source_type, source_id)
KEY (ticket_id)
An email body, an internal note and the text of a PDF are now the same shape. One index, one relevance scale, one query:
SELECT ticket_id,
MAX(MATCH(title,body) AGAINST (:q IN BOOLEAN MODE)) AS score
FROM search_documents
WHERE MATCH(title,body) AGAINST (:q IN BOOLEAN MODE)
AND (tenant_id IS NULL OR tenant_id IN (:scope))
AND (:include_internal = 1 OR is_internal = 0)
GROUP BY ticket_id
ORDER BY score DESCTwo things worth noticing. The permission predicate sits in the WHERE alongside the match, so Β§6 falls out of the design rather than having to be remembered. And the GROUP BY collapses hits to their ticket β users think in tickets, and without it one ticket with the term in four replies floods the first page with itself.
This duplicates body text. It does β but a stripped plaintext copy is needed anyway, because body_content is HTML and indexing it raw poisons every query (Β§3.1). Derived text is being stored either way; the only question is whether it lives in one table or three. One table wins on ranking and on having a single place where scope is expressed.
And because it's the same database, the copy is kept in step in the same transaction as the write β which is exactly the sync problem an external engine would have introduced (Β§7.2).
Because source_type is an ordinary column, the things a search UI needs are nearly free:
-
Search one kind of thing β just notes, just attachments β is
AND source_type = 'note'. -
Facet counts β "48 in notes, 12 in attachments" β is one
GROUP BY source_type. -
Attribution β every row already carries
source_type,source_idandticket_id, so a result can say "found in a note on ABC-123-45678" without a second lookup.
The ticket subject is its own row. source_type = 'ticket', with the subject as title. Burying it as the title of the first email would make "matched the subject" impossible to state cleanly and impossible to weight separately.
The result shape people actually want β grouped by ticket, each showing where it matched β is two bounded queries rather than one clever one:
1. rank tickets GROUP BY ticket_id, MAX(score), LIMIT 20
2. fetch details the matching documents for just those 20 ticket ids
Which yields "ABC-123-45678 β matched in: subject, 2 notes, 1 attachment". Doing it the other way round β fetch documents, collapse afterwards β reintroduces a top-N distortion, because the top 200 documents might collapse to only a handful of tickets.
MATCH() must correspond exactly to a FULLTEXT index. With one index over (title, body) you can only ever match both together. Searching filenames alone therefore needs a second index on (title). Cheap, but it has to be designed in rather than discovered.
LIKE than by full-text at all. invoice_2026-04_ACME.pdf tokenises badly, and people search fragments of a filename β precisely what full-text is weak at and LIKE '%printer%' is good at. Filename search and content search can reasonably use different mechanisms.
source_type. A few lines, but not something MySQL does for you.
The most concrete question anyone can ask of a search feature is "if the document says Tower of London, what do I have to type to find it?"
| Typed | Result | Why |
|---|---|---|
tower London |
β found | Word order is irrelevant β the index holds individual words, not phrases |
tower in London |
β found | "in" is an InnoDB stopword, dropped from query and index alike (and two characters, so below the token minimum anyway) |
"tower of london" |
β found | Boolean mode supports exact phrases |
Twoer of London |
β not found | No edit-distance matching whatsoever |
towers London |
β not found | MySQL does not stem β plural and singular are unrelated words to it |
fortress London |
β not found | No synonyms |
Word-order independence and stopword tolerance are not fuzzy matching β they're just how tokenisation works, and they cover a lot of everyday queries. What MySQL genuinely cannot do is forgive a misspelling or a word ending.
Cheap mitigation: append a trailing wildcard to each term in boolean mode β +tower* +london* β which picks up plurals and most word endings for nothing. The trade is over-matching on short stems (cat* reaches catastrophe), and leading wildcards are not supported, so *tower is impossible.
π This is the one honest argument for a phase 2 engine that isn't about scale. Typo tolerance is the headline feature of Meilisearch and Manticore β Twoer finds Tower there. Staying on MySQL trades forgiveness, not speed. Worth knowing that's the trade being made, rather than discovering it from a user asking why a search with one transposed letter found nothing.
Adding a source becomes an INSERT with a new source_type, not a new query path. Knowledge articles, change descriptions, problem records and time-entry notes can all join the same corpus without touching the search function or the permission model.
ts_headline), so snippets have to be generated in PHP from the stored plaintext, and escaped on output. Fine, but it is work, and it is the one place the corpus design doesn't save you anything.
Taking the request literally, item by item.
| Their question | Honest answer today | With phase 1 |
|---|---|---|
| "Is there already a way to enable full-text search for ticket content?" |
No. There is nothing to enable β no FULLTEXT index exists anywhere in the product. Search is LIKE over ticket number, address and subject, which matches their own description. |
Yes |
| "Is attachment indexing and searching currently supported?" |
No. email_attachments holds filename, path, size and mime only. No text is ever extracted. |
Yes, by format tier (Β§5.3) |
| "β¦are there plans to introduce a global search feature that can search across tickets, notes, comments, knowledge articles, and document attachments?" | Partly. The command palette already searches 7 modules β but titles and references only, never content. | The corpus (Β§4) is that feature's content half |
| Requested | Covered by | Notes |
|---|---|---|
| Ticket descriptions |
source_type = 'email' (first message) |
phase 1 |
| Ticket replies | source_type = 'email' |
phase 1 |
| Internal notes |
source_type = 'note', is_internal = 1
|
phase 1, and never returned to a portal user |
| "Comments" | same as notes β FreeITSM's term is note | phase 1 |
| Historical conversations | same rows | |
| Knowledge articles (their Q3) | source_type = 'kb_article' |
fits the corpus; see Β§5.4 |
| "All ticket-related content" | mostly | subject and bodies yes; time-entry notes and custom fields would be further source_types, cheap to add but not free |
| Requested | Tier | Reality |
|---|---|---|
| Text files | 1 β no dependency | trivial |
| Microsoft Word | 1 for .docx
|
legacy .doc is tier 2 |
| Excel spreadsheets | 1 for .xlsx
|
legacy .xls is tier 2 |
| PDF documents | 2 β optional extractor | text-based PDFs fine; scanned PDFs contain no text at all and need OCR |
| "Other common office document formats" | mixed |
.pptx and OpenDocument are feasible at tier 1; older binary formats are tier 2 only |
The honest headline for the reply: everything on their list is reachable, but PDF β the format they listed first β is the one that needs an optional extra service, and scanned PDFs need OCR on top of that. Anyone promising "PDF search" with no dependency is either shipping a fragile parser or hasn't met a scanned invoice.
Knowledge already has semantic search via embeddings (includes/knowledge/kb_ai.php). Putting articles in the corpus adds lexical search over the same content. That's a complement, not a replacement β one finds "tickets like this", the other finds "the article containing this exact error code" β but it does mean two search paths over one body of content, and the UI should be clear about which is which rather than silently picking.
The rule: filter inside the query, never filter the results afterwards.
The tempting design is "search, then remove what the user can't see". It breaks in four ways:
- Result starvation. The index returns its top 20 by relevance; you discard what the user can't see and hand back three, or none β while hundreds of documents they are entitled to see matched but never made the top 20. A portal user, who can see only their own tickets, would get empty searches almost every time.
- Pagination stops being coherent β offset 20 in the index isn't offset 20 after filtering, so paging produces gaps and repeats.
- Counts either lie or leak. A pre-filter total quietly tells the user how much exists that they cannot see.
- Over-fetching doesn't rescue it β there's no knowable multiplier, it differs per user, and for a tightly-scoped user the correct multiplier is the entire corpus.
The permission predicate goes into the query, as in Β§4.2. The corpus stores the scope keys as ordinary columns β company/tenant, ticket id, internal flag, visibility β and the search is one operation. Top-N is then correct, pagination works, counts are honest.
Crucially: FreeITSM computes the predicate; the index only applies it. Replicating policy into a second system is dangerous. Passing a computed filter to a store is ordinary. That distinction is what makes an external index safe later (Β§9).
includes/knowledge/kb_ai.php already works this way and is worth copying β it applies scope in the SQL before scoring, and even its "are there any embeddings?" check is scoped, because counting install-wide would take the vector path on the strength of rows the caller can't see.
Today an attachment's text is protected by the ticket it hangs off: the only route to it is a download endpoint that checks ticket access. Extracting that text creates a searchable copy that lives outside that check. Get the gating wrong and someone finds the contents of a payslip via a search snippet without ever having access to the ticket.
Two consequences that are easy to miss:
- Snippets are content. A highlighted fragment must come from the same scoped row that passed the filter β never from a cache or a shared store.
- Deletion must cascade. When a ticket is deleted, or a GDPR erasure runs, the corpus rows have to go too. A "deleted" ticket that is still searchable by its contents is a data-protection incident, not a bug. (Erasure tooling is itself still outstanding β see the F11 finding.)
Inbound email is unauthenticated, so the extractor parses attacker-controlled files by definition.
-
UPLOAD_MAX_BYTESis 10 MB of compressed file. XML compresses ten to one routinely and far more with repetitive markup, so a small.docxcan hold a gigabyte of XML. The existing parser callsgetFromName(), which decompresses an entry into a string with no size check at all. - A legitimate 200-page document is a problem too: that parser then builds a full DOM from the whole string, several times the XML's size, against a typical
memory_limitof 128 M. A PHP fatal is served as HTTP 200 with a broken body, so it fails quietly.
Mitigations: stream with XMLReader rather than DOMDocument (constant memory whatever the size); enforce a hard byte ceiling while reading the zip entry, treating the size declared in the zip header as attacker-controlled and not to be trusted; cap the extracted text; and run extraction in a process whose death doesn't matter.
An external extractor helps here for a reason that isn't obvious: it moves hostile parsing out of the PHP process entirely, so a zip bomb kills a container that restarts rather than the mailbox poller.
Worth stating explicitly: extracted text is plaintext in the database, derived from a file that may have been sensitive, access-controlled, or even password-protected. It inherits the database's protections, not the file's. That's an acceptable trade for a search feature β but it should be a decision, not a side effect.
The idea: embed attachment content and search by similarity. Rejected on four grounds, in order of weight.
- It doesn't remove the extraction problem β it adds a step. Embeddings take text as input. Something still has to open the zip, survive the 200-page document and solve PDF. Every hazard in Β§6.4 is unchanged.
-
It doesn't scale in this stack.
kb_ai.phpselects every in-scope row including the full embedding JSON, decodes each in PHP and computes cosine in a loop. Correct for a few hundred Knowledge articles. But a 200-page document can't be one vector β token limits force chunking, so it becomes hundreds of vectors of 1,536 floats each. MySQL 8.0 has no vector type and no approximate-nearest-neighbour index (that's 9.0+), so there is no fix short of an external vector store. - Semantic search is weakest at exactly these queries. People search documents for serial numbers, error codes, PO numbers, a person's name, an exact contract phrase. Embeddings capture aboutness and are lossy by design β ask for a serial number and you get documents vaguely about hardware. The request was find the document, not find documents on a similar theme.
- Privacy. Embedding every attachment means sending every customer document to a third-party API, on a product sold as self-hosted. Every AI feature in FreeITSM today is opt-in, per-feature, bring-your-own-key β but search is core. If search only works once you've shipped everything to OpenAI, search has silently become an AI feature.
β Where vectors are right: "have we seen this before?" across ticket bodies, where wording differs but the problem matches. That's the Knowledge gap-analysis pattern already in the product β a good future feature, and a different one.
Elasticsearch/OpenSearch, Solr, Manticore, Meilisearch and Typesense are all real options. As a first move they're wrong, because:
- They don't extract anything either. You'd still need the extractor, so it's two extra services rather than one.
- They introduce a sync problem that MySQL doesn't have. Dual-write, reindex tooling, stale detection and a bootstrap path for existing installs β none of which exists when the corpus lives in the same database and is written in the same transaction.
- The benefits (typo tolerance, faceting, ranking at scale) only pay off at volume most installs will never reach.
Covered in Β§6.1. It is the intuitive design and it is wrong; it fails worst for the least privileged users, which is also the group least likely to be testing it.
FreeITSM has no Composer and vendors by hand, so a bundled parser becomes ours to patch for its lifetime β and a bundled library version was itself a finding in the August 2026 security review. More importantly, ext/imap vanishing in PHP 8.4 already demonstrated what a hard dependency costs when it disappears. Anything of this shape must be optional and degrade honestly.
There are two entirely separate optional services in this plan, doing unrelated jobs. Conflating them is the most likely way to misunderstand the whole page.
| Optional service | Job | Likely uptake |
|---|---|---|
| Extractor (e.g. Apache Tika) | Reads PDFs and legacy Office files, turning them into plain text | Common β it's the only route to PDF search |
| Search engine (e.g. Manticore) | Searches faster, once the corpus is enormous | Rare β most installs never need it |
They are independent. The expected shape for an install that wants the full feature is extractor, no engine.
And on the engine specifically, three properties matter more than the technology:
- Off by default, chosen on a settings screen. It is a setting, not a version β nobody is upgraded into it, and nobody has to refuse an upgrade to avoid it (Β§8.3).
- No data moves. Tickets, notes, attachments and the corpus all stay in the FreeITSM database. The engine holds only a derived copy for fast lookup β a book's index versus the book. Remove the index and the book is untouched.
- Reversible. Switching it off falls back to MySQL with nothing lost, because the corpus is the source of truth and the engine's copy is rebuildable from it (Β§9.2).
π A correction to the brief. The natural way to describe this is "a settings screen where you configure where things go" β but the extracted text and the corpus must not be among the things it relocates. They stay in the FreeITSM database. What's configurable is which extractor runs and which backend answers queries. Β§9.2 explains why moving the text is the one change that would create a genuine trap.
| Setting | Options | Default |
|---|---|---|
| Extractor | none Β· built-in PHP only Β· external service (URL) | built-in PHP |
| Search backend | MySQL full-text Β· whichever engines are supported (Β§8.5) | MySQL full-text |
| Extraction caps | max file size, max extracted text, per-file time budget | conservative |
| Backfill | off Β· run once Β· scheduled | off |
- Nothing extra β PHP extraction for docx/xlsx/text, MySQL full-text. Runs on any PHP-capable host, which is most installs.
-
+ an extraction service β PDFs, legacy Office formats, OCR for scanned documents. Apache Tika is the obvious candidate: Apache-2.0, a simple HTTP API (send bytes, get text), around a thousand formats. Needs Docker or a JVM.
docker-compose.ymlcurrently runs two services, so this is a small, optional third. - + an external search engine β scale, typo tolerance, faceting. Manticore is the most natural fit for this stack because it speaks the MySQL wire protocol, so the existing PDO connection works with no new client library.
Each tier is additive and opt-in. An install that stops at tier one has no unused tables β the corpus is identical either way, so the data model never forks.
The failure to avoid is Phase 2 arriving as an upgrade that carries everyone into it. Both backends ship in the same codebase behind one interface β not a fork, not a download β so an install can switch, try it, and switch back, and nobody is stranded on an old version to avoid it.
This is existing house style: bring-your-own-key AI, the CA-bundle fallback to a shipped cacert.pem, and the rejected-attachment keep-or-drop setting all work this way.
The instinct is to add cards under System β Integrations, one for OpenSearch, one for Manticore, and so on. That's the wrong shape, for two reasons.
Search is singular; trackers are plural. Jira and Azure DevOps can both be connected at once, because a ticket can escalate to either β so a card each is honest. Only one thing can answer a search query. Cards per engine would imply you can run two side by side, and the UI would be describing a model that doesn't exist.
A search engine isn't an integration in that sense. Integrations are external systems you hold an account with and talk to as a peer. A search engine is infrastructure β the same family as the database, the CA bundle or the mail server. Nobody has an "OpenSearch account"; it's a component of your own install.
The right precedent is already in the product: the mailbox modal. You choose a provider β Microsoft 365 / Google / IMAP β and the provider-specific fields appear. One screen, one selector, conditional detail:
System β Search
Document extraction
[ none | built-in only | external service ] β URL, timeout if external
Search backend
[ MySQL (built-in) | β¦ supported engines β¦ ] β host, port, credentials if external
Index status
12,431 documents Β· last built 3 minutes ago Β· [ Rebuild ]
Two sections because extraction and searching are independent choices (Β§8.0) β the common configuration is an extractor, with the backend left on MySQL.
Offering admins a choice is right, and this codebase has already proved the pattern twice β AI providers (Anthropic / OpenAI / OpenRouter) and issue trackers, where adding Azure DevOps as the second provider required no schema or core change. There's a real argument beyond preference, too: "we already run Elasticsearch" is common in larger organisations, and telling that admin to stand up a second search system alongside it cuts against the whole no-lock-in position.
The surface is small enough to make it plausible. A backend needs exactly four operations β upsert a document, delete a document, query with filters returning ids and scores, and bulk rebuild. No faceting, no aggregations, no highlighting (snippets are generated in PHP anyway, Β§4.7). At that surface each adapter is plausibly 150β250 lines of PHP with no client library: Manticore over the MySQL wire protocol via existing PDO, the rest as JSON over curl. OpenSearch forked from Elasticsearch 7.10, so for a surface this small one adapter likely serves both.
π΄ But the real cost isn't lines of code β it's that every adapter is a new place authorisation can fail open. Each one translates the permission predicate into a different filter syntax: Manticore's SQL WHERE, OpenSearch's bool.filter.terms, Meilisearch's filter expression. A translation bug doesn't raise an error β it returns rows the user should never have seen, and a wrong search result looks exactly like a right one. That makes search adapters sharper than AI adapters, where wrong output is visibly wrong. It is Β§6 all over again, with a fresh surface per engine.
Secondary costs: the engines disagree on typo tolerance, stemming, phrase syntax and wildcards, so capability drift multiplies Β§9.5's bifurcation risk; and with no CI in the repo, verifying each adapter means standing it up and testing by hand.
So: design the interface for many, ship one, add on demand.
- Designing for N costs nothing beyond the seam already required (Β§9.1, Β§9.3).
- The first real adapter is what proves the contract. Build three against an untested interface and you rewrite three when the predicate turns out to need a shape nobody anticipated. Jira β Azure DevOps is the precedent: one provider proved the contract, the second confirmed it held.
β οΈ Every adapter must pass the same permission test suite before it ships β a fixed set of "this user must see exactly these ids" cases that every backend answers identically. That is what makes admin choice safe rather than merely generous.
Three rules make the backend swappable. They cost almost nothing up front and are expensive to retrofit.
Every caller goes through a single function taking a query plus a scope, returning ranked {type, id, score, snippet}. If MATCH β¦ AGAINST appears in six endpoints, changing engines is a rewrite. In one file, it's writing a second implementation of one file.
This is the anti-brick-wall rule. If the only copy of extracted text lives inside the search engine, changing engines means re-extracting every attachment β re-running an extractor over tens of thousands of PDFs, possibly with OCR, possibly on files since archived. That's a project. With the text in search_documents, rebuilding an index is a SELECT loop, against any engine, forever.
A SQL fragment welds the interface to MySQL. A plain structure β tenant ids, visibility flags, ticket ids β is translated by each backend into its own filter syntax. Same predicate, two translations, one source of truth.
- Ranking order changes between engines. Results reshuffle and users notice.
-
Query syntax differs. So keep the user-facing language deliberately small β words,
"quoted phrases",-exclusionβ and translate it inside the adapter. Expose raw engine operators in the UI and the engine has leaked into the product. - Sync machinery is genuinely new work in phase 2. Good design saves rewriting the callers, not building the reindex job.
- MySQL's quirks vanish β the three-character minimum, the stopword list β which is an improvement, but it is a behaviour change.
Build the seam; ship only the MySQL backend. Each further backend is a permanent maintenance tax β every search feature afterwards is implemented once per backend or built to the lowest common denominator. Β§8.5 covers how many to support once that day comes, and why the answer is design for many, ship one, add on demand.
Instrument the search function from day one β query, result count, elapsed time. The trigger to migrate should be a graph, not a hunch. What bites first is rarely raw row count; it's concurrency, wanting accurate totals, and complex boolean queries combined with narrow filters.
Stated separately from the design, because confidence isn't uniform across it.
- Relevance quality is MySQL's weakest area β no BM25 tuning, no real field weighting, no fuzzy matching. It will find the right documents; whether the best one ranks first is much less certain, and users are calibrated by Google. Β§4.6 sets out exactly what it will and won't match β the no-stemming limitation in particular is a definite, knowable gap rather than an uncertainty, and the trailing-wildcard mitigation should be tested for over-matching before it's adopted.
- The three-character minimum could be the difference between "good" and "why doesn't this find anything" in a product where people search short codes and error fragments. It's a documented caveat, not a solved problem.
- Behaviour at real install volumes is untested.
The cheap way to settle all three before committing: build the corpus table and the search function only β no UI, no extraction. Populate from real data, then run real queries: a serial number, an error code, a phrase from a note, a surname. That is a short spike, and it answers the relevance question with evidence rather than argument, before any of the surrounding work is committed to.
- History β backfill existing tickets and attachments, or index from switch-on only? (Β§3.5) The discussion explicitly asked for historical conversations, so this one has a customer waiting on it.
- Should searches be audited? Staff searching customer content is arguably an auditable event; nothing logs it today.
- Do portal users get content search at all, or only over their own tickets β and is that a setting?
-
Knowledge articles: one search box or two? Lexical in the corpus and semantic in
kb_ai.phpanswer different questions over the same content (Β§5.4). - Encrypted or password-protected attachments β skip and mark, presumably, but it should be explicit.
- What ranks a ticket? A match in the subject, the first message, and a five-year-old note are not equally interesting, and a naive index treats them alike.
Phase 1 is genuinely buildable and would answer most of what was asked in #53. It's parked rather than scheduled because the shape of the work is decided but the scope isn't: the history question (Β§3.5) and the portal-users question (Β§11, item 3) both change what gets built, and the security surface in Β§6 deserves the same standard as the August 2026 review rather than being bolted on.
Phase 2 is parked more firmly, and should stay that way until a real install hits a real wall. The seam is worth building early precisely so that day is a one-file change rather than a rewrite.
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)