diff --git a/docs/CODEX_HANDOFF.md b/docs/CODEX_HANDOFF.md index 0d7d1b8..fe90b2a 100644 --- a/docs/CODEX_HANDOFF.md +++ b/docs/CODEX_HANDOFF.md @@ -21,6 +21,7 @@ The repository now provides: synthetic inputs; - a fail-closed manual desktop-acceptance report and validator; - pinned upstream contract/license manifests and a drift checker; +- a fail-closed API-23 adapter isolating Rayforge's internal driver registry; - a proposed MIT project-license decision in ADR 0003; Kaz has not accepted it, so the repository remains unlicensed. diff --git a/docs/adr/0003-project-license-and-upstream-boundary.md b/docs/adr/0003-project-license-and-upstream-boundary.md index ad7cde0..2eef062 100644 --- a/docs/adr/0003-project-license-and-upstream-boundary.md +++ b/docs/adr/0003-project-license-and-upstream-boundary.md @@ -58,6 +58,10 @@ Kaz must explicitly choose the project license. Until then this ADR remains Proposed, the README's no-license statement remains authoritative, and no root `LICENSE` file is created. +The compatibility-adapter part of item 5 is implemented for the offline addon +and fails closed outside Rayforge addon API 23. This implementation evidence +does not accept this ADR or select the project license. + See the [upstream contract and license audit](../research/upstream-contract-and-license-audit.md) for the evidence and API-stability assessment. diff --git a/docs/research/rayforge-addon-spike.md b/docs/research/rayforge-addon-spike.md index ec833c2..7fbf4a4 100644 --- a/docs/research/rayforge-addon-spike.md +++ b/docs/research/rayforge-addon-spike.md @@ -13,7 +13,7 @@ An external addon loaded through Rayforge's real `AddonManager` and registered discovery specification or transport, and returns a recognizable binary test payload from an `OpsEncoder`. -The following lifecycle completed twice with identical JSON results: +The following lifecycle completes against the pinned Rayforge checkout: 1. load the external addon; 2. resolve the driver by class name; @@ -21,12 +21,12 @@ The following lifecycle completed twice with identical JSON results: 4. disable the addon and verify driver removal; 5. re-enable it and verify re-registration; 6. reload it and verify there is exactly one registry entry; -7. package its three source/manifest files into a ZIP; and +7. package its source and manifest files into a ZIP; and 8. disable it cleanly. -The two result files had SHA-256 -`610f8f191526afe4685f8659c1ef2b34fac3c68a8f8088d77ca5e1fdd6080bfc`. -The checked result is `tests/fixtures/rayforge-addon-spike.json`. +The current checked result is `tests/fixtures/rayforge-addon-spike.json` with +SHA-256 +`c7a7ba7d561af58829c9c0fc271234e7600d50cca5f8125fc574fa3f0a1aa25f`. ## Registration seam finding @@ -37,10 +37,13 @@ a driver class. The source-level driver registry instead exposes `rayforge.machine.driver.register_driver(driver)`. The working spike therefore registers during import of the addon's worker -entry point and removes its class in `on_unload`. This survives load, disable, -enable and reload, but it relies on mutable module-level registry internals for -cleanup. Before a production addon, Rayforge should ideally expose a dedicated -addon-aware driver registry hook with supported unregister behavior. +entry point and removes its class in `on_unload`. All access to the mutable +module-level registry is now isolated in `rayforge_compat.py`. The adapter +accepts only addon API 23, checks the expected registry shape, refuses class +name collisions, is idempotent, and verifies symmetric cleanup. Contract drift +therefore fails closed instead of silently replacing another driver. Before a +production addon, Rayforge should still expose a dedicated addon-aware driver +registry hook with supported unregister behavior. ## Reproduce @@ -67,8 +70,9 @@ The script refuses any Rayforge revision other than the pinned commit. - The ZIP check is addon packaging, not a proof that a complete PyInstaller or MSIX Rayforge build discovers an externally installed addon. -- The current registration seam is functional but not a stable, addon-aware - API. Upstream feasibility should be assessed before relying on it. +- The compatibility adapter contains the unstable seam, but does not make it + a supported addon API. Upstream feasibility should be assessed before + relying on it in production. - No JCZ command encoding, machine profile, UI, project reload, discovery, transport, controller status or cancellation is implemented here. - Nothing in this spike authorizes USB access, galvo movement or emission. diff --git a/docs/research/upstream-contract-and-license-audit.md b/docs/research/upstream-contract-and-license-audit.md index aa7fa86..c8f41e0 100644 --- a/docs/research/upstream-contract-and-license-audit.md +++ b/docs/research/upstream-contract-and-license-audit.md @@ -65,7 +65,10 @@ its manifest and sidecar; installer-only publication is not accepted. The immediate upstream contribution should be a narrow, addon-aware driver registry contract with symmetric registration and cleanup. Until upstream accepts such a contract, all internal registry access must remain isolated in -one compatibility adapter and guarded by the hashes/symbol tests. +one compatibility adapter and guarded by the hashes/symbol tests. The current +addon now does this: its API-23 adapter rejects version drift, malformed +registries, duplicate entries and foreign class-name collisions, and its +load/disable/enable/reload lifecycle passes against the pinned checkout. ## Code-origin boundary diff --git a/docs/roadmap.md b/docs/roadmap.md index 23a9127..750e616 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -333,8 +333,9 @@ ignored build area, records their hashes and refuses to overwrite an existing test run. The pinned upstream contracts and license notices are now machine-verifiable. Rayforge's documented addon lifecycle is viable, but production driver -registration still needs a supported symmetric registry API; project license -selection remains an explicit owner decision in ADR 0003. +registration still needs a supported symmetric registry API. The current +internal access is contained by a fail-closed API-23 compatibility adapter; +project license selection remains an explicit owner decision in ADR 0003. ## Decision rules diff --git a/docs/status/phase-0.md b/docs/status/phase-0.md index 8f642ce..ccb81ab 100644 --- a/docs/status/phase-0.md +++ b/docs/status/phase-0.md @@ -87,7 +87,9 @@ evidence for: run directory; - pinned Rayforge and MeerK40t license/source contracts with drift tests, plus checkpoint schema 2 requiring the Rayforge MIT notice in the portable ZIP - and beside the installer manifest. + and beside the installer manifest; +- a single fail-closed Rayforge API-23 compatibility adapter around the + internal driver registry, with collision, drift and symmetric-cleanup tests. The remaining M1 gates are deliberately explicit: diff --git a/scripts/spike_rayforge_addon.py b/scripts/spike_rayforge_addon.py index f82982a..60fca62 100644 --- a/scripts/spike_rayforge_addon.py +++ b/scripts/spike_rayforge_addon.py @@ -113,7 +113,9 @@ def run(source: Path, addon_path: Path) -> dict[str, object]: ), "lifecycle": ["load", "disable", "enable", "reload", "disable"], "packaged_files": packaged_files, - "registration_seam": "worker import side effect + on_unload cleanup", + "registration_seam": ( + "fail-closed API 23 compatibility adapter + on_unload cleanup" + ), } diff --git a/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/rayforge_compat.py b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/rayforge_compat.py new file mode 100644 index 0000000..450c683 --- /dev/null +++ b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/rayforge_compat.py @@ -0,0 +1,127 @@ +"""Fail-closed compatibility boundary for Rayforge's driver registry. + +Rayforge API 23 does not expose addon-aware driver registration and cleanup. +Keep the unavoidable internal registry access here so it can be removed when +the host provides a supported symmetric API. +""" + +from typing import Any + + +SUPPORTED_PLUGIN_API_VERSION = 23 + + +class RayforgeCompatibilityError(RuntimeError): + """The pinned Rayforge registry contract is unavailable or has drifted.""" + + +def _resolve_host() -> tuple[Any, int]: + from rayforge.core.hooks import PLUGIN_API_VERSION + from rayforge.machine import driver as registry + + return registry, PLUGIN_API_VERSION + + +def _validate_host(registry: Any, plugin_api_version: int) -> None: + if plugin_api_version != SUPPORTED_PLUGIN_API_VERSION: + raise RayforgeCompatibilityError( + "unsupported Rayforge addon API: " + f"expected {SUPPORTED_PLUGIN_API_VERSION}, got {plugin_api_version}" + ) + if not isinstance(getattr(registry, "driver_by_classname", None), dict): + raise RayforgeCompatibilityError( + "Rayforge driver_by_classname registry is unavailable" + ) + if not isinstance(getattr(registry, "drivers", None), list): + raise RayforgeCompatibilityError("Rayforge drivers registry is unavailable") + if not callable(getattr(registry, "register_driver", None)): + raise RayforgeCompatibilityError("Rayforge register_driver is unavailable") + + +def _host( + registry: Any | None, plugin_api_version: int | None +) -> tuple[Any, int]: + if registry is None or plugin_api_version is None: + resolved_registry, resolved_version = _resolve_host() + registry = resolved_registry if registry is None else registry + plugin_api_version = ( + resolved_version + if plugin_api_version is None + else plugin_api_version + ) + _validate_host(registry, plugin_api_version) + return registry, plugin_api_version + + +def register_driver( + driver_cls: type, + *, + registry: Any | None = None, + plugin_api_version: int | None = None, +) -> bool: + """Register exactly one driver class, or fail before replacing anything.""" + + registry, _version = _host(registry, plugin_api_version) + name = driver_cls.__name__ + registered = registry.driver_by_classname.get(name) + named_entries = [item for item in registry.drivers if item.__name__ == name] + + if registered is driver_cls: + if named_entries == [driver_cls]: + return False + raise RayforgeCompatibilityError( + f"inconsistent Rayforge driver registry for {name}" + ) + if registered is not None or named_entries: + raise RayforgeCompatibilityError( + f"Rayforge driver name collision for {name}" + ) + + registry.register_driver(driver_cls) + if registry.driver_by_classname.get(name) is not driver_cls: + raise RayforgeCompatibilityError( + f"Rayforge did not index registered driver {name}" + ) + if sum(item is driver_cls for item in registry.drivers) != 1: + raise RayforgeCompatibilityError( + f"Rayforge did not register exactly one {name} driver" + ) + return True + + +def unregister_driver( + driver_cls: type, + *, + registry: Any | None = None, + plugin_api_version: int | None = None, +) -> bool: + """Remove only the exact class previously registered by this addon.""" + + registry, _version = _host(registry, plugin_api_version) + name = driver_cls.__name__ + registered = registry.driver_by_classname.get(name) + identity_entries = [item for item in registry.drivers if item is driver_cls] + named_foreign = [ + item + for item in registry.drivers + if item.__name__ == name and item is not driver_cls + ] + + if registered is None and not identity_entries and not named_foreign: + return False + if registered is not driver_cls or named_foreign: + raise RayforgeCompatibilityError( + f"refusing to unregister foreign Rayforge driver {name}" + ) + + del registry.driver_by_classname[name] + registry.drivers[:] = [ + item for item in registry.drivers if item is not driver_cls + ] + if name in registry.driver_by_classname or any( + item is driver_cls for item in registry.drivers + ): + raise RayforgeCompatibilityError( + f"Rayforge did not fully unregister driver {name}" + ) + return True diff --git a/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/worker.py b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/worker.py index f44b58a..8206516 100644 --- a/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/worker.py +++ b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/worker.py @@ -12,7 +12,6 @@ import struct from typing import Any from rayforge.core.hooks import hookimpl -from rayforge.machine import driver as driver_registry from rayforge.machine.driver import NoDeviceDriver from rayforge.pipeline.encoder.base import ( EncodedOutput, @@ -24,6 +23,7 @@ from .offline_session import ( OfflineJCZSession, OfflineSessionState, ) +from .rayforge_compat import register_driver, unregister_driver class OfflineJCZEncoder(OpsEncoder): @@ -341,19 +341,11 @@ class PirateRayJCZNoDeviceDriver(NoDeviceDriver): def _register() -> None: - name = PirateRayJCZNoDeviceDriver.__name__ - if name not in driver_registry.driver_by_classname: - driver_registry.register_driver(PirateRayJCZNoDeviceDriver) + register_driver(PirateRayJCZNoDeviceDriver) def _unregister() -> None: - name = PirateRayJCZNoDeviceDriver.__name__ - registered = driver_registry.driver_by_classname.get(name) - if registered is PirateRayJCZNoDeviceDriver: - del driver_registry.driver_by_classname[name] - driver_registry.drivers[:] = [ - item for item in driver_registry.drivers if item is not registered - ] + unregister_driver(PirateRayJCZNoDeviceDriver) # Rayforge currently has no driver registry in call_registration_hooks(). diff --git a/tests/fixtures/rayforge-addon-spike.json b/tests/fixtures/rayforge-addon-spike.json index f051da7..e0ea8f1 100644 --- a/tests/fixtures/rayforge-addon-spike.json +++ b/tests/fixtures/rayforge-addon-spike.json @@ -19,9 +19,10 @@ "pirate_ray_jcz/__init__.py", "pirate_ray_jcz/offline_session.py", "pirate_ray_jcz/profile_import.py", + "pirate_ray_jcz/rayforge_compat.py", "pirate_ray_jcz/recipe_metadata.py", "pirate_ray_jcz/worker.py", "rayforge-addon.yaml" ], - "registration_seam": "worker import side effect + on_unload cleanup" + "registration_seam": "fail-closed API 23 compatibility adapter + on_unload cleanup" } diff --git a/tests/test_rayforge_addon_spike.py b/tests/test_rayforge_addon_spike.py index d666860..57eacec 100644 --- a/tests/test_rayforge_addon_spike.py +++ b/tests/test_rayforge_addon_spike.py @@ -28,6 +28,10 @@ class RayforgeAddonSpikeTests(unittest.TestCase): result["lifecycle"], ) self.assertEqual(23, result["rayforge_api_version"]) + self.assertEqual( + "fail-closed API 23 compatibility adapter + on_unload cleanup", + result["registration_seam"], + ) def test_addon_contains_no_hardware_transport_import(self): source = "\n".join( diff --git a/tests/test_rayforge_compat.py b/tests/test_rayforge_compat.py new file mode 100644 index 0000000..a431b3e --- /dev/null +++ b/tests/test_rayforge_compat.py @@ -0,0 +1,123 @@ +import importlib.util +import unittest +from pathlib import Path + + +ROOT = Path(__file__).parents[1] +COMPAT_PATH = ( + ROOT + / "spikes" + / "rayforge-jcz-no-device-addon" + / "pirate_ray_jcz" + / "rayforge_compat.py" +) +SPEC = importlib.util.spec_from_file_location("rayforge_compat_test", COMPAT_PATH) +COMPAT = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(COMPAT) + + +class FakeRegistry: + def __init__(self): + self.drivers = [] + self.driver_by_classname = {} + + def register_driver(self, driver_cls): + self.drivers.append(driver_cls) + self.driver_by_classname[driver_cls.__name__] = driver_cls + + +class ExampleDriver: + pass + + +class RayforgeCompatibilityTests(unittest.TestCase): + def test_registration_and_cleanup_are_symmetric_and_idempotent(self): + registry = FakeRegistry() + + self.assertTrue( + COMPAT.register_driver( + ExampleDriver, registry=registry, plugin_api_version=23 + ) + ) + self.assertFalse( + COMPAT.register_driver( + ExampleDriver, registry=registry, plugin_api_version=23 + ) + ) + self.assertEqual([ExampleDriver], registry.drivers) + self.assertIs( + ExampleDriver, registry.driver_by_classname["ExampleDriver"] + ) + + self.assertTrue( + COMPAT.unregister_driver( + ExampleDriver, registry=registry, plugin_api_version=23 + ) + ) + self.assertFalse( + COMPAT.unregister_driver( + ExampleDriver, registry=registry, plugin_api_version=23 + ) + ) + self.assertEqual([], registry.drivers) + self.assertEqual({}, registry.driver_by_classname) + + def test_api_version_drift_fails_before_mutation(self): + registry = FakeRegistry() + + with self.assertRaisesRegex( + COMPAT.RayforgeCompatibilityError, + "expected 23, got 24", + ): + COMPAT.register_driver( + ExampleDriver, registry=registry, plugin_api_version=24 + ) + + self.assertEqual([], registry.drivers) + self.assertEqual({}, registry.driver_by_classname) + + def test_name_collision_is_never_replaced_or_removed(self): + foreign_driver = type("ExampleDriver", (), {}) + registry = FakeRegistry() + registry.register_driver(foreign_driver) + + with self.assertRaisesRegex( + COMPAT.RayforgeCompatibilityError, "name collision" + ): + COMPAT.register_driver( + ExampleDriver, registry=registry, plugin_api_version=23 + ) + with self.assertRaisesRegex( + COMPAT.RayforgeCompatibilityError, "foreign" + ): + COMPAT.unregister_driver( + ExampleDriver, registry=registry, plugin_api_version=23 + ) + + self.assertEqual([foreign_driver], registry.drivers) + self.assertIs( + foreign_driver, registry.driver_by_classname["ExampleDriver"] + ) + + def test_incomplete_registry_contract_fails_closed(self): + class MissingRegister: + drivers = [] + driver_by_classname = {} + + with self.assertRaisesRegex( + COMPAT.RayforgeCompatibilityError, "register_driver" + ): + COMPAT.register_driver( + ExampleDriver, + registry=MissingRegister(), + plugin_api_version=23, + ) + + def test_adapter_has_no_transport_imports(self): + source = COMPAT_PATH.read_text(encoding="utf-8").lower() + for forbidden in ("import usb", "import serial", "import socket"): + self.assertNotIn(forbidden, source) + + +if __name__ == "__main__": + unittest.main()