fix(governance): resolve inherited fields freshly instead of from a never-cleared cache - #31133
fix(governance): resolve inherited fields freshly instead of from a never-cleared cache#31133harshach wants to merge 15 commits into
Conversation
The gate that decides whether an approval task is created (CheckGlossaryTermHasReviewers -> CheckEntityAttributesImpl) evaluated `some reviewers` over whatever the entity read returned, so it was correct only when that read had both requested and applied field inheritance. A glossary term that inherits its reviewers from its parent glossary could therefore be judged to have none, skip the approval task, and settle in Draft. Two sibling paths already resolve the chain explicitly -- setDefaultStatus and SetApprovalAssigneesImpl -- so "what status" and "who gets the task" were robust while "should there be a task at all" was not. TagRepository.setInheritedFields compounds the exposure by swallowing exceptions and silently skipping inheritance, leaving the same gate with an empty reviewers list. Add an EntityRepository.getEffectiveReviewers hook, overridden by GlossaryTermRepository (term -> parent term -> glossary) and TagRepository (tag -> classification), and have the gate resolve effective reviewers before evaluating the rule. The resolved reviewers are injected into the rule input map rather than onto the entity, which may be request-cached and must not be mutated. This covers both gates using the reviewers rule: GlossaryApprovalWorkflow and AIAssetApprovalWorkflow, and needs no workflow migration -- it fixes the data the rule sees, so already-customized workflow definitions benefit unchanged. Adds GlossaryTermInheritedReviewerApprovalIT covering direct, glossary-inherited, parent-term-inherited, inherited-with-owner-set, and remove-direct-reviewer fallback scenarios. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r entity type Replace the GlossaryTermRepository and TagRepository overrides with a single generic implementation on EntityRepository that walks getParentEntity -- the same hook the inheritance path already uses -- so any entity declaring an inheritance parent participates without type-specific code. GlossaryTermRepository already implements that hook as "parent term, else glossary", which is exactly the walk the removed override duplicated. Reading the parent applies the parent's own inheritance, so a glossary's reviewers still surface for a term nested under an intermediate term that has none. Covered by a new grandparent test. Resolution now goes through resolveInheritanceParentLeniently, which swallows EntityNotFoundException. This fixes a regression the previous commit introduced: the per-type lookups used Entity.getEntity(..., NON_DELETED), which throws when the parent is missing or soft-deleted, turning a previously silent skip into a BpmnError that aborted the approval gate and left the term with no task at all. TagRepository is reverted to its original state. Covering tags generically would require overriding getParentEntity there, which also feeds create-permission evaluation in CreateResourceContext -- a behaviour change out of scope here. Tags keep their existing read-path inheritance; their setInheritedFields still swallows exceptions and silently skips inheritance, which is worth addressing separately. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…to buildRuleContext Add CheckEntityAttributesImplTest, which drives the delegate against the rule GlossaryApprovalWorkflow.json actually ships and asserts the node result. Unlike the integration tests, the unit level can force the failing condition directly -- an entity read that returns no reviewers on the term while the parent supplies them -- so reviewersRule_inheritedReviewersOnly_evaluatesTrue is a genuine fail-without-fix test: reverting the gate to JsonUtils.getMap(entity) fails it with "expected: <true> but was: <false>", the exact false that routes a term to Draft. Also covers direct reviewers, no reviewers anywhere, an inherited reference without an FQN, and an entity type that does not support reviewers. Rename buildRuleData to buildRuleContext: RuleEngine.apply names its parameter `context`, so the helper now matches the vocabulary of the API it feeds. The Map return type is required -- RuleEngine.apply takes a Map<String, Object>, and every other call site passes JsonUtils.getMap(entity). Applying the reviewers override to that map rather than to the entity is deliberate and now documented: getMap already returns a detached copy, while the entity may be request-cached. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
❌ PR checklist incompleteThis PR cannot be merged until the following are addressed on its linked issue:
The fields live on the linked issue in the Shipping project (open the issue → right sidebar → Projects). After you set them, re-run this check (or push a commit) — issue/project changes do not re-trigger it automatically. Maintainers can bypass this check by adding the |
…arent cache Adds a Playwright spec and an integration test that drive the ordering the bug needs: the glossary is created with NO reviewers, a first term runs the approval workflow (populating EntityRepository.inheritanceParentCache on a workflow executor thread), and only then is the reviewer added. Every term created afterwards must still be gated as "has reviewers". That cache is a static ThreadLocal cleared only by ImpersonationCleanupFilter, a JAX-RS response filter, so workflow threads never clear it and can serve a reviewer-less glossary snapshot indefinitely. A server restart clears it, which matches the reported recovery. Both tests currently PASS in-process: the embedded integration environment does not keep a poisoned executor thread alive across the ordering, so the sequence alone does not reproduce there. They stand as regression guards, and the Playwright spec is the one that exercises a real deployed stack where the executor threads are long lived. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (4)
openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java:7884
getEffectiveReviewersrelies ongetParentEntity(...)to find an inheritance parent. Repositories likeTagRepositorydo not overridegetParentEntity/getParentReference(default returns null), so tags/classifications will never resolve inherited reviewers via this path and the reviewers gate will still see an empty list.
List<EntityReference> reviewers = listOrEmpty(entity.getReviewers());
if (reviewers.isEmpty() && supportsReviewers) {
EntityInterface parent = resolveInheritanceParentLeniently(entity, FIELD_REVIEWERS);
if (parent != null) {
reviewers = listOrEmpty(parent.getReviewers());
openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryInheritedReviewerApproval.spec.ts:195
- This assertion can race task creation: the term may reach
In Reviewbefore the approval task is persisted/indexed, which can make the test flaky under load. Consider polling until at least one open approval task is visible (similar to the status poll).
expect(
await getOpenApprovalTaskCount(apiContext, term.fullyQualifiedName),
`Term ${term.name} must have an open approval task for the inherited reviewer`
).toBeGreaterThan(0);
openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java:7879
getEffectiveReviewersis described/used as an extensibility hook (e.g., GlossaryTerm/Tag reviewer chains), but it is declaredfinal, which prevents repository-specific overrides and makes the API inconsistent with that intent.
This issue also appears on line 7880 of the same file.
public final List<EntityReference> getEffectiveReviewers(T entity) {
openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryInheritedReviewerApproval.spec.ts:113
- PR description/checklist says Playwright tests are "Not applicable", but this PR adds a new Playwright E2E spec. Please update the PR description’s Tests section/checklist so reviewers/CI expectations match the actual changes.
This issue also appears on line 192 of the same file.
test.describe(
'Glossary Approval - inherited reviewers',
{ tag: ['@Features', '@Governance'] },
() => {
…ever-cleared cache Glossary terms whose reviewers were inherited from their glossary were judged to have none: the approval workflow's reviewers gate evaluated false, no approval task was created, and the term settled in Draft. Restarting the server fixed it. EntityRepository.inheritanceParentCache was a static ThreadLocal with no TTL, no size bound and no eviction, cleared only by ImpersonationCleanupFilter -- a JAX-RS response filter, so only on HTTP request threads. The approval workflow does not run on one: a change event reaches WorkflowEventConsumer on a Quartz thread and signals the process synchronously, so the filter, the attribute gates and the status transition all execute there. That thread never cleared the cache, so a glossary read before its reviewer was added was served for the life of the pod. Being a ThreadLocal it also cannot be evicted from the thread that performs the write, which is why no invalidation existed. It affected every inheritable field -- owners and domains as well as reviewers. Delete it. Parent lookups now fall through to the Guava L1 and the Redis L2, which are invalidated inline on write and across nodes via CacheInvalidationPubSub; with Redis disabled they fall through to the database. The batch path still issues one bulk load per parent type, so list endpoints do not regress to an N+1 -- the cost lands on repeated single-entity reads within one request. Its superset-field matching also disappears, which only TableRepository and DatabaseSchemaRepository relied on; narrower is a strict subset, so correctness is unchanged. Two threads that run entity reads still never cleared the remaining per-request ThreadLocals, so PerRequestContextCleaner now centralises that set and is called from the Quartz consumer tick and, via WorkflowThreadCleanupListener, at Flowable job completion. Workflow reads additionally open a FreshReadScope, which forces fromCache=false. Their reads are gating decisions where a stale answer silently routes an entity to the wrong terminal status, and cross-node L1 invalidation requires Redis; workflow volume is low, so the fresh read is the cheaper side of that trade. This makes the reviewers-only getEffectiveReviewers helper added earlier on this branch redundant, so it is reverted: it covered only reviewers and replaced the list rather than merging own and inherited entries the way inheritance does. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.
Suppressed comments (3)
openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryInheritedReviewerApproval.spec.ts:31
- This block comment refers to
EntityRepository.inheritanceParentCacheandImpersonationCleanupFilteras the mechanism behind the ordering requirement, but this PR removes the inheritance parent ThreadLocal cache and adds explicit per-job cleanup/fresh-read scoping. The comment should be updated so future readers don't chase a deleted implementation detail.
* The ordering matters. The approval workflow resolves inherited fields through
* `EntityRepository.inheritanceParentCache`, a static ThreadLocal cleared only by
* `ImpersonationCleanupFilter` — a JAX-RS response filter. Workflow execution threads never pass
* through that filter, so a glossary snapshot captured while it had NO reviewers can be served
* indefinitely. These tests therefore create the glossary WITHOUT reviewers first, let a term run the
openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryTermInheritedReviewerApprovalIT.java:183
- This test Javadoc still states (in present tense) that the bug is caused by
EntityRepository.inheritanceParentCachebeing a static ThreadLocal only cleared byImpersonationCleanupFilter. In this PR that cache is removed and cleanup is handled viaPerRequestContextCleaner/Flowable job listeners, so the Javadoc is now misleading and should be rewritten to describe the scenario being regression-tested rather than the deleted mechanism.
* Stale inheritance-parent cache. {@code EntityRepository.inheritanceParentCache} is a static
* ThreadLocal cleared only by {@code ImpersonationCleanupFilter}, a JAX-RS response filter — so on
* Flowable's async job-executor threads it is never cleared for the life of the process.
*
openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowHandler.java:289
- The PR description explains the fix as adding an
EntityRepository.getEffectiveReviewershook and wiring the reviewers gate to use it, but the code changes here focus on clearing ThreadLocals at Flowable/Quartz boundaries and bypassing caches viaFreshReadScope. If the implementation approach has changed, the PR description should be updated so reviewers and future maintainers understand the actual mechanism being relied on (or the missing reviewer-resolution hook should be added).
// Add Global Failure Listener + per-job ThreadLocal cleanup for the async executor pool
processEngineConfiguration.setEventListeners(
List.of(new WorkflowFailureListener(), new WorkflowThreadCleanupListener()));
waitForSettledStatus accepted Draft as settled, but a term under a reviewed parent is written as Draft at creation, before the workflow runs. The poll therefore returned on its first sample and every assertion read the pre-workflow value, so the spec reported the inherited-reviewer bug on a healthy build. Wait only for a status the workflow itself commits; a term that never leaves Draft now times out with a message naming the bug, which is the real signal. WorkflowThreadCleanupListener matched job-boundary events by name string. A Flowable rename would have fallen through to the no-op branch and silently stopped clearing the per-request ThreadLocals on the async-executor pool, restoring the stale-read leak with no compile error. Compare against the enum constants instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/AbstractEventConsumer.java:503
- Cleanup at the end of the tick isn’t guaranteed to run if
init(...)or downstream processing throws (or returns early in paths not shown here), which can reintroduce the ThreadLocal retention/staleness this change aims to prevent. Wrap the tick body in atry/finallysoPerRequestContextCleaner.clear()is always executed on exit (success, error, or early return).
public void execute(JobExecutionContext jobExecutionContext) {
// Quartz worker threads are long lived and never pass through the JAX-RS response filter, so
// per-request ThreadLocal caches would otherwise persist across ticks and serve indefinitely
// stale reads. Destinations on this thread read entities (governance workflows resolve
// inherited reviewers here), so start each tick from a clean slate.
PerRequestContextCleaner.clear();
this.init(jobExecutionContext);
if (this.eventSubscription == null) {
LOG.error("Skipping job execution - EventSubscription could not be loaded");
return;
}
openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryInheritedReviewerApproval.spec.ts:42
| stringmakesTermStatuseffectively “any string”, which defeats the purpose of the union (and reduces type-safety for assertions/logging). Consider replacing| stringwith the specific additional states you actually use (e.g.''for the initial value) so TypeScript flags unexpected statuses.
type TermStatus = 'Draft' | 'In Review' | 'Approved' | 'Rejected' | string;
openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryInheritedReviewerApproval.spec.ts:86
- The poll loop parses JSON without asserting a successful response. If the API intermittently returns non-2xx (or HTML/error payloads), the failure can become a confusing JSON parse error rather than a clear HTTP error. Consider checking
response.ok()/response.status()inside the poll and failing with a targeted message (status + body) to make test failures actionable.
const response = await apiContext.get(
`/api/v1/glossaryTerms/${termId}?fields=reviewers`
);
const term = await response.json();
status = term.entityStatus;
openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowThreadCleanupListener.java:45
- Even though
isFailOnException()returns false, letting an exception escapeonEventcan still create noisy logs and may skip cleanup on the very boundary where you most need it. Consider wrappingPerRequestContextCleaner.clear()in atry/catch(logging at debug/warn) to guarantee the listener is best-effort and never throws.
public void onEvent(FlowableEvent event) {
// Registered engine-wide, so every event lands here: keep the check cheap and first. Compare
// against the enum constants rather than their names so that if Flowable renames an event type,
// this fails to compile instead of silently never clearing again.
if (event.getType() == FlowableEngineEventType.JOB_EXECUTION_SUCCESS
|| event.getType() == FlowableEngineEventType.JOB_EXECUTION_FAILURE) {
PerRequestContextCleaner.clear();
}
}
The exit-side clear sat in the inner finally, which init() precedes and which the "subscription could not be loaded" early return skips entirely. Quartz worker threads are shared with every other scheduled job, so state left behind on those paths is inherited by whatever runs next on the thread — the entry-side clear only protects the next AbstractEventConsumer tick, not an unrelated job. Bracket the tick in try/finally so cleanup runs on success, error and early return alike. The accompanying test pins the end-to-end invariant and fails if cleanup is removed altogether. It does not isolate the exit clear from the entry clear: the paths that previously skipped cleanup run inside the private init(), which offers no seam to populate the ThreadLocals mid-tick. The unconditional exit clear is enforced structurally by the try/finally rather than by that test, and the javadoc says so. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.
Suppressed comments (2)
openmetadata-service/src/test/java/org/openmetadata/service/apps/bundles/changeEvent/AbstractEventConsumerTest.java:172
- This test asserts the thread is clean after execute(), but it can still pass if the exit-side cleanup is accidentally removed (because the new entry-side clear wipes the pre-set ThreadLocal before any work runs). Verifying that PerRequestContextCleaner.clear() is invoked twice (entry + finally) makes the invariant testable and guards against regressions.
void execute_leavesThreadCleanForTheNextJob() {
ImpersonationContext.setImpersonatedBy("someone");
try {
testEventConsumer.execute(jobExecutionContext);
openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryInheritedReviewerApproval.spec.ts:203
- This step is described as creating terms "in a batch" to increase the chance of hitting a poisoned async-executor thread, but the loop currently serializes (it waits for each term to settle before creating the next). That makes the repro less reliable because it can keep landing on the same clean thread by chance. Create all probe terms first, then poll/assert in parallel.
await test.step('Every term created afterwards inherits the reviewer and gets an approval task', async () => {
for (let index = 0; index < REVIEWER_ADDED_PROBE_COUNT; index++) {
const term = await createTermWithoutReviewers(
apiContext,
glossaryFqn,
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.
Suppressed comments (2)
openmetadata-service/src/main/java/org/openmetadata/service/util/FreshReadScope.java:36
- FreshReadScope is implemented by forcing
fromCache=false, which routes reads throughdao.findEntityById(...)and bypasses the entire caching path (Guava L1 + optional Redis loader). The Javadoc currently implies it only opts out of in-process caches (and not Redis), which is misleading for future maintainers.
* <p>{@link #enter()} restores the previous value rather than clearing, so nesting is safe. This is
* the same shape as {@link org.openmetadata.service.cache.EntityCacheBypass}, which opts out of the
* Redis layer; this one opts out of the in-process caches.
openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryInheritedReviewerApproval.spec.ts:152
- This suite relies on cross-test ordering and shared mutable state (the first test adds a reviewer to the shared glossary; the second test assumes that reviewer exists). The repo Playwright config enables
fullyParallel: true, so tests within a file/describe can run concurrently and this can become flaky. Mark the describe block as serial (or merge into a single test) to enforce the required ordering.
test.describe(
'Glossary Approval - inherited reviewers',
{ tag: ['@Features', '@Governance'] },
() => {
Code Review ✅ Approved 6 resolved / 6 findingsRemoves the static inheritance parent cache and adds thread-scoped context cleaners and fresh read scopes to fix stale inherited field resolutions in workflows. No issues found. ✅ 6 resolved✅ Edge Case: Unresolvable parent reviewer aborts the approval gate
✅ Bug: waitForSettledStatus returns on transient Draft, making tests flaky
✅ Quality: Job-boundary detection relies on stringly-typed event names
✅ Edge Case: expect.poll for approval task lacks explicit timeout
✅ Edge Case: Workflow cancel inside delete txn is not atomic with it
...and 1 more resolved from earlier reviews OptionsDisplay: compact → Showing less information. Comment with these commands to change the behavior for this request:
Was this helpful? React with 👍 / 👎 | Powered by Gitar — free for open source |
Describe your changes:
Fixes #
A glossary term whose reviewers are inherited from its glossary was judged to have none: the
approval workflow's reviewers gate evaluated false, no approval task was created, and the term
settled in Draft. A term with reviewers attached directly worked. Restarting the server
fixed it — the signature of a stale in-memory cache, and the observation that identified the real
cause.
Root cause
EntityRepository.inheritanceParentCachewas astatic ThreadLocal<Map<...>>with no TTL, no sizebound and no eviction, cleared in exactly one place:
ImpersonationCleanupFilter, a JAX-RSresponse filter — so only on HTTP request threads.
The approval workflow does not run on one. A change event reaches
WorkflowEventConsumeron aQuartz thread and signals the process via
runtimeService.signalEventReceived, which is Flowable'ssynchronous API; no node on the glossary path is marked async. So the trigger filter, the attribute
gates and the status transition all execute inline on that thread. It never cleared the cache, so a
glossary snapshot read before its reviewer was added was served for the life of the pod.
Two further properties made it worse: it is
static(shared by every repository on that thread) andit did superset field matching, so one stale entry answered several different field queries. And
because it is a
ThreadLocal, the thread performing the write cannot reach it — write-basedinvalidation was not implementable, which is why none existed.
This affected every inheritable field —
ownersanddomainsas well asreviewers.Fix
Delete the cache rather than add invalidation to it. It was a redundant third tier duplicating
layers that are already correct: parent lookups now fall through to the Guava L1 → Redis L2 → DB, and
both caches are invalidated inline on write and across nodes via
CacheInvalidationPubSub. Redis isopt-in (
cache.providerdefaults tonone), so when it is off the read simply goes to the database.an N+1; the added cost lands on repeated single-entity reads inside one request.
TableRepository/DatabaseSchemaRepositorybuilt variable fieldstrings, so only they used it; narrower is a strict subset, so correctness is unchanged.
Clean the ThreadLocals on the threads that never did.
PerRequestContextCleanercentralises theper-request set (previously inline in the JAX-RS filter) and is invoked from the Quartz consumer tick
and, via
WorkflowThreadCleanupListener, at Flowable job completion.Workflow reads open a
FreshReadScope(fromCache=false). Their reads are gating decisions wherea stale answer silently routes an entity to the wrong terminal status, and cross-node L1 invalidation
requires Redis; workflow volume is low, so the fresh read is the cheaper side of that trade. A
thread-scoped marker was chosen over threading a
fromCacheboolean becausegetParentEntityisoverridden in ~21 repositories.
Type of change:
High-level design:
Net effect is a deletion:
EntityRepositoryloses ~170 lines. New pieces are three small classes —PerRequestContextCleaner(shared cleanup),WorkflowThreadCleanupListener(Flowable job boundary),FreshReadScope(thread-scoped fresh-read marker, modelled on the existingEntityCacheBypass).No schema change, no migration, and no workflow-definition change — customized workflow definitions
benefit unchanged, because the fix corrects the data the rules see rather than the rules.
Tests:
Use cases covered
In Reviewwith an open approval task.sequence: glossary gains a reviewer after a term already ran the workflow).
auto-approves.
Unit tests
EntityRepositoryInheritanceParentTest— fails without the fix. Verified by temporarilyreintroducing the memoization:
expected: <1> but was: <0>inherited domains, i.e. the productionsymptom. The fake repository returns a fresh snapshot per parent read, as a real read does —
returning the same mutable instance would let a memoized reference appear up to date and hide the
bug.
WorkflowThreadCleanupListenerTest— clears onJOB_EXECUTION_SUCCESS/FAILURE, leavesunrelated events alone, never fails the job.
CheckEntityAttributesImplTest— pins how the shipped reviewers rule reads the entity.Backend integration tests
GlossaryTermInheritedReviewerApprovalIT(7 tests): direct, glossary-inherited,parent-term-inherited, grandparent-inherited, inherited-with-owner-set, remove-direct-reviewer
fallback, and reviewer-added-after-first-term.
Verified: 852 unit tests and ~1,100 integration tests green —
TableResourceIT(304),GlossaryResourceIT(99),WorkflowDefinitionResourceIT(54),GlossaryTermResourceIT(29),DatabaseSchemaResourceIT,EntityCacheInvalidationIT,GlossaryTermMoveApprovalIT,ImpersonationCleanupFilterTest.Still outstanding for a reviewer to weigh: inheritance is cross-cutting, so a full
openmetadata-integration-testsrun is warranted before merge; I ran a targeted subset.Ingestion integration tests
Playwright (UI) tests
GlossaryInheritedReviewerApproval.spec.ts— drives the reported ordering (glossary with noreviewers → term → add reviewer → new terms) against a deployed stack, which is where the
long-lived threads that caused this actually exist. Not yet executed against a live stack.
Manual testing performed
Root cause was confirmed on the reporting instance: the workflow recovered after a server restart and
regressed again once a glossary was edited, which is what an in-memory, never-invalidated cache
predicts and what led to deleting it.
UI screen recording / screenshots:
Not applicable — no UI changes.
Checklist:
Fixes <issue-number>: <short explanation>Fixes #<issue-number>above.🤖 Generated with Claude Code