ADR-0111: Plugins get beans scoped to them, and message through registrations Studio converges
- Status: accepted
- Date: 2026-09-24
- Deciders: Mahdi Amirabdollahi
- Change:
openspec/changes/plugin-messaging-and-secrets - Builds on: ADR-0009, ADR-0062, ADR-0077, ADR-0079, ADR-0099
Context
A runtime plugin may not start threads (ADR-0099), so it cannot run a broker client of its own. It can reach only @PluginApi beans, which are singletons shared by every plugin: none of them can tell which plugin is calling. A plugin that wants to react to messages, send one, or keep a credential has nowhere to go.
Studio already solves the hard half for itself. Message capture installs a bounded, drop-oldest copy of a queue's traffic that never touches production consumers or producers. It names its objects after the Studio instance, converges them under a per-cluster advisory lock, and sweeps orphans at startup. SecretVault seals credentials with AES-GCM and binds each ciphertext to its row.
Decision
- Scoped beans. The host asks every
PluginScopedBeansbean for the objects it binds to a plugin id and registers them in that plugin's context before it refreshes. A scoped object is a host object that knows its plugin's id and holds nothing of the plugin's. A plugin cannot name another plugin, because nothing it can call takes a plugin id. PluginSecretsis scoped. Values are sealed bySecretVaultwith the AADplugin|<id>|<name>. Writes and deletes are audited by name only, and purge deletes the rows. No interface returns a value.PluginMessagingis scoped, and declarative. A plugin registers what it wants (a tap or a consumer on a queue, acting for a user) under a key of its own. Studio stores the registration, and a reconciler makes it true on every serving node, as capture's does. Deliveries go to the plugin'sPluginMessageHandlerbean throughrunInPlugin, on Studio's threads.- A tap is capture's mechanism under its own reserved prefix
artemis-studio.plugin.: a non-exclusive divert into a non-durable, ring- and byte-boundedDROPqueue, restricted to Studio's broker role. - A consumer is a
CLIENT_ACKNOWLEDGECore consumer on the queue. Anything but an accept ends in redelivery.
- A tap is capture's mechanism under its own reserved prefix
- Registrations act for a user, whose grants are re-read on every pass:
message:readfor a tap, plusqueue:purgefor a consumer, andmessage:sendfor a send. Losing one suspends the registration with its reason. A disabled or uninstalled plugin's registrations stop and leave nothing on the broker, and purge deletes them.
Consequences
- A plugin can automate on messages without a thread, a client or a credential store of its own. Crash recovery, failover, multi-instance and cleanup behave exactly as capture's do, because they are the same loop.
- The scoped-bean seam is general. Future per-plugin facilities use it instead of adding plugin-id parameters to shared beans.
- A registration takes effect on the next pass, not synchronously. The API reports per-node state rather than pretending otherwise.
- Taps need
artemis-studio.capture.broker-role, as capture does. - A blocking handler holds one Core session. Bounding it is the plugin's job.
Alternatives considered
- A plugin-id argument on shared beans. Any plugin could pass another's id.
- Letting plugins run their own clients on Studio-provided executors. Studio would still have to own cleanup, failover and multi-instance behaviour, which is everything hard, while also exposing threads that pin classloaders.
- Generalising
CaptureTapin place. Capture's names and settings match are parsed by the capture reconciler, which would treat plugin taps as its own orphans. A separate prefix keeps the two independent.