# Event Migration

> AEM Cloud Service expert skill for OSGi Event Admin handlers (non-resource events). Covers migration of javax.jcr.observation.EventListener (residual non-resource cases) and OSGi EventHandler with inline business logic to the lightweight EventHandler + JobConsumer split. Includes routing rules (resource events go to resource-change-listener, external notification goes to AEM Eventing), TopologyEv…

- **Type:** Skill
- **Install:** `agentstack add skill-adobe-skills-event-migration`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [adobe](https://agentstack.voostack.com/s/adobe)
- **Installs:** 0
- **Category:** [Agent Skills](https://agentstack.voostack.com/c/agent-skills)
- **Latest version:** 0.1.0
- **License:** Apache-2.0
- **Upstream author:** [adobe](https://github.com/adobe)
- **Source:** https://github.com/adobe/skills/tree/main/plugins/aem/cloud-service/skills/code-assessment/event-migration
- **Website:** https://www.adobe.com/ai/overview.html

## Install

```sh
agentstack add skill-adobe-skills-event-migration
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# Event Migration — AEM as a Cloud Service

## Overview

`org.osgi.service.event.EventHandler` is the API for reacting to **non-resource** OSGi Event Admin events on AEM CS — replication events (`com.day.cq.replication.ReplicationEvent.EVENT_TOPIC`), workflow events (`com/adobe/granite/workflow/*`), and custom inter-bundle event topics. The handler runs on a shared OSGi event thread and **must** stay lightweight — all business logic must be offloaded to a Sling Job via `JobManager.addJob()`.

**Three CS-specific constraints every event handler must satisfy:**

| Constraint | Why |
|-----------|-----|
| No `ResourceResolver` / `Session` / JCR ops inside `handleEvent()` | Blocks the shared OSGi event-admin thread; can delay every other registered handler |
| `getServiceResourceResolver(SUBSERVICE)` in the consumer | `getAdministrativeResourceResolver` is removed from the CS SDK |
| Leader-only handlers must implement `TopologyEventListener` and check `isLeader` | A simple run-mode check fires on every author cluster pod — `TopologyEventListener` is the supported way to elect a single leader |

> **`handleEvent()` runs synchronously on a shared OSGi thread.** Doing repository, network, or workflow work inline blocks event delivery for every other handler subscribed to anything. Always offload to a `JobConsumer`.

---

## Routing — is this the right skill?

**Use this skill when** the source listens to a **non-resource** OSGi Event Admin topic:

| Source topic | Description |
|--------------|-------------|
| `com.day.cq.replication.ReplicationEvent.EVENT_TOPIC` | Replication actions (activate, deactivate, etc.) |
| `com/adobe/granite/workflow/*` | Workflow lifecycle events |
| Custom OSGi topics posted by another bundle | Inter-bundle event signaling |
| Other non-resource OSGi events | Any OSGi event that is not a resource-change topic |

**Route elsewhere when:**

| Source observes… | Use instead |
|------------------|-------------|
| Repository content (page, asset, property added/changed/removed) | **`resource-change-listener` skill** — `ResourceChangeListener` is the supported API on CS |
| External system notification (Adobe I/O Events, App Builder, webhooks, downstream services) | **AEM Eventing** — cloud-native, runs outside AEM, distinct from OSGi Event Admin |
| Anything subscribed to `org/apache/sling/api/resource/Resource/*` | **`resource-change-listener` skill** — those OSGi resource topics are a deprecated internal Sling dispatcher detail |

**Do not** subscribe a new `EventHandler` to `org/apache/sling/api/resource/Resource/ADDED|CHANGED|REMOVED`. Those topics are an internal Sling dispatcher detail, deprecated as an application-facing API. Use `ResourceChangeListener` for resource observation.

### Where to find topic constants

Always subscribe via a documented topic constant — never invent or copy-paste a topic string. The canonical constants live in the producing bundle's API:

| Producing API | Constant |
|---------------|----------|
| Replication | `com.day.cq.replication.ReplicationEvent.EVENT_TOPIC` |
| Workflow | `com.adobe.granite.workflow.event.WorkflowEvent.EVENT_TOPIC` — combine with `WorkflowEvent.EVENT_TYPE` property to discriminate the sub-event |
| Sling Jobs (lifecycle) | `org.apache.sling.event.jobs.NotificationConstants.*` |
| Custom bundle topics | Check the producing bundle's source or documentation for its declared `static final String` topic constant |

A typo'd topic string compiles fine and silently subscribes to a topic that nothing posts to — the handler is `ACTIVE` but never fires. Reference the constant in code instead of duplicating the literal string.

---

## Classification — choose before making any changes

**Implements `EventHandler` for a non-resource topic** and `handleEvent()` only enqueues jobs
→ Already compliant — verify against the [Review Checklist](#review-checklist) only.

**Implements `EventHandler` for a non-resource topic** and `handleEvent()` contains business logic (resolver, JCR ops, heavy processing)
→ Apply **E1–E5**.

**Implements `javax.jcr.observation.EventListener`** for a concern that **cannot** be expressed as a `ResourceChangeListener` (rare — most legacy JCR listeners should route to the `resource-change-listener` skill)
→ Apply **E0** (convert to `EventHandler` for the appropriate non-resource topic) then **E1–E5**. If unsure whether RCL covers it, prefer RCL and ask before falling through to this skill.

**One pattern per session.** If the bundle has multiple legacy handlers, migrate one class at a time.

**Before starting:** Read [`../references/aem-cloud-service-pattern-prerequisites.md`](../references/aem-cloud-service-pattern-prerequisites.md) and apply SCR→DS, service-user, and SLF4J fixes if present in the same changeset.

---

## Complete example — before and after

### Before (replication EventHandler with inline business logic)

```java
package com.example.listeners;

import org.apache.felix.scr.annotations.Component;
import org.apache.felix.scr.annotations.Reference;
import org.apache.sling.api.resource.ModifiableValueMap;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.ResourceResolverFactory;
import org.osgi.service.event.Event;
import org.osgi.service.event.EventConstants;
import org.osgi.service.event.EventHandler;
import com.day.cq.replication.ReplicationAction;
import com.day.cq.replication.ReplicationActionType;
import com.day.cq.replication.ReplicationEvent;

import java.util.Calendar;
import java.util.Collections;

@Component(
    immediate = true,
    property = {
        EventConstants.EVENT_TOPIC + "=com/day/cq/replication"
    }
)
public class ReplicationDateEventHandler implements EventHandler {

    @Reference
    private ResourceResolverFactory resolverFactory;

    @Override
    public void handleEvent(Event event) {
        try {
            ReplicationAction action = ReplicationEvent.fromEvent(event).getReplicationAction();
            if (action.getType() == ReplicationActionType.ACTIVATE) {
                ResourceResolver resolver = resolverFactory.getServiceResourceResolver(
                        Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, "replication-service"));
                Resource resource = resolver.getResource(action.getPath() + "/jcr:content");
                if (resource != null) {
                    ModifiableValueMap map = resource.adaptTo(ModifiableValueMap.class);
                    map.put("cq:lastReplicated", Calendar.getInstance());
                    resolver.commit();
                }
                resolver.close();
            }
        } catch (Exception e) {
            System.err.println("Error updating replication date: " + e.getMessage());
            e.printStackTrace();
        }
    }
}
```

### After — Cloud Service compatible

**File 1 — `ReplicationDateEventHandler.java`** (lightweight `EventHandler` + leader election)

```java
package com.example.listeners;

import org.apache.sling.discovery.TopologyEvent;
import org.apache.sling.discovery.TopologyEventListener;
import org.apache.sling.event.jobs.JobManager;
import org.osgi.framework.Constants;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.event.Event;
import org.osgi.service.event.EventConstants;
import org.osgi.service.event.EventHandler;
import com.day.cq.replication.ReplicationAction;
import com.day.cq.replication.ReplicationActionType;
import com.day.cq.replication.ReplicationEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.HashMap;
import java.util.Map;

@Component(
    service = { EventHandler.class, TopologyEventListener.class },
    immediate = true,
    property = {
        Constants.SERVICE_DESCRIPTION + "=Update lastReplicated on activation",
        EventConstants.EVENT_TOPIC + "=com/day/cq/replication"
    }
)
public class ReplicationDateEventHandler implements EventHandler, TopologyEventListener {

    public static final String JOB_TOPIC = "com/example/replication/date/update";

    private static final Logger LOG = LoggerFactory.getLogger(ReplicationDateEventHandler.class);
    private volatile boolean isLeader = false;

    @Reference
    private JobManager jobManager;

    @Override
    public void handleTopologyEvent(TopologyEvent event) {
        if (event.getType() == TopologyEvent.Type.TOPOLOGY_CHANGED
                || event.getType() == TopologyEvent.Type.TOPOLOGY_INIT) {
            isLeader = event.getNewView().getLocalInstance().isLeader();
            LOG.info("Topology updated; isLeader={}", isLeader);
        }
    }

    @Override
    public void handleEvent(Event event) {
        if (!isLeader) {
            return;
        }
        try {
            ReplicationAction action = ReplicationEvent.fromEvent(event).getReplicationAction();
            if (action.getType() != ReplicationActionType.ACTIVATE) {
                return;
            }
            Map jobProperties = new HashMap<>();
            jobProperties.put("path", action.getPath());
            jobManager.addJob(JOB_TOPIC, jobProperties);
        } catch (Exception e) {
            LOG.error("Failed to enqueue replication-date job", e);
        }
    }
}
```

**File 2 — `ReplicationDateJobConsumer.java`** (business logic runs here, with a service-user resolver)

```java
package com.example.listeners;

import org.apache.sling.api.resource.LoginException;
import org.apache.sling.api.resource.ModifiableValueMap;
import org.apache.sling.api.resource.PersistenceException;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.ResourceResolverFactory;
import org.apache.sling.event.jobs.Job;
import org.apache.sling.event.jobs.consumer.JobConsumer;
import org.apache.sling.event.jobs.consumer.JobResult;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Calendar;
import java.util.Collections;

@Component(
    service = JobConsumer.class,
    property = {
        JobConsumer.PROPERTY_TOPICS + "=com/example/replication/date/update"
    }
)
public class ReplicationDateJobConsumer implements JobConsumer {

    private static final Logger LOG = LoggerFactory.getLogger(ReplicationDateJobConsumer.class);
    private static final String SUBSERVICE = "event-handler-service";

    @Reference
    private ResourceResolverFactory resolverFactory;

    @Override
    public JobResult process(final Job job) {
        final String path = job.getProperty("path", String.class);
        if (path == null) {
            LOG.warn("Job missing 'path' property; CANCEL");
            return JobResult.CANCEL;
        }

        try (ResourceResolver resolver = resolverFactory.getServiceResourceResolver(
                Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, SUBSERVICE))) {

            Resource resource = resolver.getResource(path + "/jcr:content");
            if (resource != null) {
                ModifiableValueMap map = resource.adaptTo(ModifiableValueMap.class);
                map.put("cq:lastReplicated", Calendar.getInstance());
                resolver.commit();
                LOG.debug("Updated lastReplicated for {}", path);
            }
            return JobResult.OK;

        } catch (LoginException e) {
            LOG.error("Could not open service resolver for '{}'", SUBSERVICE, e);
            return JobResult.FAILED;
        } catch (PersistenceException e) {
            LOG.error("Failed to commit lastReplicated for {}", path, e);
            return JobResult.FAILED;
        }
    }
}
```

**Required Repoinit** (in `ui.config`):

```
create service user event-handler-service

set ACL for event-handler-service
    allow jcr:read,rep:write on /content
end
```

**Required service-user mapping** (`ui.config`, file named e.g. `org.apache.sling.serviceusermapping.impl.ServiceUserMapperImpl.amended-event-handler.cfg.json`):

```json
{
  "user.mapping": [
    "com.example.mybundle:event-handler-service=[event-handler-service]"
  ]
}
```

Use the **amend** form (`ServiceUserMapperImpl.amended-.cfg.json`) and the **principal-name** mapping (square-bracket form `=[service-user-name]`). Do not use the deprecated `userName` form. Permissions must be declared **directly on the service user** — do not rely on group inheritance.

### Variant — workflow events

Workflow events live on topics under `com/adobe/granite/workflow/*` and expose payload properties via `com.adobe.granite.workflow.event.WorkflowEvent` constants. The overall shape (lightweight handler + leader election + JobConsumer) is identical to the replication example — only the topic, the event-type discriminator, and the payload keys change. Full standalone handler:

```java
package com.example.listeners;

import com.adobe.granite.workflow.event.WorkflowEvent;
import org.apache.sling.discovery.TopologyEvent;
import org.apache.sling.discovery.TopologyEventListener;
import org.apache.sling.event.jobs.JobManager;
import org.osgi.framework.Constants;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.event.Event;
import org.osgi.service.event.EventConstants;
import org.osgi.service.event.EventHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.HashMap;
import java.util.Map;

@Component(
    service = { EventHandler.class, TopologyEventListener.class },
    immediate = true,
    property = {
        Constants.SERVICE_DESCRIPTION + "=Handle workflow completion",
        EventConstants.EVENT_TOPIC + "=com/adobe/granite/workflow/event"
    }
)
public class WorkflowCompletedEventHandler implements EventHandler, TopologyEventListener {

    public static final String JOB_TOPIC = "com/example/workflow/completed";

    private static final Logger LOG = LoggerFactory.getLogger(WorkflowCompletedEventHandler.class);
    private volatile boolean isLeader = false;

    @Reference
    private JobManager jobManager;

    @Override
    public void handleTopologyEvent(TopologyEvent event) {
        if (event.getType() == TopologyEvent.Type.TOPOLOGY_CHANGED
                || event.getType() == TopologyEvent.Type.TOPOLOGY_INIT) {
            isLeader = event.getNewView().getLocalInstance().isLeader();
        }
    }

    @Override
    public void handleEvent(Event event) {
        if (!isLeader) {
            return;
        }
        try {
            String eventType = (String) event.getProperty(WorkflowEvent.EVENT_TYPE);
            if (!WorkflowEvent.WORKFLOW_COMPLETED_EVENT.equals(eventType)) {
                return;
            }
            Map jobProperties = new HashMap<>();
            jobProperties.put("workflowId", event.getProperty(WorkflowEvent.WORKFLOW_ID));
            jobProperties.put("workItemId", event.getProperty(WorkflowEvent.WORK_ITEM));
            jobProperties.put("path", event.getProperty("path"));
            jobManager.addJob(JOB_TOPIC, jobProperties);
        } catch (Exception e) {
            LOG.error("Failed to enqueue workflow-completed job", e);
        }
    }
}
```

Common `WorkflowEvent` payload keys:

| Key | Purpose |
|-----|---------|
| `WorkflowEvent.EVENT_TYPE` | Event subtype — `WORKFLOW_STARTED_EVENT`, `WORKFLOW_COMPLETED_EVENT`, `WORKFLOW_ABORTED_EVENT`, `WORK_ITEM_COMPLETED_EVENT`, etc. |
| `WorkflowEvent.WORKFLOW_ID` | The workflow instance ID |
| `WorkflowEvent.WORK_ITEM` | The current work item ID |
| `path` | The payload path the workflow is operating on (when applicable) |

The JobConsumer pattern is unchanged from the replication example — read the properties via `job.getProperty("key", Type.class)` and apply the business logic.

---

## E0 — Conver

…

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [adobe](https://github.com/adobe)
- **Source:** [adobe/skills](https://github.com/adobe/skills)
- **License:** Apache-2.0
- **Homepage:** https://www.adobe.com/ai/overview.html

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** no
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-adobe-skills-event-migration
- Seller: https://agentstack.voostack.com/s/adobe
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
