Skip to content

Commit cc10c88

Browse files
author
FTMahringer
committed
feat(plugins): add deprecation compatibility matrix
Add manifest deprecation metadata, registry compatibility evaluation, CLI and dashboard warnings, suppressible version warnings, and install-time compatibility blocking.
1 parent 5cfa6fa commit cc10c88

26 files changed

Lines changed: 739 additions & 29 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# v2.6.9-dev
2+
3+
## Governance: Deprecation and compatibility matrix
4+
5+
- Added manifest `deprecated` metadata validation with `since`, `reason`, and optional migration URL.
6+
- Added registry compatibility evaluation for plugin versions using `min_synapse` and `max_synapse`.
7+
- Added compatibility and deprecation warnings to the CLI registry commands.
8+
- Added store warning badges and detail panels for deprecated, incompatible, and updateable entries.
9+
- Added an admin policy toggle to suppress version warnings for frozen environments.
10+
- Blocked direct and bundle installs when the selected registry version is incompatible with the running SYNAPSE version.
11+
- Added Flyway migration `V22__plugin_governance_and_store_state.sql` so plugin/store signing and governance fields match the application model.

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
---
1111

12+
## [v2.6.9-dev] - 2026-05-19
13+
14+
**Governance Deprecation & Compatibility Matrix**
15+
16+
### Added
17+
- Manifest `deprecated` metadata with `since`, `reason`, and optional migration URL validation.
18+
- Registry compatibility matrix evaluation with per-version `min_synapse` and `max_synapse` support.
19+
- CLI compatibility inspection plus registry warnings for deprecated, incompatible, and outdated entries.
20+
- Store policy support for suppressing version warnings in frozen environments.
21+
- Flyway migration `V22__plugin_governance_and_store_state.sql` to align plugin and store governance fields with the current entity model.
22+
23+
### Changed
24+
- Store entries are now enriched with compatibility and deprecation metadata before being returned to the dashboard and registry consumers.
25+
- Bundle installs now preserve registry manifest metadata and block incompatible versions before installation.
26+
- Direct plugin installs now reject incompatible registry versions before lifecycle install begins.
27+
- Dashboard store cards and details now surface deprecation, migration, incompatibility, and update guidance.
28+
29+
### Fixed
30+
- The persisted plugin/store schema now matches the existing signing and governance fields instead of relying on unmigrated entity columns.
31+
1232
## [v2.6.8-dev] - 2026-05-19
1333

1434
**Governance Reporting & Takedowns**

docs/roadmaps/SYNAPSE_V3_IMPLEMENTATION_ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -504,7 +504,7 @@ security controls. Completes the plugin platform story.
504504
- Existing installs: dashboard warning on takedown
505505
- **Exit**: Report filed → admin notified → plugin hidden on takedown
506506

507-
#### v2.6.9-dev: Governance — Deprecation & Compatibility Matrix
507+
#### v2.6.9-dev: Governance — Deprecation & Compatibility Matrix (completed)
508508
- Manifest `deprecated` field (since, reason, migration URL)
509509
- Registry compatibility matrix (plugin version ↔ Synapse version ranges)
510510
- Store + CLI warnings on deprecated or incompatible versions

packages/cli/cmd/registry.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ var registryListCmd = &cobra.Command{
2929
RunE: func(cmd *cobra.Command, args []string) error {
3030
client := clientFromCmd(cmd)
3131
rawJSON, _ := cmd.Flags().GetBool("json")
32+
suppressWarnings, _ := cmd.Flags().GetBool("suppress-warnings")
3233
query, _ := cmd.Flags().GetString("query")
3334
source, _ := cmd.Flags().GetString("source")
3435
entryType, _ := cmd.Flags().GetString("type")
@@ -66,6 +67,9 @@ var registryListCmd = &cobra.Command{
6667
fmt.Sprint(entry["version"]),
6768
fmt.Sprint(entry["source"]),
6869
)
70+
if !suppressWarnings {
71+
printRegistryWarnings(entry)
72+
}
6973
}
7074
return nil
7175
},
@@ -78,6 +82,7 @@ var registryVersionsCmd = &cobra.Command{
7882
RunE: func(cmd *cobra.Command, args []string) error {
7983
client := clientFromCmd(cmd)
8084
rawJSON, _ := cmd.Flags().GetBool("json")
85+
suppressWarnings, _ := cmd.Flags().GetBool("suppress-warnings")
8186

8287
var resp []map[string]any
8388
if err := client.Get("/api/registry/"+args[0]+"/versions", &resp); err != nil {
@@ -95,8 +100,54 @@ var registryVersionsCmd = &cobra.Command{
95100
tuioutput.Row(
96101
fmt.Sprint(version["version"]),
97102
fmt.Sprint(version["min_synapse"]),
103+
fmt.Sprint(version["max_synapse"]),
98104
fmt.Sprint(version["artifact_url"]),
99105
)
106+
if !suppressWarnings {
107+
printVersionWarnings(version)
108+
}
109+
}
110+
return nil
111+
},
112+
}
113+
114+
var registryCompatibilityCmd = &cobra.Command{
115+
Use: "compatibility <pluginId>",
116+
Short: "Show compatibility details for a registry entry",
117+
Args: cobra.ExactArgs(1),
118+
RunE: func(cmd *cobra.Command, args []string) error {
119+
client := clientFromCmd(cmd)
120+
rawJSON, _ := cmd.Flags().GetBool("json")
121+
version, _ := cmd.Flags().GetString("version")
122+
123+
path := "/api/registry/" + args[0] + "/compatibility"
124+
if version != "" {
125+
path += "?version=" + url.QueryEscape(version)
126+
}
127+
128+
var resp map[string]any
129+
if err := client.Get(path, &resp); err != nil {
130+
return err
131+
}
132+
if rawJSON {
133+
tuioutput.JSON(resp)
134+
return nil
135+
}
136+
137+
tuioutput.Header("Registry Compatibility: " + args[0])
138+
tuioutput.KV("Synapse", fmt.Sprint(resp["currentSynapseVersion"]))
139+
tuioutput.KV("Version", fmt.Sprint(resp["selectedVersion"]))
140+
tuioutput.KV("Compatible", fmt.Sprint(resp["compatible"]))
141+
tuioutput.KV("Min", fmt.Sprint(resp["minimumSynapseVersion"]))
142+
tuioutput.KV("Max", fmt.Sprint(resp["maximumSynapseVersion"]))
143+
if resp["deprecated"] == true {
144+
tuioutput.Warning(fmt.Sprintf("Deprecated since %v: %v", resp["deprecatedSince"], resp["deprecationReason"]))
145+
if migrationURL := fmt.Sprint(resp["migrationUrl"]); migrationURL != "" && migrationURL != "<nil>" {
146+
tuioutput.KV("Migration", migrationURL)
147+
}
148+
}
149+
if resp["updateAvailable"] == true {
150+
tuioutput.Warning(fmt.Sprintf("Recommended version: %v", resp["recommendedVersion"]))
100151
}
101152
return nil
102153
},
@@ -293,6 +344,9 @@ func init() {
293344
registryListCmd.Flags().String("source", "", "Filter by source")
294345
registryListCmd.Flags().String("type", "", "Filter by type")
295346
registryListCmd.Flags().Bool("compatible", false, "Only show entries compatible with this SYNAPSE version")
347+
registryListCmd.Flags().Bool("suppress-warnings", false, "Hide compatibility and deprecation warnings")
348+
registryVersionsCmd.Flags().Bool("suppress-warnings", false, "Hide compatibility and deprecation warnings")
349+
registryCompatibilityCmd.Flags().String("version", "", "Specific plugin version to evaluate")
296350

297351
registryAddSourceCmd.Flags().String("id", "", "Source ID (defaults to a slug from name)")
298352
registryAddSourceCmd.Flags().String("name", "", "Source display name")
@@ -310,6 +364,7 @@ func init() {
310364
registryCmd.AddCommand(
311365
registryListCmd,
312366
registryVersionsCmd,
367+
registryCompatibilityCmd,
313368
registrySyncCmd,
314369
registryStatusCmd,
315370
registrySourcesCmd,
@@ -320,3 +375,32 @@ func init() {
320375
commandfeatures.BindSubcommandListing(registryCmd)
321376
rootCmd.AddCommand(registryCmd)
322377
}
378+
379+
func printRegistryWarnings(entry map[string]any) {
380+
meta, _ := entry["meta"].(map[string]any)
381+
if meta == nil {
382+
return
383+
}
384+
if compatibility, ok := meta["compatibility"].(map[string]any); ok {
385+
if compatible, ok := compatibility["compatible"].(bool); ok && !compatible {
386+
tuioutput.Warning(fmt.Sprintf("Incompatible with current SYNAPSE version: %v", entry["id"]))
387+
} else if updateAvailable, ok := compatibility["update_available"].(bool); ok && updateAvailable {
388+
tuioutput.Warning(fmt.Sprintf("Update recommended: %v -> %v", entry["version"], compatibility["recommended_version"]))
389+
}
390+
}
391+
if deprecated, ok := meta["deprecated"].(map[string]any); ok {
392+
tuioutput.Warning(fmt.Sprintf("Deprecated since %v: %v", deprecated["since"], deprecated["reason"]))
393+
}
394+
}
395+
396+
func printVersionWarnings(version map[string]any) {
397+
if compatible, ok := version["compatible"].(bool); ok && !compatible {
398+
tuioutput.Warning(fmt.Sprintf("Incompatible version: %v", version["version"]))
399+
}
400+
if deprecated, ok := version["deprecated"].(bool); ok && deprecated {
401+
tuioutput.Warning(fmt.Sprintf("Deprecated since %v: %v", version["deprecated_since"], version["deprecation_reason"]))
402+
}
403+
if recommended, ok := version["recommended"].(bool); ok && recommended {
404+
tuioutput.Warning(fmt.Sprintf("Recommended version: %v", version["version"]))
405+
}
406+
}

packages/cli/cmd/registry_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,47 @@ func TestRegistryAddSourceCommand(t *testing.T) {
134134
}
135135
}
136136

137+
func TestRegistryCompatibilityCommand(t *testing.T) {
138+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
139+
if r.URL.Path != "/api/registry/legacy-provider/compatibility" {
140+
t.Fatalf("unexpected path: %s", r.URL.Path)
141+
}
142+
_ = json.NewEncoder(w).Encode(map[string]any{
143+
"pluginId": "legacy-provider",
144+
"currentSynapseVersion": "2.6.9-dev",
145+
"selectedVersion": "1.0.0",
146+
"minimumSynapseVersion": ">=2.6.0",
147+
"maximumSynapseVersion": "<=2.6.9",
148+
"compatible": true,
149+
"deprecated": true,
150+
"deprecatedSince": "1.0.0",
151+
"deprecationReason": "Use provider-v2",
152+
"migrationUrl": "https://example.com/provider-v2",
153+
"recommendedVersion": "1.1.0",
154+
"updateAvailable": true,
155+
})
156+
}))
157+
defer server.Close()
158+
159+
cmd, _, err := newIsolatedRootCommand()
160+
if err != nil {
161+
t.Fatalf("newIsolatedRootCommand: %v", err)
162+
}
163+
164+
got := captureStdout(t, func() {
165+
cmd.SetArgs([]string{"registry", "compatibility", "--host", server.URL, "legacy-provider"})
166+
if err := cmd.Execute(); err != nil {
167+
t.Fatalf("execute: %v", err)
168+
}
169+
})
170+
if !strings.Contains(got, "Deprecated since 1.0.0") {
171+
t.Fatalf("unexpected output: %q", got)
172+
}
173+
if !strings.Contains(got, "Recommended version: 1.1.0") {
174+
t.Fatalf("unexpected output: %q", got)
175+
}
176+
}
177+
137178
func captureStdout(t *testing.T, fn func()) string {
138179
t.Helper()
139180

packages/cli/internal/output/print.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const (
1212
bold = "\033[1m"
1313
cyan = "\033[36m"
1414
green = "\033[32m"
15+
yellow = "\033[33m"
1516
red = "\033[31m"
1617
dim = "\033[2m"
1718
)
@@ -32,6 +33,10 @@ func OK(msg string) {
3233
fmt.Printf("%s✓%s %s\n", green, reset, msg)
3334
}
3435

36+
func Warning(msg string) {
37+
fmt.Printf("%s!%s %s\n", yellow, reset, msg)
38+
}
39+
3540
func Error(msg string) {
3641
fmt.Fprintf(os.Stderr, "%s✗%s %s\n", red, reset, msg)
3742
}

packages/core/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
<groupId>dev.synapse</groupId>
1717
<artifactId>synapse-core</artifactId>
18-
<version>2.6.8-dev</version>
18+
<version>2.6.9-dev</version>
1919
<name>synapse-core</name>
2020
<description>SYNAPSE Spring Boot backend</description>
2121

packages/core/src/main/java/dev/synapse/plugins/core/ManifestValidator.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import java.util.Locale;
1111
import java.util.Map;
1212
import java.util.regex.Pattern;
13+
import dev.synapse.plugins.loader.dependency.VersionConstraint;
1314

1415
/**
1516
* Validates plugin manifests before install.
@@ -49,6 +50,9 @@ public ValidationResult validate(PluginManifest manifest) {
4950
errors.add("version must follow semver format (e.g. 1.0.0)");
5051
}
5152

53+
validateVersionConstraint("min_synapse", manifest.minSynapse(), errors);
54+
validateVersionConstraint("max_synapse", manifest.maxSynapse(), errors);
55+
5256
if (blank(manifest.author())) {
5357
errors.add("author is required");
5458
}
@@ -69,10 +73,22 @@ public ValidationResult validate(PluginManifest manifest) {
6973
}
7074

7175
validateSignature(manifest, errors);
76+
validateDeprecated(manifest, errors);
7277

7378
return errors.isEmpty() ? ValidationResult.ok() : ValidationResult.fail(errors);
7479
}
7580

81+
private void validateVersionConstraint(String field, String value, List<String> errors) {
82+
if (blank(value)) {
83+
return;
84+
}
85+
try {
86+
VersionConstraint.parse(value);
87+
} catch (IllegalArgumentException e) {
88+
errors.add(field + " must be a valid version constraint");
89+
}
90+
}
91+
7692
private void validateMcp(PluginManifest manifest, List<String> errors) {
7793
if (manifest.mcp() == null || manifest.mcp().isEmpty()) {
7894
errors.add("mcp section is required for type mcp");
@@ -207,6 +223,29 @@ private void validateSignature(PluginManifest manifest, List<String> errors) {
207223
}
208224
}
209225

226+
private void validateDeprecated(PluginManifest manifest, List<String> errors) {
227+
if (manifest.deprecated() == null || manifest.deprecated().isEmpty()) {
228+
return;
229+
}
230+
231+
String since = stringValue(manifest.deprecated().get("since"));
232+
String reason = stringValue(manifest.deprecated().get("reason"));
233+
String migrationUrl = stringValue(manifest.deprecated().get("migration_url"));
234+
235+
if (blank(since)) {
236+
errors.add("deprecated.since is required when deprecated is present");
237+
} else if (!VERSION_PATTERN.matcher(since).matches()) {
238+
errors.add("deprecated.since must follow semver format (e.g. 1.0.0)");
239+
}
240+
if (blank(reason)) {
241+
errors.add("deprecated.reason is required when deprecated is present");
242+
}
243+
if (!blank(migrationUrl) &&
244+
!(migrationUrl.startsWith("http://") || migrationUrl.startsWith("https://"))) {
245+
errors.add("deprecated.migration_url must start with http:// or https://");
246+
}
247+
}
248+
210249
private String stringValue(Object value) {
211250
return value == null ? null : value.toString();
212251
}

packages/core/src/main/java/dev/synapse/plugins/core/PluginManifest.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@ public record PluginManifest(
2020
String license,
2121
String description,
2222
String minSynapse,
23+
String maxSynapse,
2324
List<String> tags,
2425
List<PluginDependency> dependencies,
2526
List<PluginDependency> softDependencies,
2627
Map<String, Object> skills,
2728
Map<String, Object> mcp,
2829
Map<String, Object> bundle,
30+
Map<String, Object> deprecated,
2931
Map<String, Object> signature,
3032
Map<String, Object> configSchema,
3133
Map<String, Object> raw
@@ -66,12 +68,14 @@ public static PluginManifest fromMap(Map<String, Object> map) {
6668
str(map, "license"),
6769
str(map, "description"),
6870
str(map, "min_synapse"),
71+
str(map, "max_synapse"),
6972
tags,
7073
hardDeps,
7174
softDeps,
7275
mapOf(map.get("skills")),
7376
mapOf(map.get("mcp")),
7477
mapOf(map.get("bundle")),
78+
mapOf(map.get("deprecated")),
7579
mapOf(map.get("signature")),
7680
mapOf(map.get("config_schema")),
7781
map

0 commit comments

Comments
 (0)