Search: require an alias on every push, bound the provenience wildcard, and add a status page

Two behaviour changes and one new page. Everything else is a test that did not test what it claimed, or a number that had two definitions.

Every push now goes through an alias. The indexer posted to /{index}/_bulk with no require_alias, so a push to a write alias that did not exist returned "errors": false with per-item 201 created and silently created a concrete index of that name with a dynamic mapping; the poller marked the row done, nothing logged, and the alias could never be bound afterwards. With require_alias=true the same push returns a per-item 404, which the indexer's per-id attribution turns into a retry and then a park as failed with the engine's text in error. Existing callers are unaffected: both production sites take their target from writeAliasFor() and search_backfill has no --index. The flag also refuses a concrete index name, so every write target from here on has to be an alias; any writer added later binds its alias first.

An over-long provenience value renders an empty page instead of a 500. ProvenienceSearchQuery shipped its wildcard clause without the ceiling the other filtering entities carry; at 315 characters the engine refuses to compile the automaton, which classifies as a query error, and the page 500'd where the database renders an empty one. The class now emits match_none at 300 compiled pattern characters, the constant on ProvenienceIndexMapping where PublicationSearchQuery already keeps its own. The ceiling measures the compiled pattern, not the typed value, since escaping moves the length both ways; a test pins both directions. One divergence is accepted: at exactly 300 the database could match a stored name of that length while the engine answers empty. The direction is fewer rows and never more, the longest stored name is 45 characters, and a #[Group('db')] assertion reddens the day that stops being true.

The new page. /admin/search-status, for an operator who is not on the command line. Per entity: the read and write alias, the concrete indexes, which alias points at which, and each index's document count. Below that, the outbox: rows per status, stale pending rows, processing rows locked past the watchdog timeout, and the dead-letter count. Display only: no form, no button, one declared action, held by a test; the page names the commands that do the work. Administrators only (the class is deliberately not named Search, which sits in RequestAccessComponent's editor allowlist). Each entity row renders the error it got, so a dead engine shows five errors instead of a 500, with a ten-second timeout pinned by a test.

Screenshot 2026-08-22 at 2.02.23 PM.png

This is the resized remainder of #2615. The lifecycle and backfill commands shipped in !1270 and !1272, and !1272's tail re-enqueue removed the reason to pause the poller during a rebuild. This page replaces the issue's progress UI, resized from progress to status, since a rebuild runs from the command line and reports its own progress. Staying out: resume and checkpoint, the poller pause, and any browser-driven reindex loop. The fallback audit is closed for four of the five controllers by !1274, !1277, !1281 and !1283; the fifth is the artifact read path, which the engine-selection work owns.

Two agreement tests, for invariants held until now by memory. One pins each query class's guard against every parameter its body can carry, including a source scan for the spelling routes a constant-to-constant pin cannot see. The other checks that every controller test file has its <file> line in the phase2 suite: the suite names them one by one, so a file nobody lists never runs, and the suite still reports OK.

Test hygiene. ReindexPollerE2ETest identified outbox rows by table position in all seven of its reads, so a row left by an interrupted run read as a poller regression; the reads now key on what the test enqueued. A shared preflight runs ahead of every end-to-end file's guards and names leftover state it finds (an index, an alias, outbox rows, reserved-range rows), so an unclean stack reads as one. One removeBehavior('Orderly') where the behaviour silently supplied the ordering the code under test was supposed to. And the outbox health numbers move to one definition on ReindexOutboxTable, read by both reindex_poller_health and the page; the command's own tests are the preservation gate and did not change.

Not in this MR

The #2615 items above. Admin/SearchController, which reads the legacy Elasticsearch URL and has no error handling, which belongs with the work that moves the artifact read path onto OpenSearch. CollectionSearchQuery's wildcard ceiling, which sits on the query class rather than its mapping and is now the only placement not following that rule; moving it is behaviour-neutral and that file is not otherwise in this diff.

Runbook

Nothing to run. No migration, no index change, no mapping version bump.

Edited by sung

Merge request reports

Loading
Loading