Search: entity-type dispatch and read alias (factory, grouped poller, registry)
The incremental pipeline was artifact-only in three places: enqueue hardcoded entity_type = 'artifact', the poller hardcoded ArtifactDocumentBuilder, and the read path passed the literal 'artifacts'. This makes all three dispatch on the entity type instead. No new entity is added here, so nothing about artifact behavior changes, it's the seam the four Phase 2 entities plug into.
DocumentBuilderFactory maps an entity type to the builder for it, one entry today. It holds factory closures rather than builders so every lookup returns a fresh instance, keeping the existing rule that a builder's caches never outlive the cycle that made it. An entity type nobody registered throws, which parks the row for the watchdog instead of quietly marking it done with nothing indexed.
The poller groups claimed rows by entity type and runs the existing build/push/attribute body once per group, with that group's builder and index. The per-id outcome maps are local to a group: an id only identifies a document together with an entity type, so artifact 5 and publication 5 are different documents, and sharing the maps would let one type's failed id retry another type's indexed one. A group that throws is logged and otherwise left alone, so its rows stay processing for the watchdog with no retry burned and nothing marked done, while sibling groups still commit. An artifact-only batch is a single group, which is the Phase 1 path exactly.
Capture side: enqueue() and enqueueCascade() take an entity type defaulting to 'artifact', so all 22 existing call sites are untouched. On enqueueCascade the parameter is the target, not the source: the source is already in the event type, so a publication edit that reaches artifact documents stays entity_type = artifact, event_type = cascade_publications. The claim query and its hydration now carry entity_type through, which the grouping needs, and expanded cascade children inherit their parent's.
Read path: SearchAliasRegistry names the index each entity type is searched in, and the two ElasticSearchQuery constructions in SearchController go through it instead of the literal. Same string, so no request or result changes. The names are read aliases by intent, so an index can later be rebuilt under its alias without touching the query side; artifacts is still a concrete index today and querying it works either way.
One source for the index name. The registry is the only place the PHP application writes an index name now. ArtifactDocumentBuilder::indexName() asks it for the artifact alias instead of holding its own INDEX_NAME literal, and the admin _doc view drops its hardcoded /artifacts/_search for the same lookup. Every string is unchanged, so the poller writes to and the read path queries the index they did before. A builder registered for an entity type with no read alias, or one whose index name disagrees with its read alias, now fails a test instead of indexing into a name nothing queries.
Cascade target. CascadeResolver::resolve() takes the target document type, defaulting to 'artifact', and the poller passes the claimed row's entity_type. The sixteen existing branches move under the artifact target with their SQL unchanged, so every resolved id set is the same. This closes a gap where expansion forwarded the parent's entity type to its children while resolution ignored it, which would have handed artifact ids to a row targeting another document type. Which artifacts a change touches is still the only mapped question; a region change also rebuilding provenience documents is Phase 2 work.
Deletes need no new event type. A retired or absent id already maps to ['_action' => 'delete'] in the builder, and the indexer turns that into a bulk delete that's idempotent on an already-absent document. is_public is carried on the document and is never a delete trigger, so unpublishing re-indexes with the flag and read-time filtering handles it. Worth recording: a raw hard-delete of an artifacts row outside the UpdateEvent workflow isn't captured, but there's no such path today, and if one gets added the fix is an enqueue at that call site rather than a pipeline change.
Not in this MR: any new entity builder, index/mapping/alias creation, and CascadeResolver's queries (it gains a target parameter, not a new query). ReindexReconcileCommand stays artifact-only, and its coverage check matches outbox rows by id with no entity-type filter, so that needs one before a second entity ever enqueues. ArtifactDocumentBuilder's document output is unchanged; the only edit to it is indexName() reading from the registry, so the indexed documents stay identical.
Testing
New tests cover dispatch to the right builder and index, a failing entity type parking only its own rows, the same id failing in one type's index while succeeding in another's, the entity-type parameter on both enqueue paths, cascade children inheriting their parent's type, the claim surfacing the column, and the alias registry. The delete path gets a unit case plus an end-to-end one that indexes a document, retires the artifact, and checks it's gone. Also covered: every registered builder writing to the index its entity type is read from, the artifact target being the resolver's default, an unmapped target throwing, and expansion resolving for the row's own entity type.