Compatibility policy
A plugin ships as a compiled assembly, built against an SDK version and updated on a schedule its author
controls, not Macro Deck. So the promise is: a plugin compiled against an older SDK keeps loading and
behaving the same against a newer host - no recompile, no behaviour change. Every contract below is
frozen while it is public and not marked [Obsolete], and changes only through the process under
how contracts change.
What is covered
Section titled “What is covered”| Surface | Examples | Promise |
|---|---|---|
| SDK packages | MacroDeck.Sdk, MacroDeck.Plugin.Hosting, MacroDeck.Plugin.Testing, MacroDeck.Plugin.Packaging, MacroDeck.Plugin.Serilog, MacroDeck.Plugin.Cli, MacroDeck.Localization |
No public type or member is removed or changed incompatibly, source or binary. |
| UI model | MacroDeck.Ui, MacroDeck.Ui.Model, MacroDeck.Ui.Testing |
Frozen like the SDK; the UI model’s wire major is negotiated separately - see the UI model majors. |
| Protocol | MacroDeck.Plugin.Protocol: the envelope, DTOs, message types, error codes |
Append-only within a protocol major; a break needs a new major. |
| Plugin HTTP and WebSocket | /api/plugins/*, /plugins/ws |
Existing endpoints keep their shape and meaning - see the protocol reference. |
| Capability and host API catalogues | device-provider/devices, layout-provider/layouts, folder-view-provider/folder-views, widget-type-provider/widget-types, screensaver-provider/screensavers, event-bindings |
Names stay; see capability operations. |
| Manifest and package format | manifest.json, the .macroDeckPlugin package |
An existing manifest and package keep installing - see the manifest reference. |
| Analyzer diagnostic ids | MDP1001, …, the MDLOC family MDLOC001-MDLOC008 |
An id keeps its meaning and is never reused - see analyzers. |
| Conformance check ids | MDC0305, … |
Stable, so you can gate CI on them - see conformance. |
Plugin .resx contract |
the default-language file, Strings.<culture>.resx, named placeholders, the bracketed parameter type in a <comment> |
A plugin’s Localization/*.resx keeps compiling - see Localization. |
| Macro Deck’s localization catalog | macrodeck:Common.Save, … exposed as MacroDeckStrings |
Additive-only: a shipped key is never deleted outright. |
Retiring a catalog key follows the same idea as SDK deprecation: the key keeps resolving, a plugin referencing it gets MDLOC006 naming the replacement, and the record stays for as long as the deprecation lifecycle requires.
Background: ADR 0026 (protocol and SDK boundary) and ADR 0029 (packaging).
Breaking or not?
Section titled “Breaking or not?”Source and binary compatibility both count. A plugin ships compiled, so a change that recompiles cleanly can still break it at run time.
| Change | Verdict |
|---|---|
| Remove or rename a public type, member, parameter, enum value, constant, message type, field or error code | Break |
| Change a parameter or return type, or parameter order | Break |
| Add a parameter to a public method, even with a default value | Binary break |
Change class to struct, or add or narrow a generic constraint |
Break |
| Add a member to a public interface without a default implementation, or make an existing member abstract | Break for every implementer |
| Add a member to a public interface with a default implementation | Allowed |
| Add a new overload | Allowed |
| Add a value to a C# enum | Allowed by .NET’s own rules - give a switch over a Macro Deck enum a default branch |
| Add an optional DTO or manifest field | Allowed - unknown fields are ignored on read |
| Rename a JSON property, change its type, or change an enum’s wire representation | Break |
| Make an optional field required, tighten validation, or change a default a plugin relied on | Break |
| Change an error code’s meaning, even with the same spelling | Break |
Change ordering guarantees, id formats (the integrationId::localId shape), thrown exception types, timing or lifecycle contracts |
Break |
The two that surprise people most:
A parameter with a default is a binary break. The compiler bakes the full argument list into the
call site, so an already-compiled plugin calls a method that no longer exists and gets a
MissingMethodException.
// illustrative method// beforepublic Task ShowAsync(string text);
// after - recompiles cleanly, breaks every compiled callerpublic Task ShowAsync(string text, TimeSpan? duration = null);
// instead - keep the old method, add an overloadpublic Task ShowAsync(string text);public Task ShowAsync(string text, TimeSpan duration);A changed meaning is a break. Nothing about the spelling changes, but a plugin branching on it now branches wrongly.
hypothetical: an error code that meant "retry later" starts to mean "give up"same spelling, same field - still a break, because plugins that retry now retry wronglyHow contracts change
Section titled “How contracts change”The rule is add, do not change.
New overloads, rather than new parameters on an existing method - see the example above.
New optional members with a sensible default, rather than required ones.
New optional DTO fields. Unknown fields are ignored on read, so an older plugin simply does not see what it was not built for.
// illustrative: a reader built before displayName existed reads this exactly as {"id":"a"}{ "id": "a", "displayName": "Kitchen" }New message types. An unrecognised message type produces UNKNOWN_MESSAGE_TYPE and is never fatal:
the peer reports it and carries on. That makes every additive protocol change backward-compatible by
construction.
New capability interfaces a plugin opts into, rather than new members on an existing one. An existing interface is extended only through a default interface implementation.
// illustrative interface names// not this: every existing implementer stops loadingpublic interface IStateProvider { Task RefreshAsync(); }
// this: a new opt-in interfacepublic interface IRefreshableStateProvider : IStateProvider { Task RefreshAsync(); }
// or, on the existing interface, a default implementationpublic interface IStateProvider { Task RefreshAsync() => Task.CompletedTask; }Anything that has to go follows the deprecation lifecycle. It is marked with [Obsolete] and
[MacroDeckDeprecated] carrying a declared removal version, registered so it appears in the registry
table, and it keeps working unchanged the whole time. Removal happens only in the major release its
RemovedIn names - never earlier, never in a minor or a patch. You are told at compile time as
MDP5002, with the removal version and replacement in the message, and at
run time in the desktop app, where the person running your plugin sees the same finding under the same
id. See deprecations and
ADR 0037.
A wire break means a new protocol major, with the previous one still served - see protocol majors.
The 3.0 metadata retype
Section titled “The 3.0 metadata retype”One break was taken deliberately before 3.0 shipped, and it is the only non-additive change on this page.
The members carrying an action’s, a parameter’s and a config-flow step’s user-facing text changed type
from string to LocalizedText:
IActionDefinition.Name/.Description, ActionParameter.Label/.Description/.Placeholder and its
factories, ActionParameterOption.Label, ActionStateDefinition.Label,
WidgetTargetOptions.Label/.Description, ConfigFlowStep.Title/.Description, ConfigFlowLink.Label,
ConfigFlowCopyValue.Label, ConfigFlowInstruction.Text, and IIntegration.Name.
Label = "Client ID" // assigning: unchanged, LocalizedText converts from stringpublic string Name => "Timer"; // implementing: no longer compilespublic LocalizedText Name => "Timer"; // implementing: fixedImplementing one of those members is a source and a binary break. It was affordable because 3.0 had not
been released and no plugin existed to break; the alternative was a permanent parallel surface (Name
beside NameLocalized). After 3.0 this page’s ordinary rules apply to these members like any other.
Two things did not change:
VariableDefinition.Nameis still astring. It is the variable’s identity - the host derives a definition id from it and substitutes it into a template placeholder - so it cannot differ by language. Localized display text goes in the additiveDisplayNameproperty.- Descriptor metadata is localized from protocol v3.
ActionDescriptorDto.Name,ActionParameterDto.Label, the event and issue descriptors and the config-flow DTOs carryLocalizedText, so an out-of-process plugin’s action names render in the reader’s language. Below v3 the SDK resolves them in the plugin’s own default language first, because an older host would reject an object where it expects a string - the negotiated version decides, and v1 and v2 are still served. A plugin’s text inside a Macro Deck UI tree was never affected; it always travelled as a reference.ConfigFlowResultDto.EntryTitlestays a plain string on purpose: the host stores it as the configured entry’s name, which the user then owns and can rename.
Versions
Section titled “Versions”| Version | Scheme | Breaks allowed |
|---|---|---|
| SDK packages | Ordinary semver | Only at an SDK major, and only removals whose RemovedIn names that major |
| Plugin protocol | One monotonically increasing integer major, not semver | Only with a new major; the previous one stays served |
| Capability, per kind | Integer min/max range per kind | Only with a new capability version; negotiated separately from the protocol |
UI model (UiModelVersions) |
Integer major | Only with a new major; negotiated separately from the protocol |
Protocol majors
Section titled “Protocol majors”Unknown fields are ignored and unknown message types are reported rather than fatal, so every additive change is backward-compatible without a version change. A bump therefore only ever means breaking.
Today minimum is 1 and current is 3.
| Major | Changes exactly one thing | Older majors |
|---|---|---|
2 |
A widget appearance change names its states by stable id instead of the fixed Current/On/Off/Both selector, because a button may have any number of states. |
1 fully served - see the migration guide for what a version 1 plugin sees when it restyles a three-state button. |
3 |
Action names, parameter labels and config-flow text may be a {"$localized":…} reference the reader’s client resolves, instead of a plain string. |
1 and 2 fully served. A plugin below 3 must keep sending a plain string, since an older host would reject the object. |
What did not need a major:
- Actions gained the ability to supply a button’s states: an additive descriptor field plus an additive
capability operation, so a version
1plugin can be a state provider. Additive changes never move the major, however visible the feature. - The
variablescatalog half -discover,resolve,subscribeand thevariable-valueshost API’svalue/invalidate- is ordinarycapability.invoke/capability.resultandhost.invoke/host.resulttraffic. Variable definition attributes, awritecapability and asetoperation did change a payload plugins already send, so the capability version moved from1to2whileProtocolVersions.Currentstayed at3. - Localization moved the UI model major, not this one - see the localization major.
Negotiation happens exactly once, in POST /api/plugins/sessions:
plugin sends: { minimum: clientMin, maximum: clientMax }host computes: negotiated = min(clientMax, current)fails with PROTOCOL_VERSION_UNSUPPORTED when negotiated < max(clientMin, minimum),echoing the host's own supported rangeThe later session.hello only asserts the outcome - it never re-negotiates.
A protocol break means bumping the major and keeping the previous version served for the negotiated
range. Version 1 is never silently redefined. A plugin declaring { minimum: 1, maximum: 1 } in its
manifest’s compatibility.protocol keeps negotiating version 1 against a host that has moved on, until 1
leaves that host’s supported range - at which point the failure is explicit and diagnosable.
Capabilities negotiate independently, per kind, on the same min/max algorithm but with a
non-fatal failure policy: an unknown kind, or one whose range does not overlap the host’s, is
rejected with a reason and the session proceeds degraded. An unknown host API call is answered with
CapabilityUnsupported rather than failing the session, and a plugin that never declares a kind is never
asked about it.
UI model majors
Section titled “UI model majors”The localization major
Section titled “The localization major”Localizable Macro Deck UI text properties moved MacroDeck.Ui.Model (UiModelVersions.Current)
from 1 to 2: a text-bearing configuration property may now carry a localization reference where it
always carried a JSON string, so the session has to say which version it speaks.
Localization did not move the plugin protocol’s major. The protocol moved to 2 for an unrelated
reason (widget state by stable id) and localization would have left it at 1. The two majors have never
been coupled (see
ADR 0038).
Localization broke neither wire: the localization capability kind and its limits are additive, and a
localized value reads a plain JSON string as literal text.
Version 1 of the UI model stays served: the host opens every session at the current version and
honours whatever a provider negotiates down to, so a plugin on version 1 keeps emitting plain strings
untouched. See
ADR 0057.
The component major
Section titled “The component major”Renaming the component vocabulary moved MacroDeck.Ui.Model (UiModelVersions.Current) from 2 to
3. Every node type was respelled: the ten a reader can draw from the tree alone became ui.*, the four
that resolve a Macro Deck reference became macrodeck.*.
widget.* -> ui.* (drawn from the tree alone)widget.* -> macrodeck.* (resolves a Macro Deck reference)This one also moved UiModelVersions.Minimum. It is a hard cut - no widget.* spelling is
accepted, and no shim is published - so majors 1 and 2 of the UI model are no longer served.
Advertising them would be worse than refusing them: an unknown node type is non-fatal, so a version 2
tree would negotiate and then render every node as the unsupported placeholder. This was affordable
exactly once, because no plugin served a component surface and a UI tree is built per session and never
persisted. See
ADR 0064.
What did not need a UI model major
Section titled “What did not need a UI model major”New component types and properties are additive. The modifiers added a
modifiers property an older reader ignores, five gesture event names it never sends, and the ui.modifier
type it answers with the node’s fallback; UiModelVersions did not move. See
UI model compatibility.
What “maintained per major” means in practice
Section titled “What “maintained per major” means in practice”- Within an SDK major, nothing frozen is removed or changed incompatibly. A minor or patch may add, and may deprecate; it may not remove. Upgrading the SDK inside a major should never require a source change to your plugin.
- At an SDK major, only APIs whose
[MacroDeckDeprecated]named that major as theirRemovedIndisappear. You will have had at least one release cycle of compile-time and run-time warnings naming the replacement. - Within a protocol major, the error-code list and the message-type catalogue are append-only.
- At a protocol major, the previous major stays negotiable for as long as the host’s supported range
says it does. Read the descriptor at
GET /api/plugins/protocol, which needs no authentication, rather than hard-coding a version. - Limits and timeouts are advertised, not fixed. They are published in the protocol descriptor and again in the session response. A value changing is not a breaking change; read them at run time.
Verifying compatibility yourself
Section titled “Verifying compatibility yourself”- Run the conformance suite against your plugin:
macrodeck-plugin test. A broken check id is a break, and the ids are stable, so gate CI on them. See conformance. - Reference
MacroDeck.Plugin.Analyzers. Its source generator records the deprecated APIs your compilation actually references, so the host can report confirmed rather than inferred usage. A plugin without it lands ininferred, which is never presented to a user as fact. - Check the compatibility report in the session response: an overall state, the evidence source, and
per-finding guidance. States, best to worst:
compatible,deprecated_apis,update_recommended,update_required,partially_incompatible,incompatible.
If a fix cannot be made without a break
Section titled “If a fix cannot be made without a break”It is not made. A change that breaks a non-obsolete public contract is not shipped as a bug fix, is not made unilaterally, and does not arrive in a minor release. It goes through deprecation, or through a protocol major, or it does not happen.
See also
Section titled “See also”- Deprecations - the lifecycle, the registry, and the evidence model.
- Migrations - what a migration guide contains, and when one is published.
- Conformance - the contract suite and its stable check ids.
- Plugin protocol - version and capability negotiation in detail.