Skip to content

feat(store): refuse to install a plugin that needs a newer core (re-target of #429) - #431

Merged
ChuckBuilds merged 4 commits into
mainfrom
fix/compatibility-gate-onto-main
Aug 3, 2026
Merged

feat(store): refuse to install a plugin that needs a newer core (re-target of #429)#431
ChuckBuilds merged 4 commits into
mainfrom
fix/compatibility-gate-onto-main

Conversation

@ChuckBuilds

@ChuckBuilds ChuckBuilds commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Re-opens #429 against main. #429 was merged, but into its stacked base push/version-reporting — which had already been squash-merged as #428. So the merge landed on an orphaned branch and none of this reached main. Verified: src/plugin_system/compatibility.py is absent from main, and store_manager.py there has neither _install_plugin_impl nor threading.RLock().

Nothing was lost. These are the same three commits, cherry-picked onto current main (which now also carries #428 and #430). No conflicts — #430 touched installer scripts, not the plugin system.

The review-thread fixes from #427 are already on main and are not repeated here.


Why

ledmatrix_min_version was decoration. The loader logged an advisory warning and continued; the store never compared the core version at all, so a routine "update" delivered a plugin that could not run. This is the change B6 — the sports-unification sunset — depends on.

The gate

Lives in install_plugin, after the manifest is on disk and before dependencies install — the earliest knowable point, since the registry carries no compatibility field. It is also the chokepoint: _reinstall_with_rollback calls install_plugin, so a refused update restores the version the user already had.

Floor resolution moves to src/plugin_system/compatibility.py, shared with the loader so the two cannot drift.

Refusal requires evidence. An undeclared floor, an unparseable version on either side, or a core below TRUSTWORTHY_FLOOR (2.0.0) all allow the install. That last one is load-bearing: the v3.1.0 release reports __version__ = "1.0.0" while nearly every manifest floors at 2.0.0, so a strict gate would lock those users out of the store entirely. A regression test pins it.

The gate is inert today — all 42 manifests declare exactly 2.0.0. It arms when B5 starts declaring 3.2.0 floors.

Known gap, recorded in #427: the gate reads versions[].ledmatrix_min_version, not compatible_versions — which is the schema-required field and supports upper bounds. Harmless now (no manifest uses one), must close before B6.

Two bugs found while validating on hardware

  1. A failed install destroyed the plugin it replaced. _install_plugin_impl deletes the existing directory before downloading; the update path was protected by _reinstall_with_rollback, a direct install was not — and the gate added a new way to fail late. install_plugin is now a wrapper that sets the old copy aside and restores on failure, including on exceptions. The aside name embeds .standalone-backup- because plugin_manager._scan_directory_for_plugins:177 keys on that substring.

  2. The fix for Stocks #1 would have deadlocked every plugin update. The wrapper needs the per-plugin lock, but _reinstall_with_rollback holds it across its call to install_plugin and threading.Lock is not reentrant — so update_plugin → _reinstall_with_rollback → install_plugin would hang the request thread. Confirmed by reverting to a plain Lock: the test times out at 10s. Now RLock.

Verified

792 core unit tests pass on this branch against current main.

On devpi (Pi, Python 3.13.5, real registry and network): fresh install, reinstall-over-existing, failed-reinstall-restores, and a real update_plugin through the full rollback path in 13.1s. 22 plugins load, web API and UI 200.

Still outstanding

CI does not run the three new test files — the test.yml enrollment needs the workflow scope, which the pushing token still lacks. That follow-up is unchanged.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

Summary by CodeRabbit

  • New Features

    • Added plugin compatibility checks based on declared minimum core versions.
    • Incompatible official plugins are rejected before installation.
    • Compatibility checks tolerate missing or invalid version information.
  • Bug Fixes

    • Existing plugin installations are preserved and restored when updates or reinstalls fail.
    • Improved rollback handling prevents deadlocks and cleans up temporary backups.
    • Concurrent installations of the same plugin are safely serialized.

ChuckBuilds and others added 3 commits August 3, 2026 16:17
`ledmatrix_min_version` was decoration. The loader logged an advisory warning
and continued; the store never compared the core version at all, so a routine
"update" delivered a plugin that could not run. That is the gap phase B6 (the
sports-unification sunset) cannot be done over: deleting a plugin's bundled
fallback while nothing enforces the floor hands un-updated users a scoreboard
that raises ModuleNotFoundError at load and is reported only as one line in
the journal.

The gate lives in install_plugin, after the manifest is on disk and before
dependencies are installed. That is the earliest knowable point -- the
registry carries no compatibility field, so the floor is not visible until
the files are down -- and it is also the chokepoint: _reinstall_with_rollback
calls install_plugin, so a refused *update* restores the version the user
already had, for free.

Floor resolution and the comparison move to src/plugin_system/compatibility.py,
shared with the loader so the two cannot drift. Both read all four spellings
published manifests use, including the deprecated `ledmatrix_min`.

Refusal requires evidence. An undeclared floor, an unparseable version on
either side, or a core below TRUSTWORTHY_FLOOR (2.0.0) all allow the install.
That last one is deliberate and load-bearing: the v3.1.0 release reports
__version__ = "1.0.0" while nearly every published manifest floors at 2.0.0,
so a strict gate would lock those users out of the plugin store entirely --
much worse than the problem being solved. They stay unprotected until they
update the core, which is also what fixes their version string.

Verified: 782 core unit tests pass, including 25 new ones and the existing
loader-warning suite unchanged (the refactor is behavior-preserving). The
install tests drive the real install_plugin path with the download stubbed --
the allow and refuse cases differ only in the declared floor, so the refusal
is demonstrably the gate and not an earlier bail-out.

Follow-ups, deliberately not in this PR: surfacing the reason in the store UI
rather than only the log, and publishing the floor in plugins.json so the
store can refuse before downloading.

Phase B4 in docs/SPORTS_UNIFICATION.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
Found while validating the compatibility gate. `_install_plugin_impl` deletes
the existing plugin directory *before* downloading, so any failure after that
point leaves the user with nothing. `_reinstall_with_rollback` protects the
update path exactly this way; a direct `install_plugin` had no equivalent.

The gate made this reachable in a new way: a plugin whose declared floor
exceeds the running core is now refused *after* the old copy is already gone.
Floors are hand-written and can be over-declared, so the refusal could remove
a plugin that had been working fine on that core.

install_plugin is now a thin wrapper that renames any existing install aside,
delegates to _install_plugin_impl, and restores it on failure -- including
when the implementation raises, which is re-raised after the restore. It is a
pass-through when nothing is installed and when called from
_reinstall_with_rollback, which has already moved the old copy aside; a test
pins that so the two mechanisms cannot start nesting.

The aside name embeds '.standalone-backup-' because
plugin_manager._scan_directory_for_plugins keys on exactly that substring to
skip backups. A different name would have made the backup discoverable as a
duplicate plugin; a test pins that too.

789 core unit tests pass, including 7 new ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
Second bug found while validating the previous commit on hardware.

install_plugin's new set-aside/restore had no lock. The web UI runs Flask
threaded, so a double-clicked Install button gives two threads the same
plugin_id; interleaved, one thread's restore deletes the other's freshly
installed copy. _reinstall_with_rollback already guards exactly this with a
per-plugin lock, and install_plugin needs the same one.

Taking that lock naively deadlocks. _reinstall_with_rollback holds it across
its call to install_plugin, and threading.Lock is not reentrant -- so the
request thread hangs forever on the standard monorepo update path
(update_plugin -> _reinstall_with_rollback -> install_plugin), which is to say
on every plugin update. Verified by reverting to a plain Lock: the regression
test times out after 10s instead of passing.

The per-plugin locks are now RLocks, and install_plugin holds one for its
whole set-aside/install/restore sequence.

Verified on devpi (Pi, Python 3.13.5, real registry and network):
- update_plugin on an up-to-date plugin: True in 5.4s
- update_plugin forced through the full reinstall-with-rollback path:
  True in 13.1s, correct version restored, old copy replaced, no backup
  directories left behind
- install -> reinstall-over-existing -> failed-reinstall-restores: all pass
  against real downloads
- 22 plugins load, no tracebacks, web API and UI 200, steady-state journal
  50 lines/min

791 core unit tests pass, including 2 new concurrency tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@ChuckBuilds, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 36 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0a75df52-50b6-410d-a353-9211ad23f043

📥 Commits

Reviewing files that changed from the base of the PR and between 1bbf934 and b32563c.

📒 Files selected for processing (2)
  • .github/workflows/release-version-check.yml
  • .github/workflows/test.yml
📝 Walkthrough

Walkthrough

The PR adds shared plugin compatibility checks, enforces them during official installation, and protects existing installations with hidden backups, restoration handling, reentrant locks, and concurrency regression tests.

Changes

Plugin installation behavior

Layer / File(s) Summary
Compatibility contract and decisions
src/plugin_system/compatibility.py, test/test_plugin_compatibility_gate.py
Adds semantic-version parsing, manifest requirement resolution, the trustworthy core-version floor, compatibility decisions, and coverage for supported and invalid inputs.
Compatibility enforcement during installation
src/plugin_system/plugin_loader.py, src/plugin_system/store_manager.py, test/test_plugin_compatibility_gate.py
The loader and store use the shared compatibility module. Incompatible plugins are rejected before dependency installation. Tests verify matching loader and store results.
Backup restoration and concurrent installation safety
src/plugin_system/store_manager.py, test/test_install_preserves_existing.py
Existing installations move to hidden backups during installation. Failed installs restore backups, successful installs remove them, and reentrant locks support nested rollback installs.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PluginStoreManager
  participant compatibility.check
  participant PluginDirectory
  participant DependencyInstaller

  PluginStoreManager->>PluginDirectory: Move existing installation to hidden backup
  PluginStoreManager->>compatibility.check: Validate manifest against core version
  compatibility.check-->>PluginStoreManager: Return compatibility verdict
  alt Compatible
    PluginStoreManager->>DependencyInstaller: Install plugin dependencies
    PluginStoreManager->>PluginDirectory: Remove backup after success
  else Incompatible or failed
    PluginStoreManager->>PluginDirectory: Delete partial installation
    PluginStoreManager->>PluginDirectory: Restore hidden backup
  end
Loading

Possibly related PRs

  • ChuckBuilds/LEDMatrix#405: Both PRs modify plugin installation and update rollback logic to preserve existing installations with temporary backups.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: refusing plugin installation when the plugin requires a newer core version.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/compatibility-gate-onto-main

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 38 complexity · 2 duplication

Metric Results
Complexity 38
Duplication 2

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

These were split out of #428/#429 because the token pushing them lacked the
`workflow` scope. Folding them in here rather than opening a stacked PR --
#429 was merged into its stacked base after that base had already been
squash-merged, so its content never reached main, and one such near-miss is
enough.

All three enrolled suites exist on this branch: test_version_consistency.py
came with #428 and is on main; the other two arrive with the commits above.
Enrolling them in a separate PR would have either raced with this one on
test.yml or briefly pointed CI at files main did not have.

- test.yml: enroll test_version_consistency, test_plugin_compatibility_gate
  and test_install_preserves_existing in the core unit job. Until now these
  32 tests existed but nothing ran them automatically.

- release-version-check.yml: run scripts/check_release_version.py on pushed
  v* tags and published releases, plus workflow_dispatch so a tag can be
  checked *before* it is created. No dependencies -- it reads src/__init__.py
  and CHANGELOG.md only.

Verified: both workflow files parse, and the release check still passes for
v3.2.0 against this tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
src/plugin_system/plugin_loader.py (1)

685-698: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Confirm _parse_semver has no remaining callers, then remove it.

_warn_if_incompatible now uses compatibility.parse_semver (Line 712, 719) instead of self._parse_semver. This static method appears to duplicate compatibility.parse_semver exactly. Keeping both risks the two copies drifting apart if one is fixed (e.g., the dead-except issue flagged in compatibility.py) without the other being updated.

#!/bin/bash
# Description: Check whether PluginLoader._parse_semver still has any callers.
rg -n '_parse_semver' src/plugin_system/plugin_loader.py
rg -n '\._parse_semver\(' src -g '*.py'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/plugin_system/plugin_loader.py` around lines 685 - 698, Confirm that no
callers remain for PluginLoader._parse_semver, then remove this static method
and its associated dead code. Preserve _warn_if_incompatible’s use of
compatibility.parse_semver as the single semver parsing implementation.
test/test_plugin_compatibility_gate.py (1)

204-232: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Exercise the real _warn_if_incompatible, not a re-derivation of its logic.

This test recomputes the loader's warn/no-warn decision inline (Lines 220-231) instead of invoking PluginLoader._warn_if_incompatible on an instance. The only real touch of PluginLoader is hasattr(PluginLoader, "_warn_if_incompatible") (Line 232), which only confirms the method exists. If the loader's implementation ever diverges from this reimplementation (e.g., a future change to the debug-log branch), this test keeps passing because it checks its own copy of the logic, not the loader's.

Instantiate PluginLoader with a mocked logger, monkeypatch src.__version__, call the real method, and assert on logger.warning.called to genuinely pin the cross-module agreement this test class is named for.

def test_same_verdict(self, manifest, core, expected, monkeypatch):
    import src
    from src.plugin_system.plugin_loader import PluginLoader

    store_ok, _ = compatibility.check(manifest, core)
    assert store_ok is expected

    monkeypatch.setattr(src, "__version__", core)
    loader = PluginLoader(logger=MagicMock())
    loader._warn_if_incompatible("some-plugin", manifest)
    assert loader.logger.warning.called is (not expected)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test_plugin_compatibility_gate.py` around lines 204 - 232, Update
TestLoaderAndStoreAgree.test_same_verdict to exercise
PluginLoader._warn_if_incompatible directly instead of recomputing its decision
with compatibility.parse_semver and compatibility.declared_min_version. Add
monkeypatch, set src.__version__ to core, instantiate PluginLoader with a mocked
logger, invoke _warn_if_incompatible with the manifest, and assert
logger.warning.called matches not expected.
test/test_install_preserves_existing.py (1)

45-129: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add coverage for the "no rollback net" fallback branches.

TestFailedInstallPreservesPrevious covers the happy path of setting a backup aside, but two fallback branches in install_plugin (store_manager.py:1203-1267) have no test:

  • backup_path.exists() and not self._safe_remove_directory(backup_path) → falls back to installing without a rollback net.
  • plugin_path.rename(backup_path) raising OSError → falls back to installing without a rollback net.

The source comments mark these as deliberate degrade-gracefully behavior ("Better to attempt the install than to refuse outright"). A regression that turns either fallback into an outright refusal, or that raises instead of degrading, would pass this suite unnoticed.

Add two tests that mock _safe_remove_directory to return False and mock Path.rename (or the plugin path's .rename) to raise OSError, then assert _install_plugin_impl is still invoked.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test_install_preserves_existing.py` around lines 45 - 129, Add two tests
to TestFailedInstallPreservesPrevious covering install_plugin’s no-rollback
fallback paths: one with an existing backup where _safe_remove_directory returns
False, and one where the existing plugin path’s rename raises OSError. In both
cases, mock _install_plugin_impl and assert it is still invoked, confirming
installation degrades gracefully instead of refusing or propagating the setup
error.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/plugin_system/plugin_loader.py`:
- Around line 685-698: Confirm that no callers remain for
PluginLoader._parse_semver, then remove this static method and its associated
dead code. Preserve _warn_if_incompatible’s use of compatibility.parse_semver as
the single semver parsing implementation.

In `@test/test_install_preserves_existing.py`:
- Around line 45-129: Add two tests to TestFailedInstallPreservesPrevious
covering install_plugin’s no-rollback fallback paths: one with an existing
backup where _safe_remove_directory returns False, and one where the existing
plugin path’s rename raises OSError. In both cases, mock _install_plugin_impl
and assert it is still invoked, confirming installation degrades gracefully
instead of refusing or propagating the setup error.

In `@test/test_plugin_compatibility_gate.py`:
- Around line 204-232: Update TestLoaderAndStoreAgree.test_same_verdict to
exercise PluginLoader._warn_if_incompatible directly instead of recomputing its
decision with compatibility.parse_semver and compatibility.declared_min_version.
Add monkeypatch, set src.__version__ to core, instantiate PluginLoader with a
mocked logger, invoke _warn_if_incompatible with the manifest, and assert
logger.warning.called matches not expected.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c5b00e9c-07c2-426e-9a37-48cd00689dfb

📥 Commits

Reviewing files that changed from the base of the PR and between f2b246e and 1bbf934.

📒 Files selected for processing (5)
  • src/plugin_system/compatibility.py
  • src/plugin_system/plugin_loader.py
  • src/plugin_system/store_manager.py
  • test/test_install_preserves_existing.py
  • test/test_plugin_compatibility_gate.py

@ChuckBuilds
ChuckBuilds merged commit 970ca2d into main Aug 3, 2026
9 checks passed
evansalter pushed a commit to evansalter/LEDMatrix that referenced this pull request Aug 5, 2026
The 3.2.0 section described the unified sports library but none of the
install-path work that landed in ChuckBuilds#428 and ChuckBuilds#431 -- which matters more than a
normal changelog omission, because the sunset rule keys on this section to
tell plugin authors what a given floor buys them.

The headline addition: 3.2.0 is the first release that *enforces*
ledmatrix_min_version. Before it the floor was advisory, so a plugin could
declare one and still be delivered to a core that could not run it. That is
the property B6 waits on, and it is now stated where a plugin author will
look for it -- along with the caveat that a core reporting below 2.0.0 is
treated as unknown rather than old and is never blocked.

Also records compatibility.py (and that it does not yet read
compatible_versions), check_release_version.py and its workflow, the
install-preservation fix, the reentrant-lock deadlock fix, and the
web_interface version re-export.

No version bump: 3.2.0 is unreleased, so this describes the release being
cut rather than a new one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
ChuckBuilds added a commit that referenced this pull request Aug 11, 2026
A line wrapped onto "#433), the newest manifest entry ...", which
markdownlint reads as a malformed ATX heading (MD018). Reflowed so the
line starts with "(#431, #433)" instead.

Not the suggested fix: adding a space after the hash would have turned
the PR reference into "# 433". The B5 safety claim raised alongside this
was already corrected in ac44b5a.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
ChuckBuilds added a commit that referenced this pull request Aug 21, 2026
The heading read "B5 — adoption is safe by construction", which this same
document disproves two sections later: four of the eight adopted plugins
shipped with scroll mode broken on a 3.2.0 core and were repaired in
plugins #251.

The body was already careful -- it says fallback compatibility is what is
guaranteed, and that correctness on a core which *does* ship the module
needs object-level and scroll-mode validation. The heading was not, and a
heading is what a reader scanning the plan actually takes away.

Retitled to name both halves, with a sentence up front saying why the
unqualified claim is false and pointing at the retrospective that shows
it. The phase intro said "one of them is safe by construction and the
other is not"; that now says what it actually means -- one cannot break a
user on an old core, the other can.

The second review point, MD018 on the ATX heading at line 409, does not
reproduce: that line now begins "(#431, #433)" rather than "#433)", so
there is no bare-hash heading. `grep -cE '^#+[^ #]'` returns 0 for the
whole file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
ChuckBuilds added a commit that referenced this pull request Sep 1, 2026
Sideloading a plugin from a URL validated required manifest fields, warned on
version-field and schema problems, and then installed whatever it had found --
never once comparing the core version. With this, all three routes in refuse on
the same rule: install_plugin (#431/#433), the git-pull update path (the commit
before this one), and now sideloading.

The argument for gating it is not the same as for the other two, and worth
stating. An automatic store update is something that happens TO a user; a
sideload is something they chose. But the floor is not advice about intent --
it is a statement that the plugin cannot run on this core. Letting it through
produces exactly the silent PluginState.ERROR at load that the store gate
exists to prevent, and someone who pasted a URL is no better placed to diagnose
that than someone who pressed Update.

Checked after the download, like the others, because the manifest is the only
place the floor is written. Placed before the move into plugins/, so the
existing `finally` removes the temp tree and nothing half-installed survives a
refusal. The reason string is returned to the caller, so the web UI shows the
same actionable message the store path already gives.

TestSideloadGate pins the three cases the other routes pin: refuses a floor
above this core and leaves nothing behind, still allows a compatible plugin
(the guard against a gate that refuses everything and passes the first test),
and does not block a 2.0.0 floor on a core reporting an untrustworthy version
-- the v3.1.0-reports-1.0.0 population, who must not be locked out.

TestInstallGate's docstring said this route was still ungated. It no longer is,
so it now points at TestSideloadGate rather than describing a gap.

Verified the tests bite: removing the check fails the refusal case.
Full suite 3723 passed, 6 skipped.


Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ChuckBuilds added a commit that referenced this pull request Sep 2, 2026
This PR was opened to say B6 was deliberately held. It has since run, so the
document said the opposite of the truth -- which is the exact failure it was
written to fix: "a plan that misreports which phase it is in is worse than no
plan".

Merges current main first (53 commits), which brings in #508's corrections to
this same file, then replaces the hold with what happened.

**Why the hold lifted is worth recording, because the stated gate was never
met.** It asked for evidence of 3.2.0 uptake, and that evidence could not
arrive: the core updates by `git pull --rebase`, so release-asset counts cannot
measure it, and no store-side telemetry exists. What changed is that the risk
the gate protected against was closed directly -- the store now refuses a
plugin whose floor exceeds the running core on all three routes in:
install_plugin (#431/#433), update_plugin's git branch (#508), and
install_from_url (#510). A pre-3.2.0 user cannot receive a sunset plugin at
all, so they keep the version they run. Refusal replaced the bundled copy,
which is what the copy stood in for.

Records what shipped (eight plugins, ~5,800 lines, plugins #346/#349/#350/#351)
and the two findings worth carrying to the next module: baseball's fallback was
the only one holding orchestration logic the core lacked, and two tests had
been leaning on the guard -- soccer's stubbed `src` in a way that shadowed the
core, so it had been exercising the frozen copy rather than the shipping class
since B5.

The remaining-work list is replaced too. Its first item was "nothing on the
critical path, B6 is waiting on calendar time", which is no longer true. What
remains: hardware soaks (with a note to check the rig's display_mode first, or
a board in switch mode tells you nothing about the scroll code), cutting 3.3.0
-- not required by B6, whose floors are 3.2.0, but calendar 1.2.3 floors at
3.3.0 and is un-installable until it exists -- and reconsidering the modules
held back during the sunset.

Keeps the pre-B6 sections as history. The reasoning still applies to the next
module; it is just no longer in force for this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9
ChuckBuilds added a commit that referenced this pull request Sep 2, 2026
…511)

#510 shows as merged, but into fix/gate-git-pull-updates -- #508's branch --
rather than main. #508 reached main first, so the sideload gate was left behind
on a branch. Same failure as plugins #350/#351, which merged into each other's
bases; worth knowing the pattern, because GitHub reports these as MERGED and
`gh pr list` shows nothing outstanding.

main today has two of the three routes gated: install_plugin (#431/#433) and
update_plugin's git branch (#508). install_from_url validates required manifest
fields and then installs whatever it found, never comparing the core version.

Cherry-picked unchanged from the orphaned branch -- it applies to main with no
conflict. TestSideloadGate pins the three cases the other routes pin: refuses a
floor above this core leaving nothing behind, still allows a compatible plugin
(the guard against a gate that refuses everything), and does not block a 2.0.0
floor on a core reporting an untrustworthy version.

Full suite 3725 passed, 6 skipped.


Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ChuckBuilds added a commit that referenced this pull request Sep 2, 2026
* docs(sports): record where B6 stands, and why it is waiting

The phase table had B4 as "next" and B5 as "after B4" while both had shipped,
and described B6 as blocked on B4's gate — which is now merged and released. A
plan that misreports which phase it is in is worse than no plan: the next
person reads it and repeats finished work.

Corrected, and three things that were only ever decided in conversation are now
written down:

  * **B6 is deliberately held.** 3.2.0 published 2026-08-03; 3.1.0 ran nine
    months before it. B6's premise is that cores without the module are gone,
    and there is no release-asset count or install telemetry to show that.
    Running it now strands users on their current plugin versions. The gate
    that makes it safe is already built and tested — it is the calendar that is
    missing, and no amount of further code changes that.
  * **Stop adopting further shared modules** (data_sources, game_renderer,
    base_odds_manager) until B6 closes. Each adoption adds a copy to keep in
    step against a payoff contingent on B6.
  * **A B5 retrospective**, because "the adoption went fine" is not what
    happened: four of eight shipped with scroll mode broken on a 3.2.0 core.
    The bundled fallback did not protect against it — the break was on the
    modern path — which is an argument for the sunset, not against it. Records
    the ledger too: net negative on disk until B6 runs.

Also replaces the "what's next" list, whose first five items were all done,
with what actually remains.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

* fix: apply CodeRabbit auto-fixes

Fixed 1 file(s) based on 2 unresolved review comments.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>

* docs(sports): stop a wrapped PR reference reading as a heading

A line wrapped onto "#433), the newest manifest entry ...", which
markdownlint reads as a malformed ATX heading (MD018). Reflowed so the
line starts with "(#431, #433)" instead.

Not the suggested fix: adding a space after the hash would have turned
the PR reference into "# 433". The B5 safety claim raised alongside this
was already corrected in ac44b5a.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

* docs(sports): scope the B5 safety claim to the fallback

The heading read "B5 — adoption is safe by construction", which this same
document disproves two sections later: four of the eight adopted plugins
shipped with scroll mode broken on a 3.2.0 core and were repaired in
plugins #251.

The body was already careful -- it says fallback compatibility is what is
guaranteed, and that correctness on a core which *does* ship the module
needs object-level and scroll-mode validation. The heading was not, and a
heading is what a reader scanning the plan actually takes away.

Retitled to name both halves, with a sentence up front saying why the
unqualified claim is false and pointing at the retrospective that shows
it. The phase intro said "one of them is safe by construction and the
other is not"; that now says what it actually means -- one cannot break a
user on an old core, the other can.

The second review point, MD018 on the ATX heading at line 409, does not
reproduce: that line now begins "(#431, #433)" rather than "#433)", so
there is no bare-hash heading. `grep -cE '^#+[^ #]'` returns 0 for the
whole file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW

* docs(sports): re-check the hold, and close two items that are already done

The remaining-work list had two entries that finished without the doc noticing,
which is the failure mode this file exists to prevent.

- The stale plugin-test tranche is gone. run_plugin_tests.py --all now reports
  174 passed, 2 skipped, 0 failed across the whole fleet. Recorded how to
  re-check it too: these are standalone scripts, not a pytest suite, and one
  calls sys.exit(1) at import, so pointing pytest at a plugin directory
  collapses into an INTERNALERROR that looks nothing like the real state.
- CLAUDE.md already says eight panel sizes.

That leaves the hardware soaks as the only open item needing work rather than
calendar time.

B6's prerequisite is now built -- core test/test_sports_sunset_matrix.py
(#505) -- so the phase table and the regression-test section say so, and the
two modelling traps it had to work through are recorded for whoever touches it
next: the copy-removed shape must be an unguarded import or the failure names
scroll_display_legacy instead of the core module, and only the leaf module may
be hidden because a pre-3.2.0 core still ships src/common/.

The hold itself is re-checked and unchanged: v3.2.0 is still latest,
__version__ is still 3.2.0, no 3.3.0, 23 days rather than the few months the
gate asks for. Also worth stating plainly -- the core updates by git pull, not
by downloading a release, so release-asset counts would not measure uptake even
if we had them. Whatever unblocks this has to come from the store side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant