Fix #27107: soft-deleted users still appear in Experts/Reviewers across all entities#27120
Fix #27107: soft-deleted users still appear in Experts/Reviewers across all entities#27120
Conversation
…ss all entities Two bugs caused soft-deleted users to leak into experts/reviewers/owners/followers: 1. EntityRepository.resolveRelationshipEntityReferencesByType hardcoded Include.ALL for the consolidated bulk list path, affecting ALL entities (DataProduct, Domain, GlossaryTerm, Glossary, Table, Schema, Service, Container, etc.). Fixed by switching to getEntityReferencesByIdsRespectingInclude(..., NON_DELETED). 2. EntityResource.getInternal / getByNameInternal built RelationIncludes(null, ...) which defaulted to Include.ALL, causing single-entity GET on DataProduct, Domain, EventSubscription, Query to return deleted users in relation fields. Fixed by defaulting null include to Include.NON_DELETED for all resource endpoints. Also cleaned up dead-code in DataProductRepository.batchFetchExperts and DomainRepository.batchFetchExperts which used getEntityReferenceById that silently overrides NON_DELETED to ALL. Adds regression tests in DataProductResourceIT, DomainResourceIT, GlossaryTermResourceIT covering both single-GET and list-endpoint paths.
There was a problem hiding this comment.
Pull request overview
Fixes soft-deleted users appearing in relationship-backed fields (Experts/Reviewers/Owners/Followers/etc.) by ensuring relationship reference resolution defaults to Include.NON_DELETED in both list and single-entity GET paths, and adds integration coverage to prevent regressions.
Changes:
- Default
nullincludetoInclude.NON_DELETEDwhen buildingRelationIncludesinEntityResourceGET flows. - Update bulk relationship reference resolution to use
Entity.getEntityReferencesByIdsRespectingInclude(..., Include.NON_DELETED)so soft-deleted related entities are filtered out. - Refactor Domain/DataProduct expert batch loaders to batch-resolve user references while respecting soft-delete, plus add integration tests covering list + single GET behavior.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| openmetadata-service/src/main/java/org/openmetadata/service/resources/EntityResource.java | Ensures RelationIncludes defaults to non-deleted when include is absent, fixing single-GET exposure in affected resources. |
| openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java | Filters soft-deleted related entity references during bulk field hydration for list endpoints. |
| openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DomainRepository.java | Batch-fetch experts via include-respecting reference resolution and skip deleted users. |
| openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DataProductRepository.java | Same expert batch-fetch fix as DomainRepository for DataProducts. |
| openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryTermResourceIT.java | Adds regression test ensuring soft-deleted reviewers don’t appear in list responses. |
| openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DomainResourceIT.java | Adds regression tests for soft-deleted experts excluded from single GET and list. |
| openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DataProductResourceIT.java | Adds regression tests for soft-deleted experts excluded from single GET and list. |
…ding NON_DELETED - Add Include parameter to resolveRelationshipEntityReferencesByType, fetchAndSetRelationshipFieldsInBulk, and fetchAndSetFields (new 3-arg overload) - Add setFieldsInBulk(fields, entities, Include) overload in base class; existing 2-arg delegates to NON_DELETED - The Include is no longer hardcoded — callers with request context (e.g. include=all) can pass it through via the 3-arg setFieldsInBulk or fetchAndSetFields Fixes: #27107
… Domain DataProductRepository.setInheritedFields and DomainRepository.setInheritedFields both fetched parent Domain entities with Include.ALL, causing the domain's experts and owners fields to be resolved with ALL include — returning soft-deleted users. Changed to NON_DELETED so inherited fields only carry active users. Fixes: #27107
…ll entities Five additional bug sites using Include.ALL when resolving user relationships: - EntityRepository.batchFetchOwners: getEntityReferencesByIds → getEntityReferencesByIdsRespectingInclude(NON_DELETED) - EntityRepository.batchFetchReviewers: same fix - EntityRepository.batchFetchExperts (base class): same fix - TagRepository.setInheritedFields: fetch Classification with NON_DELETED (was ALL), preventing soft-deleted owners/reviewers from being inherited by Tags - WorksheetRepository.setInheritedFields: fetch Spreadsheet with NON_DELETED (was ALL), preventing soft-deleted owners from being inherited by Worksheets Fixes: #27107
| public void setFieldsInBulk(Fields fields, List<T> entities) { | ||
| setFieldsInBulk(fields, entities, Include.NON_DELETED); | ||
| } | ||
|
|
||
| public void setFieldsInBulk(Fields fields, List<T> entities, Include include) { |
There was a problem hiding this comment.
The new setFieldsInBulk(..., Include include) overload isn’t used by the core list/batch paths (e.g., listAfter/listBefore and get(ids, ..., include) still call setFieldsInBulk(fields, entities), which hard-defaults to NON_DELETED). This makes list endpoints that accept include=all/deleted inconsistent with single-entity GET, where include affects relation loading via RelationIncludes. Consider plumbing the ListFilter/include value through to setFieldsInBulk so relationship references respect the requested include (while still defaulting null->NON_DELETED).
setFieldsInBulk, fetchAndSetFields, fetchAndSetRelationshipFieldsInBulk, and resolveRelationshipEntityReferencesByType now accept an Include parameter so that include=all list queries correctly surface soft-deleted relationship targets (owners, experts, reviewers) — matching the single-entity GET path behaviour via RelationIncludes. All existing 2-param overrides delegate to the new 3-param overloads with NON_DELETED, so all subclass repositories and the update hydration path are unchanged. All list/get callers with access to an Include value (listAfter, listBefore, listAll, listAfterKeyset, listWithOffset, get, getByNames) now forward that value through the chain.
| public void setFieldsInBulk(Fields fields, List<T> entities, Include include) { | ||
| if (entities == null || entities.isEmpty()) { | ||
| return; | ||
| } | ||
| try (var ignored = phase("fetchFields")) { | ||
| fetchAndSetFields(entities, fields); | ||
| fetchAndSetFields(entities, fields, include); | ||
| } |
There was a problem hiding this comment.
setFieldsInBulk(fields, entities, include) passes include straight through to fetchAndSetFields(..., include) / resolveRelationshipEntityReferencesByType(..., include). However include is allowed to be null in several call sites (e.g., new ListFilter(null) is used in multiple resources/background workflows), which will cause relationship reference resolution to effectively behave like Include.ALL (soft-deleted related users reappear) and may also propagate a null Include into repository DAO methods.
Consider normalizing include at the boundary (e.g., Include resolved = include != null ? include : Include.NON_DELETED;) and using resolved for the bulk relationship reference resolution path, so the default behavior remains “non-deleted” even when callers omit include.
… setFieldsInBulk dispatch The previous approach made callers invoke the 3-param setFieldsInBulk directly, which bypassed all subclass overrides of the 2-param version (GlossaryTermRepository, DatabaseRepository, TableRepository, etc. all override 2-param with essential hydration logic like populateParentAndGlossaryReferencesInBulk). This caused a regression where entities from those repos would be missing service references, parent references, and other custom bulk-hydrated fields. Fix: store the include in a static ThreadLocal before delegating to the virtual 2-param setFieldsInBulk (so all subclass overrides run), then the 2-param fetchAndSetFields reads bulkInclude.get() so it reaches fetchAndSetRelationshipFieldsInBulk with the correct value. The 3-param overload is now final to prevent further accidental overrides.
| public final void setFieldsInBulk(Fields fields, List<T> entities, Include include) { | ||
| Include previous = bulkInclude.get(); | ||
| bulkInclude.set(include); | ||
| try { | ||
| setFieldsInBulk(fields, entities); | ||
| } finally { | ||
| bulkInclude.set(previous); | ||
| } |
There was a problem hiding this comment.
include can be null (e.g., several callers build new ListFilter(null)), but this method stores the null in bulkInclude and the relationship bulk loader forwards it into Entity.getEntityReferencesByIdsRespectingInclude(...). For soft-deletable types like user, a null include means the deleted filter is not applied, which can re-introduce soft-deleted users in owners/experts/reviewers/followers (and may lead to inconsistent behavior vs API defaults).
Resolve include == null to a concrete value (likely Include.NON_DELETED, matching the resource-layer defaulting you added) before setting the ThreadLocal.
Callers like DomainResource/DataProductResource/PersonaResource build new ListFilter(null), so filter.getInclude() returns null. That null was stored in the bulkInclude ThreadLocal and propagated into getEntityReferencesByIdsRespectingInclude, bypassing the soft-delete filter for USER/TEAM types and re-introducing soft-deleted users in owners/experts/reviewers/followers on those list endpoints. Resolve null to NON_DELETED before setting the ThreadLocal. Add unit test covering null → NON_DELETED defaulting.
Review: Changes in
|
|
Thanks for the detailed review @sonika-shah. Addressing each point: 1. EntityResource null guardThe 5 resources that pass Without the guard, The guard is a no-op for the 70+ resources that declare 2. ThreadLocal default — keeping
|
|
The Java checkstyle failed. Please run You can install the pre-commit hooks with |
…leted-users-in-relations # Conflicts: # openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java
| (entityType, ownerIds) -> { | ||
| var ownerRefs = | ||
| Entity.getEntityReferencesByIds(entityType, new ArrayList<>(ownerIds), ALL); | ||
| Entity.getEntityReferencesByIdsRespectingInclude( |
There was a problem hiding this comment.
batchFetchOwners, batchFetchReviewers, and batchFetchExperts now always filter relationship targets with NON_DELETED, which can contradict the stated behavior that include=all list queries should surface soft-deleted relationship targets (to match single-entity GET behavior). If these batch loaders can run on list/bulk hydration paths, they should use the request’s Include (e.g., pass include into these helpers, or read the same resolved include used for bulk hydration) instead of hardcoding NON_DELETED.
| Entity.getEntityReferencesByIdsRespectingInclude( | |
| entityType, new ArrayList<>(ownerIds), ALL); |
| var reviewerRefs = | ||
| Entity.getEntityReferencesByIds(entityType, new ArrayList<>(reviewerIds), ALL); | ||
| Entity.getEntityReferencesByIdsRespectingInclude( |
There was a problem hiding this comment.
batchFetchOwners, batchFetchReviewers, and batchFetchExperts now always filter relationship targets with NON_DELETED, which can contradict the stated behavior that include=all list queries should surface soft-deleted relationship targets (to match single-entity GET behavior). If these batch loaders can run on list/bulk hydration paths, they should use the request’s Include (e.g., pass include into these helpers, or read the same resolved include used for bulk hydration) instead of hardcoding NON_DELETED.
|
|
||
| // Batch fetch all expert references | ||
| // Batch fetch all expert references, filtering out soft-deleted users | ||
| Map<UUID, EntityReference> expertRefs = |
There was a problem hiding this comment.
batchFetchOwners, batchFetchReviewers, and batchFetchExperts now always filter relationship targets with NON_DELETED, which can contradict the stated behavior that include=all list queries should surface soft-deleted relationship targets (to match single-entity GET behavior). If these batch loaders can run on list/bulk hydration paths, they should use the request’s Include (e.g., pass include into these helpers, or read the same resolved include used for bulk hydration) instead of hardcoding NON_DELETED.
| private static final ThreadLocal<Include> bulkInclude = | ||
| ThreadLocal.withInitial(() -> Include.NON_DELETED); |
There was a problem hiding this comment.
Using a static ThreadLocal to implicitly carry Include makes the include behavior non-obvious and more fragile (e.g., harder to reason about, and easy to bypass if any code path calls fetchAndSetFields(..., fields) without going through the setFieldsInBulk(..., include) wrapper). A more maintainable approach is to pass Include explicitly through the internal call chain (or centralize the implementation so both overloads call a single non-overridable method that takes Include) and avoid hidden thread-local state.
| private static final ThreadLocal<Include> bulkInclude = | |
| ThreadLocal.withInitial(() -> Include.NON_DELETED); | |
| private static final Include DEFAULT_BULK_INCLUDE = Include.NON_DELETED; |
| public final void setFieldsInBulk(Fields fields, List<T> entities, Include include) { | ||
| Include resolved = include != null ? include : Include.NON_DELETED; | ||
| Include previous = bulkInclude.get(); | ||
| bulkInclude.set(resolved); | ||
| try { | ||
| setFieldsInBulk(fields, entities); | ||
| } finally { | ||
| bulkInclude.set(previous); | ||
| } | ||
| } |
There was a problem hiding this comment.
Using a static ThreadLocal to implicitly carry Include makes the include behavior non-obvious and more fragile (e.g., harder to reason about, and easy to bypass if any code path calls fetchAndSetFields(..., fields) without going through the setFieldsInBulk(..., include) wrapper). A more maintainable approach is to pass Include explicitly through the internal call chain (or centralize the implementation so both overloads call a single non-overridable method that takes Include) and avoid hidden thread-local state.
| } | ||
| } | ||
|
|
||
| protected void fetchAndSetFields(List<T> entities, Fields fields) { |
There was a problem hiding this comment.
Using a static ThreadLocal to implicitly carry Include makes the include behavior non-obvious and more fragile (e.g., harder to reason about, and easy to bypass if any code path calls fetchAndSetFields(..., fields) without going through the setFieldsInBulk(..., include) wrapper). A more maintainable approach is to pass Include explicitly through the internal call chain (or centralize the implementation so both overloads call a single non-overridable method that takes Include) and avoid hidden thread-local state.
| protected void fetchAndSetFields(List<T> entities, Fields fields) { | |
| fetchAndSetFields(entities, fields, NON_DELETED); |
| Domain listed = null; | ||
| ListParams params = new ListParams().setFields("experts").withLimit(100); | ||
| while (listed == null) { | ||
| ListResponse<Domain> page = listEntities(params); | ||
| listed = | ||
| page.getData().stream() | ||
| .filter(d -> d.getId().equals(domain.getId())) | ||
| .findFirst() | ||
| .orElse(null); | ||
| String after = page.getPaging() != null ? page.getPaging().getAfter() : null; | ||
| if (listed != null || after == null) break; | ||
| params = new ListParams().setFields("experts").withLimit(100).setAfter(after); | ||
| } |
There was a problem hiding this comment.
This paging loop has no hard stop / cursor-repeat protection. If paging ever returns a non-null after that doesn’t advance (or cycles), this test can hang indefinitely in CI. Consider adding a max-iterations bound and/or tracking previously seen after cursors to fail fast with a clear assertion.
…ETED Root cause: Entity.java had an inverted ternary in getEntityReferenceById and getEntityReferencesByIds: // WRONG — forces ALL when type supports soft delete include = repository.supportsSoftDelete ? Include.ALL : include; // CORRECT — respects caller's include; falls back to ALL only when the // type has no deleted column and filtering isn't possible include = repository.supportsSoftDelete ? include : Include.ALL; PR #25284 worked around this by introducing getEntityReferenceByIdRespectingInclude / getEntityReferencesByIdsRespectingInclude with the correct ternary, but the original broken methods were never fixed — a permanent trap. This commit fixes the root and deletes the RespectingInclude pair (now identical after the flip). For the bulk-list path (GET /entities?fields=...) nested reference hydration is hardcoded to NON_DELETED unconditionally in resolveRelationshipEntityReferencesByType and all five batchFetch* methods (owners, followers, reviewers, experts, votes). The ?include= query param controls which top-level entities appear in the list; it does not change the semantics of nested relationship pointers, which should always resolve to live entities. For single-entity GET: EntityResource.getInternal/getByNameInternal had a latent null-include bug for resources (Domain, DataProduct, EventSubscription, Query) that don't declare @QueryParam("include"). Those null values defaulted to ALL inside RelationIncludes, leaking soft-deleted nested refs. Fixed by defaulting null → NON_DELETED before constructing RelationIncludes. Remove ThreadLocal bulkInclude and the include-threading approach added earlier in this branch — superseded by the root-cause fix and the NON_DELETED semantic above. Delete EntityRepositoryIncludeThreadingTest (tested the rejected approach). Tests: 7 integration tests covering single-GET and list endpoints for Domain, DataProduct, and GlossaryTerm; verify soft-deleted experts/owners/reviewers are absent from both paths, including with ?include=all on the list endpoint.
| Map<UUID, EntityReference> followerRefs = | ||
| Entity.getEntityReferencesByIds(USER, followerIds, ALL).stream() | ||
| Entity.getEntityReferencesByIds(USER, followerIds, NON_DELETED).stream() | ||
| .collect(Collectors.toMap(EntityReference::getId, Function.identity())); | ||
|
|
||
| records.forEach( |
There was a problem hiding this comment.
batchFetchFollowers now fetches follower refs with Include.NON_DELETED, which means followerRefs.get(followerId) can legitimately return null (e.g., follower user was soft-deleted). The current loop adds that null reference into the followers list, which can lead to null entries in API responses and downstream NPEs/serialization issues. Skip/omit the follower when the reference is missing (and consider de-duplicating followerIds to avoid unnecessary fetch work).
| } | ||
|
|
||
| public final void setFieldsInBulk(Fields fields, List<T> entities, Include include) { | ||
| setFieldsInBulk(fields, entities); |
There was a problem hiding this comment.
The new setFieldsInBulk(fields, entities, include) overload ignores its include parameter and simply delegates to the 2-arg method. Since callers were updated to pass include, this reads as if bulk field hydration depends on include when it currently does not. Consider removing this overload and reverting call sites, or (if include is meant to affect bulk hydration) plumb it through where needed instead of accepting an unused parameter.
| setFieldsInBulk(fields, entities); | |
| if (entities == null || entities.isEmpty()) { | |
| return; | |
| } | |
| for (T entity : entities) { | |
| setFields(entity, fields, include); | |
| } |
| Map<UUID, EntityReference> userRefs = | ||
| Entity.getEntityReferencesByIds(Entity.USER, new ArrayList<>(allUserIds), ALL).stream() | ||
| Entity.getEntityReferencesByIds(Entity.USER, new ArrayList<>(allUserIds), NON_DELETED) | ||
| .stream() | ||
| .collect(Collectors.toMap(EntityReference::getId, Function.identity())); | ||
|
|
There was a problem hiding this comment.
This change makes vote hydration filter voters with Include.NON_DELETED. There are new integration tests for owners/experts/reviewers, but I don't see coverage specifically asserting that soft-deleted users are excluded from votes in list/bulk responses. Adding an IT for the votes field (and similarly for followers) would help prevent regressions for the original issue scope.
…arg setFieldsInBulk, add follower/voter IT tests - batchFetchFollowers: add null-check before adding followerRef to list. When a follower is soft-deleted, getEntityReferencesByIds(NON_DELETED) omits their ID from the result map, so followerRefs.get(followerId) returns null. All other batchFetch* methods already guard this; batchFetchFollowers was the only one missing the check. - Remove the dead 3-arg setFieldsInBulk(Fields, List<T>, Include) overload that accepted include but silently discarded it, delegating to the 2-arg form. Revert all 6 call sites back to the 2-arg form. The overload was misleading — callers appeared to plumb include through but bulk hydration was never affected; NON_DELETED is hardcoded in resolveRelationship- EntityReferencesByType. - Add integration tests for followers and votes: softDeletedFollower_notReturnedInListEndpoint and softDeletedVoter_notReturnedInListEndpoint in DomainResourceIT. Both pass (5/5 DomainResourceIT soft-delete tests green).
|
…large CI datasets
Code Review ✅ Approved 5 resolved / 5 findingsImplements proper filtration of soft-deleted users in bulk operations and expert listings. This resolves issues with hardcoded deletion flags, duplicate map collisions, and improper include parameter propagation. ✅ 5 resolved✅ Bug: Hardcoded NON_DELETED ignores caller's include=all in bulk path
✅ Bug: Collectors.toMap without merge function may throw on duplicates
✅ Bug: setFieldsInBulk Include parameter not threaded from callers
✅ Bug: 3-param setFieldsInBulk bypasses all subclass overrides
✅ Quality: ThreadLocal used to pass Include through subclass overrides
OptionsDisplay: compact → Showing less information. Comment with these commands to change:
Was this helpful? React with 👍 / 👎 | Gitar |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java:2019
listInternal(...)now accepts anInclude includeargument but never uses it. This adds dead code / confusion and suggests the include-threading refactor wasn't fully cleaned up. Either remove theincludeparameter (and revert call sites), or use it for actual behavior if needed.
private List<T> listInternal(
List<String> jsons, Fields fields, UriInfo uriInfo, Include include) {
List<T> entities;
try (var ignored = phase("jsonDeserialize")) {
entities = JsonUtils.readObjects(jsons, entityClass);
}
try (var ignored = phase("setFieldsBulk")) {
setFieldsInBulk(fields, entities);
}
| ListParams params = new ListParams().setFields("followers").withLimit(1000000); | ||
| ListResponse<Domain> list = listEntities(params); |
There was a problem hiding this comment.
The test uses withLimit(1000000) to ensure the target domain appears in the list. This can make the test slower/flakier as the suite grows and can stress the list endpoint unnecessarily. Prefer paging (like the earlier experts tests in this file) or a smaller limit combined with cursor iteration.
| ListParams params = new ListParams().setFields("votes").withLimit(1000000); | ||
| ListResponse<Domain> list = listEntities(params); |
There was a problem hiding this comment.
The test uses withLimit(1000000) to fetch votes in one call. To keep integration tests performant and robust, prefer paginating until the entity is found (or use a smaller limit) rather than requesting an extremely large page size.
|
|
||
| private Iterator<Either<T, EntityError>> serializeJsons( | ||
| List<String> jsons, Fields fields, UriInfo uriInfo) { | ||
| List<String> jsons, Fields fields, UriInfo uriInfo, Include include) { |
There was a problem hiding this comment.
serializeJsons(...) was updated to take an Include include parameter, but the parameter is not referenced anywhere in the method. This looks like leftover plumbing from the removed include-threading approach; consider removing the unused parameter and updating callers to avoid misleading APIs.
| List<String> jsons, Fields fields, UriInfo uriInfo, Include include) { | |
| List<String> jsons, Fields fields, UriInfo uriInfo) { |



Summary
Fixes #27107 — soft-deleted users appear in `owners`, `experts`, `reviewers`, `followers`, and `votes` fields on bulk-list API responses (`GET /entities?fields=...`) across every entity type.
Root Cause
The bug traces back to an inverted ternary in two methods in `Entity.java` (`getEntityReferenceById` and `getEntityReferencesByIds`):
```java
// WRONG (was on main for both methods)
include = repository.supportsSoftDelete ? Include.ALL : include;
// Reads: "if the type supports soft delete, ignore caller's include, force ALL"
// That's backwards.
// CORRECT (this fix)
include = repository.supportsSoftDelete ? include : Include.ALL;
// Reads: "if the type has no deleted column, force ALL (filtering impossible); else respect caller"
```
History of workarounds
PR #25284 (Jan 2026) discovered this bug and worked around it by adding two new methods:
`getEntityReferenceByIdRespectingInclude` / `getEntityReferencesByIdsRespectingInclude`
with the correct ternary — but the original broken methods were never fixed. Two method pairs, same signature, one broken, one correct — a permanent trap.
Earlier in this branch, a ThreadLocal `bulkInclude` approach was tried: thread the `include` parameter from API callers through virtual dispatch into `setFieldsInBulk` → `fetchAndSetFields` → `fetchAndSetRelationshipFieldsInBulk` → `resolveRelationshipEntityReferencesByType`. This was another workaround layer on top of the existing one, not a root fix. Rejected and removed.
The Fix
1. Fix the root — flip both ternaries in `Entity.java`
The two entity-reference resolution methods now correctly respect the caller's `include` for soft-delete-supporting types, and fall back to `ALL` only for types without a `deleted` column (DOMAIN, DATA_PRODUCT, CLASSIFICATION, TAG, etc.) where filtering is not possible.
2. Delete the `RespectingInclude` pair
After the ternary flip, `getEntityReferenceByIdRespectingInclude` and `getEntityReferencesByIdsRespectingInclude` are identical to the originals. They're deleted. Call sites in `EntityRelationshipRepository` are migrated back to the original names.
3. Bulk-list resolver hardcodes `NON_DELETED` — semantic, not a hack
In `resolveRelationshipEntityReferencesByType` and all five `batchFetch*` methods (owners, followers, reviewers, experts, votes), nested reference hydration is unconditionally `NON_DELETED`.
Why this is correct, not a hack:
The ThreadLocal approach (deleted) was trying to pass the top-level `include` down to nested resolution, which is the wrong semantic regardless of implementation.
4. Fix null `include` on single-entity GET for some entity types
`DomainResource`, `DataProductResource`, `EventSubscriptionResource`, `QueryResource` do not declare `@QueryParam("include")`. When these endpoints are called, `include` is `null`, which inside `RelationIncludes` defaulted to `Include.ALL` — leaking soft-deleted nested refs on single-entity GET.
Fixed by defaulting `null → Include.NON_DELETED` before constructing `RelationIncludes` in `EntityResource.getInternal` and `getByNameInternal`.
Behavioral Changes vs Main
Files Changed
Test Plan
Known Out of Scope
Search-index propagation: soft-deleting a user does not update embedded `EntityReference` copies in other entities' Elasticsearch/OpenSearch documents. Explore-page / search-box results may still surface the soft-deleted user until a reindex runs. Separate PR.
Summary by Gitar
setFieldsInBulk(fields, entities, include)overload to simplify bulk field resolution.nullguard inbatchFetchFollowersto prevent NPEs when resolving follower references.Domainentities to verify soft-deletedfollowersandvotersare correctly filtered in list endpoints.This will update automatically on new commits.