diff --git a/docs/CODEX_HANDOFF.md b/docs/CODEX_HANDOFF.md
index 59b88ef..8de200e 100644
--- a/docs/CODEX_HANDOFF.md
+++ b/docs/CODEX_HANDOFF.md
@@ -76,6 +76,13 @@ binaries stay out of the public repository.
## Suggested first task
+An offline project MOPA seam 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.
+
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
new file mode 100644
index 0000000..5e7828b
--- /dev/null
+++ b/docs/research/lightburn-project-mopa-import.md
@@ -0,0 +1,32 @@
+# Offline LightBurn project MOPA import seam
+
+The addon's `project_import.py` reads observed `CutSetting` and
+`CutSetting_Img` elements without loading Rayforge, geometry or any transport.
+It preserves raw `frequency`, `QPulseWidth`, layer order and image dither mode.
+The synthetic unit tests contain no private geometry or complete recipes.
+
+Normalization requires the caller to declare the serialized-unit contract
+`frequency=Hz;QPulseWidth=ns`. This is an explicit input, not an inference from
+numeric magnitude or an authorization claim. With that contract, frequency is
+represented in Hz and kHz (division by 1000), and pulse width in ns. Without
+it, only raw values are returned. The seam does not establish the contract on
+behalf of the caller or read machine limits from these values.
+
+Absent fields remain absent, never zero or inherited defaults. Malformed,
+non-finite, non-positive, duplicate and excessively large values are rejected.
+The numeric ceiling is a parser bound, not a machine operating limit. Image
+settings remain blocked pending image-processing equivalence, including
+`3dslice`. XML declarations and unsupported project formats are rejected.
+
+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.
+
+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.
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
new file mode 100644
index 0000000..8b6a611
--- /dev/null
+++ b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/project_import.py
@@ -0,0 +1,89 @@
+"""Transport-free LightBurn MOPA import seam; never authorizes encoding."""
+
+from __future__ import annotations
+
+from decimal import Decimal, InvalidOperation
+import xml.etree.ElementTree as ET
+
+
+UNIT_CONTRACT = "frequency=Hz;QPulseWidth=ns"
+
+
+class ProjectImportError(ValueError):
+ """Ambiguous or malformed process metadata."""
+
+
+def _number(element: ET.Element, field: str) -> str | None:
+ children = element.findall(field)
+ if not children:
+ return None
+ if len(children) != 1 or set(children[0].attrib) != {"Value"}:
+ raise ProjectImportError(f"ambiguous {field}")
+ raw = children[0].get("Value", "")
+ try:
+ value = Decimal(raw)
+ except InvalidOperation as exc:
+ raise ProjectImportError(f"invalid {field}") from exc
+ if not value.is_finite() or value <= 0 or value > Decimal("1e12"):
+ raise ProjectImportError(f"invalid {field}")
+ return raw
+
+
+def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dict:
+ """Preserve observed XML fields and optionally normalize declared units.
+
+ The caller must supply an independently established serialized-unit contract.
+ No unit is inferred from magnitude. Missing pulse width is never defaulted.
+ Results are offline metadata, not executable Rayforge workflow settings.
+ """
+ if unit_contract not in (None, UNIT_CONTRACT):
+ raise ProjectImportError("unsupported serialized-unit contract")
+ if len(data) > 64 * 1024 * 1024:
+ raise ProjectImportError("project exceeds offline import size limit")
+ # Reject declarations, including UTF-16/32 encodings, before XML parsing.
+ probe = data.replace(b"\x00", b"").upper()
+ if b" 1 or (modes and set(modes[0].attrib) != {"Value"}):
+ raise ProjectImportError("ambiguous image mode")
+ image_mode = modes[0].get("Value") if modes else None
+ normalized = None
+ if unit_contract and all(value is not None for value in raw.values()):
+ normalized = {
+ "frequency_hz": float(Decimal(raw["frequency"])),
+ "frequency_khz": float(Decimal(raw["frequency"]) / 1000),
+ "pulse_width_ns": float(Decimal(raw["QPulseWidth"])),
+ }
+ layers.append({"element": setting.tag, "type": kind, "raw": raw,
+ "image_mode": image_mode,
+ "normalized": normalized, "blockers": blockers})
+ if not layers:
+ raise ProjectImportError("project has no process settings")
+ return {"layers": layers, "unit_contract": unit_contract, "transport": None,
+ "encoding_allowed": False, "hardware_use_allowed": False,
+ "blockers": ["machine identity, correction and limits unverified",
+ "offline import seam is not wired to host workflow"]}
diff --git a/tests/fixtures/rayforge-addon-spike.json b/tests/fixtures/rayforge-addon-spike.json
index e0ea8f1..95b36dc 100644
--- a/tests/fixtures/rayforge-addon-spike.json
+++ b/tests/fixtures/rayforge-addon-spike.json
@@ -19,6 +19,7 @@
"pirate_ray_jcz/__init__.py",
"pirate_ray_jcz/offline_session.py",
"pirate_ray_jcz/profile_import.py",
+ "pirate_ray_jcz/project_import.py",
"pirate_ray_jcz/rayforge_compat.py",
"pirate_ray_jcz/recipe_metadata.py",
"pirate_ray_jcz/worker.py",
diff --git a/tests/test_lightburn_project_mopa.py b/tests/test_lightburn_project_mopa.py
new file mode 100644
index 0000000..2984be3
--- /dev/null
+++ b/tests/test_lightburn_project_mopa.py
@@ -0,0 +1,83 @@
+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 (
+ ProjectImportError, UNIT_CONTRACT, import_project_mopa,
+)
+
+
+def project(fields, tag="CutSetting", kind="Scan"):
+ return (f'<{tag} type="{kind}">'
+ f'{fields}{tag}>').encode()
+
+
+FIELDS = ''
+
+
+class ProjectMopaTests(unittest.TestCase):
+ def test_explicit_contract_converts_without_authorizing_execution(self):
+ result = import_project_mopa(project(FIELDS), unit_contract=UNIT_CONTRACT)
+ self.assertEqual(result["layers"][0]["normalized"], {
+ "frequency_hz": 125000, "frequency_khz": 125, "pulse_width_ns": 25,
+ })
+ self.assertFalse(result["encoding_allowed"])
+ self.assertFalse(result["hardware_use_allowed"])
+ self.assertIsNone(result["transport"])
+
+ def test_units_are_never_inferred_from_magnitude(self):
+ result = import_project_mopa(project(FIELDS))
+ layer = result["layers"][0]
+ self.assertIsNone(layer["normalized"])
+ self.assertEqual(layer["raw"]["frequency"], "125000")
+ self.assertIn("serialized units unresolved", layer["blockers"])
+ with self.assertRaises(ProjectImportError):
+ import_project_mopa(project(FIELDS), unit_contract="kHz/us")
+
+ def test_missing_fields_remain_absent(self):
+ for fields in ('', '', ''):
+ with self.subTest(fields=fields):
+ layer = import_project_mopa(project(fields), unit_contract=UNIT_CONTRACT)["layers"][0]
+ self.assertIsNone(layer["normalized"])
+ self.assertTrue(layer["blockers"])
+
+ def test_image_settings_remain_blocked_even_with_complete_parameters(self):
+ fields = FIELDS + ''
+ layer = import_project_mopa(project(fields, "CutSetting_Img", "Image"), unit_contract=UNIT_CONTRACT)["layers"][0]
+ self.assertIn("image processing equivalence unverified", layer["blockers"])
+ self.assertEqual(layer["image_mode"], "3dslice")
+
+ def test_invalid_and_ambiguous_values_fail_closed(self):
+ for field in ("frequency", "QPulseWidth"):
+ for value in ("0", "-1", "NaN", "Infinity", "1e999", "", "abc"):
+ with self.subTest(field=field, value=value):
+ with self.assertRaises(ProjectImportError):
+ import_project_mopa(project(f'<{field} Value="{value}"/>'))
+ with self.assertRaises(ProjectImportError):
+ import_project_mopa(project(FIELDS + f'<{field} Value="2"/>'))
+
+ def test_wrong_case_is_absent_not_guessed(self):
+ layer = import_project_mopa(project(FIELDS.replace("QPulseWidth", "qpulsewidth")))["layers"][0]
+ self.assertIsNone(layer["raw"]["QPulseWidth"])
+
+ def test_bad_xml_formats_and_declarations_rejected(self):
+ for data in (b'', b'',
+ b'',
+ project(FIELDS, kind="Unknown")):
+ with self.subTest(data=data):
+ with self.assertRaises(ProjectImportError):
+ import_project_mopa(data)
+
+ def test_multiple_layers_preserve_order_and_missing_pulse(self):
+ data = (''
+ ''+FIELDS+''
+ ''
+ '').encode()
+ layers = import_project_mopa(data, unit_contract=UNIT_CONTRACT)["layers"]
+ self.assertEqual([x["type"] for x in layers], ["Cut", "Image"])
+ self.assertIsNone(layers[1]["normalized"])
+
+
+if __name__ == "__main__":
+ unittest.main()