Search: publications indexed, captured, and served from the engine
Publications are searched today with raw LIKE filters against MariaDB, one per form field, and none of the search-engine machinery the artifact pipeline gained applies to them. This MR takes the entity through that pipeline end to end: an index built from PHP, kept fresh by the outbox and poller, serving the /publications page, with the database staying as a live fallback. It is the first entity whose corpus is well past the 10,000 rows an id-set page can carry, so it is also the first on the sorted-page shape: the engine matches, orders and pages, and the database hydrates the ids it named.
The document and its index. Sixteen flat fields per row, declared under the artifacts mapping's conventions: text plus a bounded keyword, no analyzers. The five _fold fields are what the LIKE filters are mirrored onto, folded by DiacriticsHelper::foldPrecomposed(), which folds a precomposed accented letter and leaves a standalone combining mark alone. That is what LIKE under utf8mb4_unicode_ci does, and it is why the suffix is not the artifact document's _ascii. bibtexkey_sort and designation_sort carry MariaDB's own collation weight string for the expressions the page orders by, so the engine reproduces the database's ORDER BY. Publications hard-delete, so a document whose row is gone is removed by a delete marker at capture time, and the clean rebuild is a fresh _vN plus an alias swap.
Capture. Three source tables. An approved publication change writes a direct outbox row carrying the id, so a delete needs no lookup after the row is gone. authors fans out to both artifact and publication documents, and authors_publications is a cascade child of publications. UpdateEventsController is unchanged: one OWN_DOCUMENT_ENTITY_TYPES line turns a publication approval into a direct row. A completed merge carries every deleted id and the survivor, and a test pins it.
Two correctness fixes ride that work. Both Authors edit forms, the admin one and the self-service one, now patch through an explicit field list: publications is an accessible field with an inverse BelongsToMany behind it, so a POST carrying that key rewrote the author's publication links under the default replace strategy, and a link removed that way is gone before any cascade can see it. And ReindexReconcileCommand's author repair writes one outbox row per document type the author dirties instead of one; what its sweep looks for stays artifact-only.
The read side. /publications asks OpenSearch for one ordered page of ids, the exact total and both facet bucket sets in a single _search, then lets MySQL hydrate those ids in the order the engine named. The total is track_total_hits, so the last-page link is right rather than capped at 10,000. Sorting, paging and validation stay the base paginator's, so ?sort=title, ?page=0 and ?limit=5000 behave as they always did. The entry type and journal dropdowns become facets counted from the same request that returned the rows, with their labels still resolved from the database. A request naming one of the seventeen columns reachable only through the controller's catch-all LIKE takes the database path in full, so a mixed request such as ?title=x&year=1900 is byte-identical to today.
The measured divergences are pinned, thirty-nine in all, each an exact id list with the condition that retires it, asserted in both directions by a harness that derives its corpus from the live tables and runs every set through both paths. Thirty-seven are the collation comparing something the engine compares literally: it treats any combining mark as equal to any other and to none, and it equates a superscript or subscript with its base character. Those retire when the affected rows are normalised. The last two are the year range, where the database compares a varchar case-insensitively and the engine compares UTF-8 bytes, so three rows whose year is Unpublished or TBD fall on the other side of nd; that pair retires with a collation weight key for year. Two differences are intended rather than pinned: the author and artifact filters count join rows on the database path and distinct publications on the engine path, and rows move inside designation tie groups. The counts behind all of this are in the second comment.
Failure handling splits deliberately. An unreachable engine logs one warning and serves the same search from the database, dropdowns restored to their full lists. A refused query surfaces as an error instead, because a malformed body or an alias nobody created is a bug in this code and plausible rows from the database would leave it running unnoticed. A filter value at or past 300 folded characters routes to the database before the engine is asked anything, since a wildcard over a longer value is refused by the engine's automaton budget. A page past the end is still a 404.
What changes on purpose. The two join filters' totals drop from join rows to distinct publications, which is a visible change to the number at the top of the page. Rows move inside designation tie groups, including across page boundaries. Both dropdowns become counted facets on the engine path and stay full lists on the fallback path.
Not in this MR
No analyzer work: no diacritic folding for atomic letters, no non-ASCII case folding, no Unicode normalization on either side. Those belong to issue #2018, and the pins keep the difference visible until then. The seventeen catch-all columns stay on the database path that serves them today. Two follow-ups: bringing proveniences onto this entity's fold, together with the unguarded wildcard ceiling that entity still has, and require_alias=true on the write path. No artifact read-path change, and no data is normalized in passing.
Runbook
Create and swap before the first read: search_index_create --entity publication, then search_index_swap --entity publication --alias both --version 1, then search_backfill --entity publication, which needs php -d memory_limit=1G under the container's 128M default. The sort keys are a MariaDB collation artifact, so a collation change reorders every page with no symptom short of a user noticing: PublicationDocumentBuilderTest::testTheWeightStringsStillComeFromTheColumnsOwnCollation is the trigger, and the response is a fresh _vN plus a reindex. max_result_window is 50,000 and is a dynamic setting, so an index created before this MR needs one PUT /_settings before the read alias moves to it, or page 101 onward is a 500. A filter value at or past 300 folded characters is served from the database, which is invisible except as latency. NATURAL_SORT_KEY() is MariaDB 10.7 and up.