diff --git a/docs/CODEX_HANDOFF.md b/docs/CODEX_HANDOFF.md
index 8de200e..69a768b 100644
--- a/docs/CODEX_HANDOFF.md
+++ b/docs/CODEX_HANDOFF.md
@@ -68,20 +68,23 @@ application until both gates below have real evidence:
reviewed build runner, verified without execution, and pass the manual
no-device desktop checklist.
2. The known-working private JPT `.lbrn2` serialization evidence gate is met.
- Import mapping still requires explicit unit conversion and fail-closed
- handling of absent pulse width, with synthetic offline tests.
+ Offline mapping and disabled-output workflow staging now have synthetic
+ tests. Promotion still requires an independently established unit contract,
+ explicit resolution of absent pulse width and machine-context validation.
Raw projects, screenshots containing identifiers, machine data and build
binaries stay out of the public repository.
## Suggested first task
-An offline project MOPA seam and synthetic tests now exist; see
+An offline project MOPA seam, disabled-output Rayforge workflow bridge and
+synthetic tests now exist; see
`docs/research/lightburn-project-mopa-import.md`. It preserves both process
-element types and requires an explicit unit contract for normalization. It is
-not wired into Rayforge's workflow and never authorizes encoding. The next
-import task is host integration with layer identity and independently
-established units; absent pulse width and image equivalence remain blockers.
+element types and requires an explicit unit contract for normalization. The
+bridge binds source layer IDs, uses the host MOPA setters, serializes review
+metadata and disables every staged step. It never authorizes encoding. The
+next import task is UI/preflight presentation plus independently established
+units; absent pulse width and image equivalence remain blockers.
If a Windows artifact is available, keep the machine disconnected and run, in
order:
diff --git a/docs/research/lightburn-project-mopa-import.md b/docs/research/lightburn-project-mopa-import.md
index 5e7828b..df31071 100644
--- a/docs/research/lightburn-project-mopa-import.md
+++ b/docs/research/lightburn-project-mopa-import.md
@@ -20,13 +20,22 @@ settings remain blocked pending image-processing equivalence, including
Every result has `encoding_allowed=False`, `hardware_use_allowed=False` and
`transport=None`, even with complete normalized values. This is a callable
-offline metadata seam; it is not yet wired to Rayforge's project importer or
-workflow. Host integration must bind settings to layer indices, establish
-serialized units from independent evidence, resolve absent pulse width and
-validate exact machine, lens, correction and parameter limits. No existing
-golden lifecycle or upstream importer run was repeated; the package inventory
-was extended to include the new module.
+offline metadata seam. The companion workflow bridge matches the normalized
+LightBurn layer index to Rayforge's source-segment layer ID and stages complete
+vector MOPA values through the host's setters. Every staged step is disabled
+through `Step.set_visible(False)`, and its raw values, normalized values,
+review status and blockers are saved in the serializable `Step.extra` data.
+Image settings and incomplete settings remain disabled without applying MOPA
+values. Multi-step staging is preflighted and rolled back on setter failure.
+
+The bridge deliberately has no function that enables output or removes a
+blocker. Host UI and preflight integration must still establish serialized
+units from independent evidence, resolve absent pulse width and validate exact
+machine, lens, correction and parameter limits before a separate reviewed
+promotion path can exist. Rayforge's relevant Step and LaserStep files are now
+included in the pinned upstream contract manifest.
Validation covers unit conversion, absent fields, case sensitivity, duplicate
-values, invalid numbers, image modes, mixed layers and malformed XML. Existing
-packaging, no-device and public evidence checks remain part of the test suite.
+values, invalid numbers, image modes, mixed layers, exact layer binding,
+transactional rollback and malformed XML. Existing packaging, no-device and
+public evidence checks remain part of the test suite.
diff --git a/docs/research/upstream-contract-and-license-audit.md b/docs/research/upstream-contract-and-license-audit.md
index c8f41e0..2f34941 100644
--- a/docs/research/upstream-contract-and-license-audit.md
+++ b/docs/research/upstream-contract-and-license-audit.md
@@ -59,6 +59,7 @@ its manifest and sidecar; installer-only publication is not accepted.
| `register_machines` hook | Documented but unusable for this driver today | The hook exists, but the supplied manager does not expose driver-class registration |
| `NoDeviceDriver`, `OpsEncoder`, `EncodedOutput`, `MachineCodeOpMap` | Imported host interfaces | Required by the addon; pin and contract-test them before upgrades |
| `register_driver`, `drivers`, `driver_by_classname` | Internal registry | Functional but unsuitable as a permanent public contract; `register_driver` is not exported in `__all__` and no symmetric unregister function exists |
+| `Step.extra`, visibility serialization, MOPA setters | Imported host workflow interfaces | Used by the disabled-output import bridge; exact source files and symbols are pinned before host integration |
| Addon-manager registry cleanup | Host lifecycle implementation | Other registries support addon-owned cleanup, but the driver registry is not part of that mechanism |
| BalorMK `GalvoController` and `MockConnection` | Application-internal reference/test seam | Valid as a pinned behavioral oracle; not treated as a stable library API |
diff --git a/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/project_import.py b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/project_import.py
index 8b6a611..3687e97 100644
--- a/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/project_import.py
+++ b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/project_import.py
@@ -29,6 +29,18 @@ def _number(element: ET.Element, field: str) -> str | None:
return raw
+def _layer_id(element: ET.Element) -> str | None:
+ children = element.findall("index")
+ if not children:
+ return None
+ if len(children) != 1 or set(children[0].attrib) != {"Value"}:
+ raise ProjectImportError("ambiguous layer index")
+ raw = children[0].get("Value", "")
+ if not raw.isdecimal():
+ raise ProjectImportError("invalid layer index")
+ return str(int(raw))
+
+
def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dict:
"""Preserve observed XML fields and optionally normalize declared units.
@@ -51,6 +63,7 @@ def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dic
if root.tag != "LightBurnProject" or root.get("FormatVersion") != "1":
raise ProjectImportError("unsupported project format")
layers = []
+ layer_ids: set[str] = set()
for setting in root:
if setting.tag not in ("CutSetting", "CutSetting_Img"):
continue
@@ -58,6 +71,11 @@ def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dic
expected = ("Image",) if setting.tag == "CutSetting_Img" else ("Cut", "Scan")
if kind not in expected:
raise ProjectImportError("unsupported layer type")
+ layer_id = _layer_id(setting)
+ if layer_id is not None and layer_id in layer_ids:
+ raise ProjectImportError("duplicate layer index")
+ if layer_id is not None:
+ layer_ids.add(layer_id)
raw = {key: _number(setting, key) for key in ("frequency", "QPulseWidth")}
blockers = []
if unit_contract is None:
@@ -78,7 +96,8 @@ def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dic
"frequency_khz": float(Decimal(raw["frequency"]) / 1000),
"pulse_width_ns": float(Decimal(raw["QPulseWidth"])),
}
- layers.append({"element": setting.tag, "type": kind, "raw": raw,
+ layers.append({"element": setting.tag, "type": kind,
+ "source_layer_id": layer_id, "raw": raw,
"image_mode": image_mode,
"normalized": normalized, "blockers": blockers})
if not layers:
diff --git a/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/workflow_import.py b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/workflow_import.py
new file mode 100644
index 0000000..4b7d6e8
--- /dev/null
+++ b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/workflow_import.py
@@ -0,0 +1,138 @@
+"""Stage MOPA metadata on Rayforge workflow steps with output disabled."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from copy import deepcopy
+from typing import Any
+
+
+METADATA_KEY = "pirateray_import"
+
+
+class WorkflowImportError(ValueError):
+ """The imported layer cannot be bound safely to a Rayforge step."""
+
+
+def _host_integer(value: Any, label: str) -> int:
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ raise WorkflowImportError(f"{label} must be numeric")
+ result = int(value)
+ if result <= 0 or result != value:
+ raise WorkflowImportError(f"{label} must be a positive integer")
+ return result
+
+
+def stage_mopa_workflow_steps(
+ imported: Mapping[str, Any],
+ steps_by_layer_id: Mapping[str, Any],
+) -> dict[str, Any]:
+ """Bind complete imported MOPA settings while keeping every step disabled.
+
+ The mapping is constructed by the host integration from the source segment's
+ ``layer_id`` to the single workflow step created by Rayforge's assembler.
+ This seam never enables a step and never removes an existing blocker.
+ """
+ if imported.get("encoding_allowed") is not False:
+ raise WorkflowImportError("import must explicitly prohibit encoding")
+ if imported.get("hardware_use_allowed") is not False:
+ raise WorkflowImportError("import must explicitly prohibit hardware use")
+ layers = imported.get("layers")
+ if not isinstance(layers, list):
+ raise WorkflowImportError("imported layers are missing")
+
+ by_id: dict[str, Mapping[str, Any]] = {}
+ for layer in layers:
+ if not isinstance(layer, Mapping):
+ raise WorkflowImportError("imported layer must be an object")
+ layer_id = layer.get("source_layer_id")
+ if layer_id is None:
+ continue
+ if not isinstance(layer_id, str) or layer_id in by_id:
+ raise WorkflowImportError("imported layer identity is ambiguous")
+ by_id[layer_id] = layer
+ if not steps_by_layer_id:
+ raise WorkflowImportError("no Rayforge workflow steps were supplied")
+
+ global_blockers = imported.get("blockers")
+ if not isinstance(global_blockers, list) or not all(
+ isinstance(item, str) and item for item in global_blockers
+ ):
+ raise WorkflowImportError("global blockers are missing")
+
+ plans = []
+ step_identities: set[int] = set()
+ for layer_id, step in steps_by_layer_id.items():
+ if not isinstance(layer_id, str):
+ raise WorkflowImportError("Rayforge layer ID must be text")
+ if id(step) in step_identities:
+ raise WorkflowImportError("one Rayforge step cannot represent multiple layers")
+ step_identities.add(id(step))
+ layer = by_id.get(layer_id)
+ if layer is None:
+ raise WorkflowImportError(f"no imported settings for layer {layer_id}")
+ if not isinstance(getattr(step, "extra", None), dict):
+ raise WorkflowImportError("Rayforge step has no serializable extra data")
+ if METADATA_KEY in step.extra:
+ raise WorkflowImportError("Rayforge step already has PirateRay import data")
+ if not hasattr(step, "visible") or not callable(
+ getattr(step, "set_visible", None)
+ ):
+ raise WorkflowImportError("Rayforge step has no output visibility gate")
+ normalized = layer.get("normalized")
+ layer_blockers = layer.get("blockers")
+ if not isinstance(layer_blockers, list):
+ raise WorkflowImportError("layer blockers are missing")
+ apply_values = normalized is not None and not layer_blockers
+ frequency = pulse_width = None
+ if apply_values:
+ if not callable(getattr(step, "set_frequency", None)) or not callable(
+ getattr(step, "set_pulse_width", None)
+ ):
+ raise WorkflowImportError("Rayforge step has no MOPA setters")
+ frequency = _host_integer(normalized.get("frequency_hz"), "frequency")
+ pulse_width = _host_integer(
+ normalized.get("pulse_width_ns"), "pulse width"
+ )
+ plans.append((layer_id, step, layer, frequency, pulse_width))
+
+ snapshots = [
+ (step, step.visible, deepcopy(step.extra), getattr(step, "frequency", None),
+ getattr(step, "pulse_width", None))
+ for _layer_id, step, _layer, _frequency, _pulse_width in plans
+ ]
+ staged = []
+ try:
+ for layer_id, step, layer, frequency, pulse_width in plans:
+ step.set_visible(False)
+ blockers = list(global_blockers) + list(layer["blockers"])
+ if frequency is not None and pulse_width is not None:
+ step.set_frequency(frequency)
+ step.set_pulse_width(pulse_width)
+ step.extra[METADATA_KEY] = {
+ "status": "staged-review-required",
+ "source_layer_id": layer_id,
+ "raw": deepcopy(layer.get("raw")),
+ "normalized": deepcopy(layer.get("normalized")),
+ "blockers": blockers,
+ "encoding_allowed": False,
+ "hardware_use_allowed": False,
+ }
+ staged.append({
+ "source_layer_id": layer_id,
+ "parameters_applied": frequency is not None,
+ "output_enabled": False,
+ "blockers": blockers,
+ })
+ except Exception:
+ for step, visible, extra, frequency, pulse_width in snapshots:
+ step.set_visible(visible)
+ step.extra.clear()
+ step.extra.update(extra)
+ if frequency is not None:
+ step.frequency = frequency
+ if pulse_width is not None:
+ step.pulse_width = pulse_width
+ raise
+ return {"steps": staged, "encoding_allowed": False,
+ "hardware_use_allowed": False, "transport": None}
diff --git a/tests/fixtures/rayforge-addon-spike.json b/tests/fixtures/rayforge-addon-spike.json
index 95b36dc..d9a407c 100644
--- a/tests/fixtures/rayforge-addon-spike.json
+++ b/tests/fixtures/rayforge-addon-spike.json
@@ -23,6 +23,7 @@
"pirate_ray_jcz/rayforge_compat.py",
"pirate_ray_jcz/recipe_metadata.py",
"pirate_ray_jcz/worker.py",
+ "pirate_ray_jcz/workflow_import.py",
"rayforge-addon.yaml"
],
"registration_seam": "fail-closed API 23 compatibility adapter + on_unload cleanup"
diff --git a/tests/test_lightburn_project_mopa.py b/tests/test_lightburn_project_mopa.py
index 2984be3..52e8fd6 100644
--- a/tests/test_lightburn_project_mopa.py
+++ b/tests/test_lightburn_project_mopa.py
@@ -8,9 +8,9 @@ from pirate_ray_jcz.project_import import (
)
-def project(fields, tag="CutSetting", kind="Scan"):
+def project(fields, tag="CutSetting", kind="Scan", layer_id="0"):
return (f'<{tag} type="{kind}">'
- f'{fields}{tag}>').encode()
+ f'{fields}{tag}>').encode()
FIELDS = ''
@@ -25,6 +25,7 @@ class ProjectMopaTests(unittest.TestCase):
self.assertFalse(result["encoding_allowed"])
self.assertFalse(result["hardware_use_allowed"])
self.assertIsNone(result["transport"])
+ self.assertEqual(result["layers"][0]["source_layer_id"], "0")
def test_units_are_never_inferred_from_magnitude(self):
result = import_project_mopa(project(FIELDS))
@@ -69,10 +70,20 @@ class ProjectMopaTests(unittest.TestCase):
with self.assertRaises(ProjectImportError):
import_project_mopa(data)
+ def test_layer_indices_are_normalized_and_must_be_unique(self):
+ layer = import_project_mopa(project(FIELDS, layer_id="007"))["layers"][0]
+ self.assertEqual(layer["source_layer_id"], "7")
+ duplicate = (''
+ ''+FIELDS+''
+ ''+FIELDS+''
+ '').encode()
+ with self.assertRaisesRegex(ProjectImportError, "duplicate layer index"):
+ import_project_mopa(duplicate)
+
def test_multiple_layers_preserve_order_and_missing_pulse(self):
data = (''
- ''+FIELDS+''
- ''
+ ''+FIELDS+''
+ ''
'').encode()
layers = import_project_mopa(data, unit_contract=UNIT_CONTRACT)["layers"]
self.assertEqual([x["type"] for x in layers], ["Cut", "Image"])
diff --git a/tests/test_lightburn_workflow_import.py b/tests/test_lightburn_workflow_import.py
new file mode 100644
index 0000000..4b457bd
--- /dev/null
+++ b/tests/test_lightburn_workflow_import.py
@@ -0,0 +1,113 @@
+import sys
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parents[1] / "spikes/rayforge-jcz-no-device-addon"))
+from pirate_ray_jcz.project_import import UNIT_CONTRACT, import_project_mopa
+from pirate_ray_jcz.workflow_import import (
+ METADATA_KEY, WorkflowImportError, stage_mopa_workflow_steps,
+)
+
+
+class Step:
+ def __init__(self, fail=False):
+ self.visible = True
+ self.frequency = 1
+ self.pulse_width = 2
+ self.extra = {"preserved": True}
+ self.fail = fail
+
+ def set_frequency(self, value):
+ self.frequency = value
+
+ def set_visible(self, value):
+ self.visible = value
+
+ def set_pulse_width(self, value):
+ if self.fail:
+ raise ValueError("synthetic setter failure")
+ self.pulse_width = value
+
+
+def imported(fields, layer_id="3", kind="Cut", tag="CutSetting"):
+ data = (f'<{tag} type="{kind}">'
+ f'{fields}{tag}>').encode()
+ return import_project_mopa(data, unit_contract=UNIT_CONTRACT)
+
+
+FIELDS = ''
+
+
+class WorkflowImportTests(unittest.TestCase):
+ def test_complete_vector_settings_are_staged_but_output_is_disabled(self):
+ step = Step()
+ result = stage_mopa_workflow_steps(imported(FIELDS), {"3": step})
+ self.assertEqual((step.frequency, step.pulse_width), (125000, 25))
+ self.assertFalse(step.visible)
+ self.assertFalse(result["encoding_allowed"])
+ self.assertIsNone(result["transport"])
+ gate = step.extra[METADATA_KEY]
+ self.assertEqual(gate["status"], "staged-review-required")
+ self.assertFalse(gate["encoding_allowed"])
+ self.assertTrue(gate["blockers"])
+
+ def test_missing_pulse_and_image_mode_do_not_apply_parameters(self):
+ for result in (
+ imported(''),
+ imported(FIELDS + '', kind="Image", tag="CutSetting_Img"),
+ ):
+ with self.subTest(result=result):
+ step = Step()
+ staged = stage_mopa_workflow_steps(result, {"3": step})
+ self.assertEqual((step.frequency, step.pulse_width), (1, 2))
+ self.assertFalse(step.visible)
+ self.assertFalse(staged["steps"][0]["parameters_applied"])
+
+ def test_layer_identity_must_match(self):
+ with self.assertRaisesRegex(WorkflowImportError, "no imported settings"):
+ stage_mopa_workflow_steps(imported(FIELDS), {"4": Step()})
+
+ def test_existing_metadata_and_reused_step_are_rejected(self):
+ step = Step()
+ step.extra[METADATA_KEY] = {"status": "existing"}
+ with self.assertRaisesRegex(WorkflowImportError, "already has"):
+ stage_mopa_workflow_steps(imported(FIELDS), {"3": step})
+
+ two_layers = import_project_mopa(
+ (''
+ ''+FIELDS+''
+ ''+FIELDS+''
+ '').encode(), unit_contract=UNIT_CONTRACT)
+ reused = Step()
+ with self.assertRaisesRegex(WorkflowImportError, "multiple layers"):
+ stage_mopa_workflow_steps(two_layers, {"1": reused, "2": reused})
+
+ def test_preflight_failure_does_not_mutate_any_step(self):
+ result = import_project_mopa(
+ (''
+ ''+FIELDS+''
+ ''+FIELDS+''
+ '').encode(), unit_contract=UNIT_CONTRACT)
+ first = Step()
+ with self.assertRaisesRegex(WorkflowImportError, "no serializable extra"):
+ stage_mopa_workflow_steps(result, {"1": first, "2": object()})
+ self.assertTrue(first.visible)
+ self.assertEqual((first.frequency, first.pulse_width), (1, 2))
+
+ def test_setter_failure_rolls_back_every_step(self):
+ result = import_project_mopa(
+ (''
+ ''+FIELDS+''
+ ''+FIELDS+''
+ '').encode(), unit_contract=UNIT_CONTRACT)
+ first, second = Step(), Step(fail=True)
+ with self.assertRaisesRegex(ValueError, "synthetic setter failure"):
+ stage_mopa_workflow_steps(result, {"1": first, "2": second})
+ for step in (first, second):
+ self.assertTrue(step.visible)
+ self.assertEqual((step.frequency, step.pulse_width), (1, 2))
+ self.assertEqual(step.extra, {"preserved": True})
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/upstreams/rayforge.json b/upstreams/rayforge.json
index d6cf0d5..e8b4e82 100644
--- a/upstreams/rayforge.json
+++ b/upstreams/rayforge.json
@@ -38,6 +38,18 @@
"sha256": "6f734a0246f35a7f18d8e4fa60b38f66193d9a5ece0982829cc269133e6ddd0b",
"stability": "host-lifecycle-implementation",
"required_symbols": ["AddonManager", "AddonManager._unregister_addon_items"]
+ },
+ {
+ "path": "rayforge/core/step.py",
+ "sha256": "4d25da9e4fb9f854562bfd33d238db3660c3056eeadfaa95c8669c6770d504f4",
+ "stability": "imported-host-workflow-interface",
+ "required_symbols": ["Step", "Step.set_visible", "Step.to_dict", "Step.from_dict"]
+ },
+ {
+ "path": "rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/laser_step.py",
+ "sha256": "59c94fec6861edc94383db856bba0c335e0e7c4d16a878e7c2a2c62b657e06c5",
+ "stability": "imported-host-workflow-interface",
+ "required_symbols": ["LaserStep", "LaserStep.set_frequency", "LaserStep.set_pulse_width", "LaserStep.to_dict", "LaserStep.from_dict"]
}
],
"purpose": "Pinned host application for offline reuse and JCZ addon validation"