Real cases · 16 of 25
A real production bug, worked end to end — an automated workflow step read dialog values (a tracking ID, a routing email) that a human review step had captured earlier in the same workflow, except those values sometimes weren't there anymore. Full working fix that hydrates workflow-level metadata from step history explicitly, instead of assuming a later step can see what an earlier step's dialog collected.
Part of the Real-World AEM Problems series — practical, production issues and how they were solved. Background reading: Workflow Architecture in AEM covers the model/instance/step/payload fundamentals this step builds on.
A content approval workflow had a human step early on — "Legal & Compliance Review" — where the reviewer filled in a dialog: a tracking ticket ID for the request, a reference number, and an email address for whoever should be notified once the page actually went live. Several steps later, after the page was activated, a fully automated step sent that notification email, reading those same three values to build the message and decide who to send it to.
Support started fielding occasional reports of notification emails that went out to a generic fallback distribution list instead of the specific person who should have been notified, with no ticket reference in the subject line at all — as if the reviewer had never filled in the dialog. The workflow itself always completed successfully; nothing failed, nothing logged an error. The reviewer, when asked, confirmed they absolutely had filled in those fields — and checking the workflow's own audit history confirmed it too: the values were right there, attached to that review step.
The actual cause was a mismatch in expectations about where workflow data lives. Each step in a running workflow instance operates against its own WorkItem, and a WorkItem's dialog values get stored on that WorkItem's own metadata — not automatically shared with every other step in the same workflow instance. The later, automated step was reading from the workflow's own top-level metadata map, which is a genuinely different, separate piece of storage that nothing had explicitly copied the reviewer's dialog values into. It worked by accident whenever some other part of the process happened to populate the workflow-level map with the same keys, and silently produced a generic, detail-less notification whenever that didn't happen — which was most of the time, just not obviously so, since a workflow reordering months earlier had changed which step ran adjacent to which.
The real fix isn't a bug in event handling or a missing null check — it's making step-to-step data flow an explicit, deliberate piece of the workflow's design, not an assumption.
Treat "does a later step need dialog values from an earlier step" as a question with an explicit answer, not an implicit one. In AEM's workflow model, per-step dialog values live on that step's own WorkItem metadata. If a later step needs them, something has to actively read the workflow's step history and copy the relevant values forward — there's no automatic promotion from "one step's dialog" to "every other step in this workflow." Once that was understood, the fix became straightforward: a small hydration step, run at the start of the automated step, that looks backward through workflow history for the most recent completed step carrying the fields needed, and copies them onto the workflow-level metadata map explicitly.
Search history by "which step actually has the data I need," not by position or step name. An earlier version of this idea (considered and rejected during the fix) was to hardcode "read from the review step, two steps back" — but workflow models get edited: steps get reordered, a new step gets inserted between two existing ones, a step gets renamed. Searching backward through history for the most recent entry that actually contains the required fields, rather than assuming a fixed position, means the hydration step keeps working correctly even after the workflow model itself changes shape later — which is exactly the kind of change that had silently broken this in the first place.
Never overwrite a value that's already present at the workflow level. If some other part of the process had already populated the workflow-level metadata directly, the hydration step needed to leave that alone rather than clobber it with an older value pulled from history — a simple "only set if currently blank" guard.
Resolve author-preview and published links defensively, one field at a time, not as a step that can fail the whole notification. The Externalizer lookup used to build links in the email body depends on domain mappings that are, in practice, sometimes misconfigured for a newer environment. A missing or invalid mapping should degrade to an empty link for that one field, logged clearly, rather than throwing an exception that aborts sending the entire notification.
@ExtendWith(AemContextExtension.class)
class ReviewMetadataHydratorTest {
@Test
void mostRecentHistoryEntryWithBothRequiredFieldsIsUsed() {
HistoryItem older = historyItemWith(Map.of("trackingId", "TCK-100", "requesterEmail", "old@example.com"));
HistoryItem newer = historyItemWith(Map.of("trackingId", "TCK-200", "requesterEmail", "new@example.com"));
WorkflowSession session = sessionWithHistory(List.of(older, newer));
Optional<ReviewMetadata> result = ReviewMetadataHydrator.hydrate(session, mock(Workflow.class));
assertThat(result).isPresent();
assertThat(result.get().trackingId()).isEqualTo("TCK-200"); // most recent, not first found
}
@Test
void historyEntryMissingOneRequiredFieldIsSkippedInFavorOfAnEarlierCompleteOne() {
HistoryItem incomplete = historyItemWith(Map.of("trackingId", "TCK-300")); // no requesterEmail
HistoryItem complete = historyItemWith(Map.of("trackingId", "TCK-150", "requesterEmail", "person@example.com"));
WorkflowSession session = sessionWithHistory(List.of(complete, incomplete));
Optional<ReviewMetadata> result = ReviewMetadataHydrator.hydrate(session, mock(Workflow.class));
assertThat(result).isPresent();
assertThat(result.get().trackingId()).isEqualTo("TCK-150");
}
@Test
void noQualifyingHistoryEntryReturnsEmpty() {
WorkflowSession session = sessionWithHistory(List.of());
assertThat(ReviewMetadataHydrator.hydrate(session, mock(Workflow.class))).isEmpty();
}
}
The second test is the one directly guarding against the original bug's shape — it forces the search to actually validate that both required fields are present on a given history entry, not just find the nearest entry and assume it's complete.
package com.example.aem.core.workflow;
/** Dialog values captured during the human review step, needed by a later automated step. */
public record ReviewMetadata(String trackingId, String referenceNumber, String requesterEmail) {
static final String KEY_TRACKING_ID = "trackingId";
static final String KEY_REFERENCE_NUMBER = "referenceNumber";
static final String KEY_REQUESTER_EMAIL = "requesterEmail";
}
package com.example.aem.core.workflow;
import java.util.List;
import java.util.Optional;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.adobe.granite.workflow.WorkflowException;
import com.adobe.granite.workflow.WorkflowSession;
import com.adobe.granite.workflow.exec.HistoryItem;
import com.adobe.granite.workflow.exec.Workflow;
import com.adobe.granite.workflow.metadata.MetaDataMap;
import static com.example.aem.core.workflow.ReviewMetadata.KEY_REFERENCE_NUMBER;
import static com.example.aem.core.workflow.ReviewMetadata.KEY_REQUESTER_EMAIL;
import static com.example.aem.core.workflow.ReviewMetadata.KEY_TRACKING_ID;
/**
* Finds the review dialog values a later, automated workflow step needs by
* searching step history for the most recent completed step that actually
* carries them — rather than assuming a fixed step position, which breaks
* the moment the workflow model is reordered.
*/
public final class ReviewMetadataHydrator {
private static final Logger LOG = LoggerFactory.getLogger(ReviewMetadataHydrator.class);
private ReviewMetadataHydrator() {
}
public static Optional<ReviewMetadata> hydrate(WorkflowSession workflowSession, Workflow workflow) {
try {
List<HistoryItem> history = workflowSession.getHistory(workflow);
if (history == null) {
return Optional.empty();
}
for (int i = history.size() - 1; i >= 0; i--) {
Optional<ReviewMetadata> candidate = extractIfComplete(history.get(i));
if (candidate.isPresent()) {
return candidate;
}
}
return Optional.empty();
} catch (WorkflowException e) {
LOG.error("Unable to read workflow history while hydrating review metadata", e);
return Optional.empty();
}
}
private static Optional<ReviewMetadata> extractIfComplete(HistoryItem historyItem) {
MetaDataMap stepMetadata = historyItem.getWorkItem().getMetaDataMap();
String trackingId = stepMetadata.get(KEY_TRACKING_ID, String.class);
String requesterEmail = stepMetadata.get(KEY_REQUESTER_EMAIL, String.class);
if (StringUtils.isBlank(trackingId) || StringUtils.isBlank(requesterEmail)) {
return Optional.empty(); // this step doesn't carry everything needed — keep searching further back
}
String referenceNumber = stepMetadata.get(KEY_REFERENCE_NUMBER, String.class);
return Optional.of(new ReviewMetadata(trackingId, referenceNumber, requesterEmail));
}
/** Copies hydrated values onto workflow-level metadata, without overwriting anything already set there. */
public static void applyIfAbsent(MetaDataMap workflowMetadata, ReviewMetadata metadata) {
putIfAbsent(workflowMetadata, KEY_TRACKING_ID, metadata.trackingId());
putIfAbsent(workflowMetadata, KEY_REFERENCE_NUMBER, metadata.referenceNumber());
putIfAbsent(workflowMetadata, KEY_REQUESTER_EMAIL, metadata.requesterEmail());
}
private static void putIfAbsent(MetaDataMap target, String key, String value) {
if (StringUtils.isNotBlank(value) && StringUtils.isBlank(target.get(key, String.class))) {
target.put(key, value);
}
}
}
package com.example.aem.core.workflow;
import org.apache.sling.api.resource.ResourceResolver;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.day.cq.commons.Externalizer;
/** Resolves external links defensively — a bad or missing mapping degrades one field, not the whole step. */
public class NotificationLinkResolver {
private static final Logger LOG = LoggerFactory.getLogger(NotificationLinkResolver.class);
private final Externalizer externalizer;
public NotificationLinkResolver(Externalizer externalizer) {
this.externalizer = externalizer;
}
public String resolve(ResourceResolver resolver, String externalizerKey, String path) {
try {
return externalizer.externalLink(resolver, externalizerKey, path);
} catch (IllegalArgumentException e) {
LOG.warn("Externalizer mapping '{}' failed for path '{}'; using empty link", externalizerKey, path, e);
return "";
}
}
}
package com.example.aem.core.workflow;
import java.util.Optional;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.adobe.granite.workflow.WorkflowException;
import com.adobe.granite.workflow.WorkflowSession;
import com.adobe.granite.workflow.exec.WorkItem;
import com.adobe.granite.workflow.exec.Workflow;
import com.adobe.granite.workflow.exec.WorkflowProcess;
import com.adobe.granite.workflow.metadata.MetaDataMap;
import com.day.cq.commons.Externalizer;
import com.day.cq.commons.jcr.JcrConstants;
/**
* Automated step that sends a "page is live" notification, using review
* details a human step captured earlier in the same workflow. Explicitly
* hydrates those values from step history before building the notification,
* rather than assuming they're already present on the workflow's own
* metadata map.
*/
@Component(service = WorkflowProcess.class, property = {
"process.label=Send Review-Aware Publish Notification" })
public class WorkflowNotificationStep implements WorkflowProcess {
private static final Logger LOG = LoggerFactory.getLogger(WorkflowNotificationStep.class);
private static final String AUTHOR_PREVIEW_KEY = "authorPreview";
private static final String PUBLISH_KEY = "publish";
private static final String FALLBACK_RECIPIENT_LIST = "notifications@example.com";
@Reference
private Externalizer externalizer;
@Reference
private NotificationDispatcher notificationDispatcher;
@Override
public void execute(WorkItem workItem, WorkflowSession workflowSession, MetaDataMap metaDataMap)
throws WorkflowException {
ResourceResolver resolver = workflowSession.adaptTo(ResourceResolver.class);
if (resolver == null) {
LOG.error("Unable to adapt WorkflowSession to ResourceResolver");
return;
}
Workflow workflow = workItem.getWorkflow();
MetaDataMap workflowMetadata = workflow.getMetaDataMap();
// Explicitly carry review-step values forward before reading them, rather than assuming they're
// already on the workflow-level metadata map.
ReviewMetadataHydrator.hydrate(workflowSession, workflow)
.ifPresent(metadata -> ReviewMetadataHydrator.applyIfAbsent(workflowMetadata, metadata));
String requesterEmail = workflowMetadata.get(ReviewMetadata.KEY_REQUESTER_EMAIL, String.class);
String recipient = requesterEmail != null ? requesterEmail : FALLBACK_RECIPIENT_LIST;
String pagePath = String.valueOf(workflow.getWorkflowData().getPayload());
Resource page = resolver.getResource(pagePath);
if (page == null) {
LOG.error("Payload page not found at {}", pagePath);
return;
}
String title = page.getChild(JcrConstants.JCR_CONTENT) != null
? page.getChild(JcrConstants.JCR_CONTENT).getValueMap().get(JcrConstants.JCR_TITLE, "Untitled")
: "Untitled";
NotificationLinkResolver linkResolver = new NotificationLinkResolver(externalizer);
String previewUrl = linkResolver.resolve(resolver, AUTHOR_PREVIEW_KEY, pagePath + ".html");
String publishedUrl = linkResolver.resolve(resolver, PUBLISH_KEY, pagePath + ".html");
String trackingId = workflowMetadata.get(ReviewMetadata.KEY_TRACKING_ID, String.class);
notificationDispatcher.sendPublishNotification(recipient, title, trackingId, previewUrl, publishedUrl);
}
}
package com.example.aem.core.workflow;
/**
* Sends the actual notification. Kept as a thin interface here — the safe,
* injection-resistant payload construction for the outbound call is the
* same concern covered in the notification-payload post earlier in this
* series, reused rather than re-derived.
*/
public interface NotificationDispatcher {
void sendPublishNotification(String recipientEmail, String pageTitle, String trackingId,
String previewUrl, String publishedUrl);
}
workflowMetadata genuinely didn't contain the reviewer's dialog values even though the step history clearly showed them, turning "reported occasionally" into a repeatable case.The specific defect — position-based assumptions about where step data lives — couldn't recur, because the fix explicitly searches for the data it needs rather than assuming a location. When the workflow model was, in fact, edited again eight months later to add an extra review step, the notification step kept working correctly without any code change, which was the entire point of searching by content rather than by position.
Separating link resolution into its own small, per-field-defensive piece also meant that a later, unrelated Externalizer misconfiguration in a new environment showed up as one blank link in a test email — caught immediately in review — rather than a completely failed workflow step that would have needed a stack trace to diagnose.
This is one entry in an ongoing series of real production AEM problems and how they were actually solved. Check back for the next one.
Get an email when I publish the next chapter. No spam — just new technical deep-dives.
Comments
Share feedback or questions about this blog post.
No comments yet. Be the first to share your thoughts.