Decision
Two things, which are separable but arrive together. 1. A plugin may declare event types and broadcast on them, behind two new capabilities:
A plugin may only publish on types it declared itself. Namespacing is by plugin
id and is not optional:
flag-manager/profile-changed, never profile-changed.
2. Some plugins ship with Cordial as first-party, installed by default and
loaded on demand when something depends on them. They are ordinary plugins —
same manifest, same capability grants, same isolation — and they are visible and
individually disableable in settings.
Why plugin-declared events
Because the alternative is a core that grows a case for every plugin. A multi-instance plugin wants to say “an instance started”; a launcher wants to hear it. If every such pair needs a new core event type, the core’s event vocabulary becomes a list of whatever plugins happened to exist, and every new integration is a core change. Letting plugins declare their own keeps the core’s vocabulary about Cordial, and lets the ecosystem grow sideways. Declaration is separate from publication on purpose. A plugin that can publish on any string can impersonate another plugin’s events, and a subscriber has no way to tell. Declaring first, under a namespace derived from the plugin id rather than chosen by the plugin, makes the origin of an event a fact rather than a claim. Subscription is deliberately broader than publication. Hearing an event tells you something happened; publishing tells everyone else something happened. Those are different powers and it would be a mistake to grant them together — a plugin that only reacts should not have to be trusted to speak.Why first-party plugins are still plugins
Because “built in” and “a plugin” are not opposites, and pretending otherwise costs the architecture. The moment a behaviour is implemented in core because it ships by default, it stops being subject to the capability model, stops being inspectable the way a plugin is, and stops being removable. Cordial would then have two kinds of behaviour with two sets of rules, and the interesting one would be the one users cannot see. Keeping them as plugins means the default feature set is an example of the plugin API being sufficient. If a first-party plugin needs something the API cannot express, that is a signal the API is incomplete — exactly the argument ADR-001 makes about the framework layer. Loaded on demand, not eagerly. A first-party plugin that nothing depends on should not be running. Resolution is by dependency: a plugin declares that it needscordial/multi-instance, and that plugin starts if it is not already up.
This is the same shape as an ESM import graph and it should behave like one —
resolved once, shared, not restarted per dependent.
On by default is a defaults question, not an architecture question. Some
first-party plugins will be on out of the box because the client is worse without
them. That is a setting, and settings are reversible. What must not happen is a
plugin that cannot be turned off being described as a plugin.
Consequences
Accepted: the event registry is a real runtime object with ownership rules, not a hashmap of strings. It has to record which plugin declared each type, refuse a publish from anyone else, and survive a plugin restarting without letting a different plugin claim its namespace in the gap. Accepted: a first-party plugin ships in the repository and is covered by its tests, so the plugin API is exercised by Cordial’s own CI rather than only by third parties. Accepted: dependency resolution introduces a failure mode the current host does not have — a plugin that depends on one that will not start. That must surface as a named error to the dependent, not a silent absence, for the same reasondenied and error are distinct in the protocol.
Rejected: letting plugins publish on core event types. Core events describe
what Cordial did; a plugin claiming Cordial did something it did not is a
correctness problem for every subscriber. Plugins declare their own or say
nothing.
Resolved: the registry filters at subscribe time, not on receipt. Since
declaring already has to record who owns each type to authorise publish,
subscribe reuses that same lookup rather than making every plugin carry
its own filter list and having publish consult all of them. The cost is
that subscribe refuses a type nobody has declared yet, rather than parking
the subscription to match something that shows up later — a subscriber that
starts before its dependency has to wait for it, which is the dependency
resolution this ADR already describes for first-party plugins (“resolved
once, shared, not restarted per dependent”), not a new problem. See
crates/cordial-plugins/src/events.rs.
What would change this
If the event registry turns out to be a way for plugins to fingerprint each other — learning what is installed by watching what is declared — subscription would need to be scoped to declared dependencies rather than open. That is worth checking before the first plugin that handles anything account-shaped ships.Notes moved from docs/plugin-api.md (2026-10-02)
Evidence that start_all ignores dependencies
And nothing orders the start-up so that the declaration happens first. Read
this before you rely on dependencies. ADR-006 describes dependency-resolved
loading, and events.rs’s own module comment leans on it to explain why
subscribe-time filtering is acceptable. That start ordering is not
implemented. plugin_host::start_all — the only path that starts a plugin in
the running client, called once from crates/cordial-runtime/src/bin/load.rs —
iterates manifest::discover(&manifest::plugin_root()), which returns plugins
sorted by directory name (dirs.sort() in manifest.rs) and nothing else.
plugin_host.rs contains no reference to dependencies or Dependency
anywhere in the file, and nothing in it reaches cordial_plugins::resolve. (The
word resolve does appear there, in crate::flags::resolve and a local
resolve_within; grep for the module rather than the word.) The dependency
planner in
crates/cordial-plugins/src/resolve.rs has non-test callers only in
marketplace.rs and the settings window’s install-confirmation UI: it decides
what gets installed, never what starts first. Nothing refuses to start a
plugin whose declared dependency is absent, either, so ADR-006’s “must surface
as a named error to the dependent” is unimplemented as well.
So naming a plugin in dependencies has no effect on start order, and a
start-up events.subscribe on another plugin’s type is a race the manifest
cannot influence. Two things actually work:
- Retry. Treat
has not been declared by any pluginas “not yet” rather than as a permanent failure, and try again later — on a timer, or when you next have reason to. - Subscribe late. Do it at the point you first need the events, by which time a plugin that starts earlier in directory-name order has usually declared.
dependencies: it is what the installer reads,
so it is how the plugin gets onto the machine at all. Just do not read it as a
scheduling promise. (INFERRED: start_all reads only
manifest::plugin_root(), the user root, and never system_plugin_root(),
whereas flags::collect reads both. On the face of the two call sites a
first-party plugin shipped under the system prefix is never spawned by the
runtime, which would make a first-party publisher unavailable at any time.
Nobody has run the client to confirm the consequence.)