docs: establish phase 0 evidence workflow

This commit is contained in:
Codex 2026-10-17 16:20:12 +02:00
commit 805691a71b
15 changed files with 573 additions and 0 deletions

7
.gitattributes vendored Normal file
View file

@ -0,0 +1,7 @@
* text=auto
*.md text eol=lf
*.py text eol=lf
*.json text eol=lf
*.jsonl text eol=lf
*.yaml text eol=lf
*.yml text eol=lf

16
.gitignore vendored Normal file
View file

@ -0,0 +1,16 @@
__pycache__/
*.py[cod]
.pytest_cache/
.venv/
.uv-cache/
# Sensitive/raw acquisition data belongs in ThePirateRay-PrivateStash.
/raw/
/private/
*.lbprefs
*.lbzip
prefs.ini
prefs.old
# Local reports are reproducible and must not be committed accidentally.
/reports/

36
README.md Normal file
View file

@ -0,0 +1,36 @@
# The Pirate Ray
The Pirate Ray is Makerspace Næstved's clean-room project for learning how to
interoperate with its laser equipment and, over time, building a safe desktop
control application.
Phase 0 focuses on the **JPT 100 W E2/M7 MOPA** fiber laser with a JCZ/EZCAD2
controller. It is deliberately limited to documentation and offline analysis:
no USB capture, framing, machine commands, or laser output.
## Repository boundary
This public repository contains source code, plans, decisions, tests, tools,
and sanitized research. Raw machine data, serial numbers, LightBurn exports,
and third-party material with unclear redistribution rights live in the
separate private `ThePirateRay-PrivateStash` repository.
Start with:
- [Project charter](docs/project-charter.md)
- [Data handling policy](docs/data-handling.md)
- [LightBurn collection checklist](docs/collection/lightburn.md)
- [Sanitized machine profile](docs/research/machine-profile.yaml)
- [Repository boundary decision](docs/adr/0001-public-private-repository-boundary.md)
## Validation
The repository uses only Python's standard library for Phase 0 tooling:
```powershell
python -m unittest discover -s tests -v
python scripts/check_public_repo.py .
```
No project license has been selected yet. Until that decision is recorded,
the contents are not offered under an open-source license.

View file

@ -0,0 +1,30 @@
# ADR 0001: Public/private repository boundary
- Status: Accepted
- Date: 2026-10-17
## Context
The project needs unusually complete evidence while some inputs contain
identifiers, configuration secrets, or third-party files whose redistribution
rights are unclear.
## Decision
Use exactly two authoritative repositories. `ThePirateRay` is public and holds
all normal development work. `ThePirateRay-PrivateStash` holds sensitive raw
data and redistribution-restricted third-party material. Google Drive is used
only as a read-only acquisition source.
Public documents cite private evidence by content-derived artifact ID and
SHA-256 digest. Git LFS is mandatory for binary files in PrivateStash. If the
Git server cannot store and retrieve LFS objects, raw binary import stops rather
than falling back to ordinary Git blobs.
## Consequences
- A public clone is useful without access to private evidence.
- Authorized researchers can reproduce claims from exact private bytes.
- Cross-repository commits must be ordered: private evidence first, sanitized
public analysis second.
- The two repositories must be validated separately.

View file

@ -0,0 +1,39 @@
# LightBurn collection checklist
Collect directly into a dated directory under
`ThePirateRay-PrivateStash/raw/lightburn/`. Do not use Google Drive as an
intermediate workspace.
## Before collection
- Confirm no marking job is active.
- Do not enable debug logging or generate Support Data.
- Record which lens is installed and whether LightBurn currently connects.
## Required evidence
| Directory | Evidence |
| --- | --- |
| `00-version/` | Screenshot of Help → About with exact version/build |
| `01-exports/` | Full `.lbzip` bundle and exported `.lbprefs` |
| `02-prefs/` | ZIP of `%LOCALAPPDATA%\LightBurn`, copied only after LightBurn is fully closed |
| `03-device-settings/` | Device list and every Device Settings tab for the JPT device |
| `04-projects/` | Two or three known-working `.lbrn2` files, including vector and MOPA/hatch examples when available |
| `05-hardware/` | Machine, source, controller, and active-lens labels; never open an energized enclosure |
| `06-context/` | Mounted lens, connection status, and normal startup/interlock notes |
## Export procedure
1. Use File → Bundles → Export Bundle and include every available component.
2. Use File → Preferences → Export Prefs.
3. Capture the UI screenshots and save existing example projects.
4. Exit LightBurn completely.
5. Copy `%LOCALAPPDATA%\LightBurn` into an archive without editing its contents.
6. Run the private manifest tool and review the staged files before committing.
LightBurn documents bundles as containing settings, device profiles, hotkeys,
presets, and libraries. Since LightBurn 2.0, device export is also represented
as a bundle: <https://docs.lightburnsoftware.com/latest/Guides/Migration/>.
LightBurn explicitly warns not to publish generated Support Data:
<https://docs.lightburnsoftware.com/latest/Reference/Hidden/GenerateSupportData/>.

33
docs/data-handling.md Normal file
View file

@ -0,0 +1,33 @@
# Data handling policy
## Authoritative repositories
| Classification | Repository | Examples |
| --- | --- | --- |
| `public` | `ThePirateRay` | Code, tests, plans, schemas, sanitized analysis |
| `private-sensitive` | `ThePirateRay-PrivateStash` | Serial numbers, local usernames, license-related data, private source locators |
| `private-third-party` | `ThePirateRay-PrivateStash` | Vendor binaries, manuals, raw LightBurn/EZCAD files with unclear redistribution rights |
Google Drive is a read-only acquisition source, not a project workspace. No
project artifact is created, edited, renamed, or reorganized there.
## Rules
- Preserve raw bytes. Analysis always produces a new file.
- Record SHA-256, byte size, media type, acquisition time, origin label, and
classification for every acquired file.
- Keep exact Drive IDs, URLs, machine serials, usernames, and local paths only
in the private manifest.
- Refer to private evidence publicly using `artifact_id` and `sha256` only.
- Never commit LightBurn Support Data to the public repository.
- Do not execute software acquired from the installer stick during analysis.
- Run the public repository guard before each public push.
## Redaction flow
1. Acquire the original into the private repository.
2. Generate and verify the private manifest.
3. Analyze the original without modifying it.
4. Create a sanitized derivative in the public repository.
5. Run automated guards and manually review the staged diff.
6. Commit and push the private evidence before the public claim that cites it.

29
docs/project-charter.md Normal file
View file

@ -0,0 +1,29 @@
# Project charter
## Goal
Build an evidence-backed knowledge base and, incrementally, a desktop
application capable of safely controlling Makerspace Næstved's lasers. The
first hardware target is the JPT 100 W E2/M7 MOPA fiber laser.
## Phase 0 success criteria
1. Public and private repositories are independently cloneable and validated.
2. Raw evidence is immutable, hashed, and kept in the private repository.
3. Every public technical claim is traceable to a private artifact ID and
SHA-256 digest without exposing private locators.
4. Collection and analysis can be repeated using committed tools and tests.
5. No command is sent to laser hardware during Phase 0.
## Clean-room boundary
The project studies observable configuration formats and hardware behavior for
interoperability. It does not bypass LightBurn licensing, copy proprietary
source code or UI assets, or redistribute third-party material without a clear
right to do so.
## Working practice
Plans, decisions, scripts, tests, reports, and collected evidence are committed
and pushed in small, meaningful units. Commit messages explain intent. Force
pushes and history rewriting are not part of the workflow.

View file

@ -0,0 +1,23 @@
# Original installer-stick inventory
Status: preliminary metadata inventory; content hashes are pending private
acquisition.
The read-only source contains an EZCAD2 2.14.16 installation configured for a
JPT 100 W E2/M7 source. Separate 110 mm and 300 mm configurations are present.
Observed evidence classes include:
- per-lens `markcfg7` files;
- `CoeFile.cfg`, `EZCAD.CFG`, parameter libraries, and USB logs;
- screenshots of field, laser-control, port, red-pointer, workspace, and rotary
settings;
- EZCAD executables, plugins, DLLs, driver installer material, and a manual;
- LightBurn `.clb` material libraries for multiple lens sizes.
All originals and exact source locators belong in PrivateStash. This public
inventory intentionally omits serial numbers, Drive identifiers, executable
copies, and manual copies.
The detailed comparison report will be added only after the private snapshot is
hashed and verified.

View file

@ -0,0 +1,40 @@
schema_version: 1
profile_id: makerspace-naestved-jpt-e2-mopa-100w
status: collection-in-progress
machine:
source_family: JPT
source_model: E2/M7
nominal_power_w: 100
technology: mopa-fiber
controller:
family: JCZ
stock_software: EZCAD2
stock_software_version_observed: 2.14.16
optics:
active_field_mm:
width: 110
height: 110
status: assumed-pending-physical-verification
other_configurations_observed_mm:
- width: 300
height: 300
lightburn:
version: null
build: null
status: pending-collection
usb:
vid: null
pid: null
driver: null
status: pending-collection
safety:
phase_0_hardware_commands_allowed: false
phase_0_laser_output_allowed: false
evidence:
- artifact_id: pending-installer-110-markcfg7
sha256: null
- artifact_id: pending-installer-300-markcfg7
sha256: null
notes:
- No complete serial numbers or private source locators are stored here.
- Incorrect galvo or source configuration can damage hardware; values are not inferred by trial and error.

View file

@ -0,0 +1,59 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.p20.dev/christian/ThePirateRay/schemas/artifact-manifest.schema.json",
"title": "The Pirate Ray artifact manifest entry",
"type": "object",
"additionalProperties": false,
"required": [
"artifact_id",
"sha256",
"size_bytes",
"observed_at_utc",
"media_type",
"origin",
"classification",
"analysis_refs"
],
"properties": {
"artifact_id": {
"type": "string",
"pattern": "^sha256-[0-9a-f]{20}$"
},
"sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"size_bytes": {
"type": "integer",
"minimum": 0
},
"observed_at_utc": {
"type": "string",
"format": "date-time"
},
"media_type": {
"type": "string",
"minLength": 1
},
"origin": {
"type": "string",
"minLength": 1
},
"classification": {
"enum": ["public", "private-sensitive", "private-third-party"]
},
"analysis_refs": {
"type": "array",
"items": {"type": "string"},
"uniqueItems": true
},
"relative_path": {
"type": "string",
"minLength": 1
},
"source_locator": {
"type": "string",
"minLength": 1
}
}
}

1
scripts/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Phase 0 acquisition and validation tools."""

99
scripts/build_manifest.py Normal file
View file

@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Build a deterministic JSON Lines manifest for an immutable artifact tree."""
from __future__ import annotations
import argparse
import hashlib
import json
import mimetypes
from datetime import datetime, timezone
from pathlib import Path
CLASSIFICATIONS = ("public", "private-sensitive", "private-third-party")
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def manifest_entry(
path: Path,
root: Path,
classification: str,
origin: str,
observed_at: str,
include_private_locators: bool,
) -> dict[str, object]:
digest = sha256_file(path)
entry: dict[str, object] = {
"artifact_id": f"sha256-{digest[:20]}",
"sha256": digest,
"size_bytes": path.stat().st_size,
"observed_at_utc": observed_at,
"media_type": mimetypes.guess_type(path.name)[0] or "application/octet-stream",
"origin": origin,
"classification": classification,
"analysis_refs": [],
}
if include_private_locators:
entry["relative_path"] = path.relative_to(root).as_posix()
return entry
def build_manifest(
root: Path,
classification: str,
origin: str,
observed_at: str,
include_private_locators: bool = True,
) -> list[dict[str, object]]:
if classification not in CLASSIFICATIONS:
raise ValueError(f"unsupported classification: {classification}")
if not root.is_dir():
raise ValueError(f"artifact root is not a directory: {root}")
files = sorted((path for path in root.rglob("*") if path.is_file()), key=lambda p: p.relative_to(root).as_posix())
return [
manifest_entry(path, root, classification, origin, observed_at, include_private_locators)
for path in files
]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("root", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--classification", choices=CLASSIFICATIONS, required=True)
parser.add_argument("--origin", required=True)
parser.add_argument(
"--observed-at",
default=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
help="ISO-8601 acquisition timestamp; pass an explicit value for reproducible output",
)
parser.add_argument("--public", action="store_true", help="omit private relative paths")
return parser.parse_args()
def main() -> int:
args = parse_args()
entries = build_manifest(
args.root.resolve(),
args.classification,
args.origin,
args.observed_at,
include_private_locators=not args.public,
)
args.output.parent.mkdir(parents=True, exist_ok=True)
with args.output.open("w", encoding="utf-8", newline="\n") as stream:
for entry in entries:
stream.write(json.dumps(entry, ensure_ascii=False, sort_keys=True) + "\n")
print(f"wrote {len(entries)} entries to {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Fail when public tracked content resembles private or vendor raw data."""
from __future__ import annotations
import argparse
import re
import subprocess
from pathlib import Path
DISALLOWED_SUFFIXES = {
".7z", ".clb", ".dll", ".ezd", ".lbprefs", ".lbzip", ".plg", ".rar", ".zip"
}
SENSITIVE_TEXT = {
"Google Drive URL": re.compile(
r"https://drive" r"\.google\.com/(?:drive/folders|file/d)/[A-Za-z0-9_-]+"
),
"Windows user path": re.compile(r"C:\\Users\\[^\\\s]+", re.IGNORECASE),
"License-key indicator": re.compile(
r"light" r"burn.{0,30}license.{0,15}key", re.IGNORECASE
),
}
TEXT_SUFFIXES = {"", ".json", ".jsonl", ".md", ".py", ".toml", ".txt", ".yaml", ".yml"}
def tracked_files(root: Path) -> list[Path]:
result = subprocess.run(
["git", "ls-files", "-z"], cwd=root, check=True, capture_output=True
)
return [root / item.decode("utf-8") for item in result.stdout.split(b"\0") if item]
def check(root: Path) -> list[str]:
failures: list[str] = []
for path in tracked_files(root):
relative = path.relative_to(root).as_posix()
suffix = path.suffix.lower()
if suffix in DISALLOWED_SUFFIXES:
failures.append(f"disallowed public artifact: {relative}")
continue
if suffix not in TEXT_SUFFIXES:
continue
try:
content = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
failures.append(f"unexpected binary content in public text file: {relative}")
continue
for label, pattern in SENSITIVE_TEXT.items():
if pattern.search(content):
failures.append(f"{label} found in {relative}")
return failures
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("root", type=Path, nargs="?", default=Path.cwd())
args = parser.parse_args()
failures = check(args.root.resolve())
if failures:
for failure in failures:
print(f"ERROR: {failure}")
return 1
print("public repository guard passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,52 @@
import hashlib
import tempfile
import unittest
from pathlib import Path
from scripts.build_manifest import build_manifest
class BuildManifestTests(unittest.TestCase):
def test_manifest_is_sorted_hashed_and_private_by_default(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
(root / "nested").mkdir()
(root / "z.txt").write_text("z", encoding="utf-8")
(root / "nested" / "a.bin").write_bytes(b"a")
entries = build_manifest(
root,
"private-third-party",
"test-source",
"2026-10-17T00:00:00Z",
)
self.assertEqual([entry["relative_path"] for entry in entries], ["nested/a.bin", "z.txt"])
expected = hashlib.sha256(b"a").hexdigest()
self.assertEqual(entries[0]["sha256"], expected)
self.assertEqual(entries[0]["artifact_id"], f"sha256-{expected[:20]}")
def test_public_manifest_omits_paths(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
(root / "item.txt").write_text("safe", encoding="utf-8")
entries = build_manifest(
root,
"public",
"test-source",
"2026-10-17T00:00:00Z",
include_private_locators=False,
)
self.assertNotIn("relative_path", entries[0])
self.assertNotIn("source_locator", entries[0])
def test_invalid_classification_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
with self.assertRaises(ValueError):
build_manifest(
Path(temporary), "secret", "test-source", "2026-10-17T00:00:00Z"
)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,41 @@
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from scripts.check_public_repo import check
class PublicRepoGuardTests(unittest.TestCase):
def test_disallowed_binary_suffix_is_reported(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
candidate = root / "vendor.dll"
candidate.write_bytes(b"MZ")
with patch("scripts.check_public_repo.tracked_files", return_value=[candidate]):
self.assertIn("disallowed public artifact: vendor.dll", check(root))
def test_private_drive_url_is_reported(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
candidate = root / "notes.md"
candidate.write_text(
"https://drive" + ".google.com/file/d/private_identifier", encoding="utf-8"
)
with patch("scripts.check_public_repo.tracked_files", return_value=[candidate]):
failures = check(root)
self.assertEqual(len(failures), 1)
self.assertIn("Google Drive URL", failures[0])
def test_safe_text_passes(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
candidate = root / "notes.md"
candidate.write_text("artifact sha256-01234567890123456789", encoding="utf-8")
with patch("scripts.check_public_repo.tracked_files", return_value=[candidate]):
self.assertEqual(check(root), [])
if __name__ == "__main__":
unittest.main()