diff --git a/docs/superpowers/plans/2026-06-10-chip-on-board-packaging.md b/docs/superpowers/plans/2026-06-10-chip-on-board-packaging.md
new file mode 100644
index 00000000..147e7431
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-10-chip-on-board-packaging.md
@@ -0,0 +1,708 @@
+# Chip-on-Board (CoB) Packaging Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Let users request Chip-on-Board (CoB) packaging on a project; the manufacturability precheck then runs with `--cob`, and toggling the option re-runs the check.
+
+**Architecture:** One new editable boolean on `Project` (`chip_on_board`), read live by the precheck command builder (like `--slot`/`--id`). Toggling it creates a new PENDING `ManufacturabilityCheck` via a new model method `create_check_cob_change()` (modelled on `create_check_drc_update()`), which explicitly cancels an in-progress check. Existing scheduled pollers (`checks_pending`, `checks_cancelling`, `checks_cleanup_orphaned_docker`) do all the async work — no new tasks, no layering changes.
+
+**Tech Stack:** Django 5.2, pytest-django + factory-boy, Celery (PostgreSQL broker), crispy-forms templates, ruff/mypy/djlint.
+
+**Spec:** `docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md` — read it first.
+
+---
+
+## Before you start
+
+- **Rebase gate:** the spec assumes PR #262 (`checks_cleanup_superseded`) lands first. Run `git fetch origin main` and check `git log --oneline origin/main | head -5`. If PR #262 has merged, `git rebase origin/main` before starting. If it has NOT merged yet, stop and ask the user whether to proceed anyway (the design works either way, but docstrings written in Task 3 reference the post-#262 world).
+- Work in the worktree `.worktrees/feature/chip-on-board-packaging`, branch `feature/chip-on-board-packaging`.
+- **Pre-commit gate for EVERY commit** (project rule, no shortcuts): `make lint-fix && make lint && make type-check && make test`. Baseline today: 1283 passed, 3 skipped.
+- TDD throughout: write the failing test, watch it fail, implement, watch it pass. See @superpowers:test-driven-development.
+- All new code needs type hints; never add `# noqa` / `# type: ignore`.
+
+## File map
+
+| File | Change |
+|------|--------|
+| `wafer_space/projects/models.py` | Add `Project.chip_on_board` field + `USER_FIELDS` entry; add `TriggerReason.COB_CHANGE`; add `ManufacturabilityCheck.create_check_cob_change()` |
+| `wafer_space/projects/migrations/0056_*.py`, `0057_*.py` | Generated migrations (field, then choices) |
+| `wafer_space/projects/tasks_checks.py` | Append `--cob` in `do_starting` command builder |
+| `wafer_space/projects/forms.py` | Add `chip_on_board` to `ProjectForm.Meta` (fields/widgets/help_texts) |
+| `wafer_space/templates/projects/project_form.html` | Render the checkbox (fields are rendered explicitly — adding to `Meta.fields` alone does NOT display it) |
+| `wafer_space/projects/views.py` | `ProjectUpdateView.form_valid`: create re-check when the flag changes |
+| `wafer_space/templates/projects/project_detail.html` | CoB badge |
+| Tests | `wafer_space/projects/tests/test_models.py`, `test_tasks.py`, `test_forms.py`, `test_views.py` |
+
+---
+
+### Task 1: `Project.chip_on_board` field + migration
+
+**Files:**
+- Modify: `wafer_space/projects/models.py` (field after `is_public` ~line 244; `USER_FIELDS` ~line 159)
+- Create: `wafer_space/projects/migrations/0056_project_chip_on_board.py` (generated)
+- Test: `wafer_space/projects/tests/test_models.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+Add near the other Project model test classes in `test_models.py` (follow the file's existing import style — `Project`, `ProjectFactory` are already imported):
+
+```python
+@pytest.mark.django_db
+class TestProjectChipOnBoard:
+ """Tests for the Project.chip_on_board flag."""
+
+ def test_defaults_to_false(self):
+ """chip_on_board defaults to False."""
+ project = ProjectFactory()
+ assert project.chip_on_board is False
+
+ def test_is_editable_after_creation(self):
+ """chip_on_board is a user field, not blocked by core-field immutability."""
+ project = ProjectFactory()
+ project.chip_on_board = True
+ project.full_clean() # core-field immutability is enforced in clean()
+ project.save()
+ # Fetch fresh from the DB: refresh_from_db() would leave the stale
+ # in-memory attribute in place pre-implementation, hiding the RED.
+ reloaded = Project.objects.get(pk=project.pk)
+ assert reloaded.chip_on_board is True
+
+ def test_is_a_user_field(self):
+ """chip_on_board is in USER_FIELDS and not in CORE_FIELDS."""
+ assert "chip_on_board" in Project.USER_FIELDS
+ assert "chip_on_board" not in Project.CORE_FIELDS
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `uv run pytest wafer_space/projects/tests/test_models.py::TestProjectChipOnBoard -v`
+Expected: 3 FAILED — `AttributeError` (no `chip_on_board` attribute on the fresh
+instances) / `AssertionError` (not in `USER_FIELDS`).
+
+- [ ] **Step 3: Add the field and USER_FIELDS entry**
+
+In `models.py`, add `"chip_on_board"` to the `USER_FIELDS` frozenset (alongside `"is_public"`). Then add the field directly after the `is_public` field definition (~line 244):
+
+```python
+ # Chip-on-Board packaging (Issue #259)
+ chip_on_board = models.BooleanField(
+ default=False,
+ verbose_name="Request Chip-on-Board (CoB) packaging",
+ help_text=(
+ "Run extra Chip-on-Board (CoB) compatibility checks during the "
+ "manufacturability precheck."
+ ),
+ )
+```
+
+- [ ] **Step 4: Generate the migration**
+
+Run: `uv run python manage.py makemigrations projects`
+Expected: one new migration adding `chip_on_board` to `project` (latest existing migration is `0055_add_commit_info_to_precheck_revision.py`). Inspect the generated file — it must contain exactly one `AddField` for `project.chip_on_board`.
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `uv run pytest wafer_space/projects/tests/test_models.py::TestProjectChipOnBoard -v`
+Expected: 3 PASSED.
+
+- [ ] **Step 6: Pre-commit gate + commit**
+
+```bash
+make lint-fix && make lint && make type-check && make test
+git add wafer_space/projects/models.py wafer_space/projects/migrations/ wafer_space/projects/tests/test_models.py
+git commit -m "feat: add Project.chip_on_board field (#259)"
+```
+
+---
+
+### Task 2: `TriggerReason.COB_CHANGE` + migration
+
+**Files:**
+- Modify: `wafer_space/projects/models.py:1549-1553` (`TriggerReason`)
+- Create: `wafer_space/projects/migrations/0057_*.py` (generated `AlterField` for choices)
+- Test: `wafer_space/projects/tests/test_models.py`
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+@pytest.mark.django_db
+class TestCobChangeTriggerReason:
+ """Tests for the COB_CHANGE trigger reason."""
+
+ def test_cob_change_choice_exists(self):
+ """COB_CHANGE is a valid TriggerReason."""
+ reason = ManufacturabilityCheck.TriggerReason.COB_CHANGE
+ assert reason.value == "cob_change"
+ assert reason.label == "Chip-on-Board Option Changed"
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `uv run pytest wafer_space/projects/tests/test_models.py::TestCobChangeTriggerReason -v`
+Expected: FAIL with `AttributeError: COB_CHANGE`.
+
+- [ ] **Step 3: Add the choice**
+
+In `models.py`, extend `ManufacturabilityCheck.TriggerReason` (after `RETRY`):
+
+```python
+ COB_CHANGE = "cob_change", "Chip-on-Board Option Changed"
+```
+
+- [ ] **Step 4: Generate the migration**
+
+Run: `uv run python manage.py makemigrations projects`
+Expected: one migration with an `AlterField` on `manufacturabilitycheck.trigger_reason` (choices-only change; the project's migration history does this for status choices in `0053`).
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run: `uv run pytest wafer_space/projects/tests/test_models.py::TestCobChangeTriggerReason -v`
+Expected: PASS.
+
+- [ ] **Step 6: Pre-commit gate + commit**
+
+```bash
+make lint-fix && make lint && make type-check && make test
+git add wafer_space/projects/models.py wafer_space/projects/migrations/ wafer_space/projects/tests/test_models.py
+git commit -m "feat: add COB_CHANGE manufacturability trigger reason (#259)"
+```
+
+---
+
+### Task 3: `ManufacturabilityCheck.create_check_cob_change()`
+
+**Files:**
+- Modify: `wafer_space/projects/models.py` (add method directly after `create_check_drc_update`, which ends ~line 2302, just before `queue_wait_seconds`)
+- Test: `wafer_space/projects/tests/test_models.py` (add after `TestCreateCheckDrcUpdate`, ~line 3091, and reuse its imports: `ManufacturabilityCheckFactory`, `ProjectFileFactory`, `pytest`)
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+@pytest.mark.django_db
+class TestCreateCheckCobChange:
+ """Tests for ManufacturabilityCheck.create_check_cob_change()."""
+
+ def test_creates_pending_cob_change_check(self):
+ """Creates a PENDING check with COB_CHANGE reason chained to the source."""
+ old_check = ManufacturabilityCheckFactory(
+ status=ManufacturabilityCheck.Status.FINISHED,
+ )
+
+ new_check = old_check.create_check_cob_change()
+
+ assert new_check.project == old_check.project
+ assert new_check.project_file == old_check.project_file
+ assert (
+ new_check.trigger_reason == ManufacturabilityCheck.TriggerReason.COB_CHANGE
+ )
+ assert new_check.parent_check == old_check
+ assert new_check.status == ManufacturabilityCheck.Status.PENDING
+
+ def test_finished_source_check_is_not_cancelled(self):
+ """A FINISHED source check keeps its status (nothing to cancel)."""
+ old_check = ManufacturabilityCheckFactory(
+ status=ManufacturabilityCheck.Status.FINISHED,
+ )
+
+ old_check.create_check_cob_change()
+
+ old_check.refresh_from_db()
+ assert old_check.status == ManufacturabilityCheck.Status.FINISHED
+
+ def test_in_progress_source_check_is_marked_cancelling(self):
+ """A RUNNING source check is explicitly marked CANCELLING."""
+ running_check = ManufacturabilityCheckFactory(
+ status=ManufacturabilityCheck.Status.RUNNING,
+ )
+
+ new_check = running_check.create_check_cob_change()
+
+ running_check.refresh_from_db()
+ assert running_check.status == ManufacturabilityCheck.Status.CANCELLING
+ assert "Chip-on-Board option changed" in running_check.processing_logs
+ assert new_check.status == ManufacturabilityCheck.Status.PENDING
+
+ def test_raises_when_not_latest_check(self):
+ """Refuses to run on a check that is not the file's latest."""
+ project_file = ProjectFileFactory()
+ old_check = ManufacturabilityCheckFactory(
+ project_file=project_file,
+ status=ManufacturabilityCheck.Status.FINISHED,
+ )
+ ManufacturabilityCheckFactory(
+ project_file=project_file,
+ status=ManufacturabilityCheck.Status.FINISHED,
+ )
+
+ with pytest.raises(ValueError, match="latest check"):
+ old_check.create_check_cob_change()
+```
+
+Note: `ManufacturabilityCheckFactory(project_file=...)` does not link `project` to the file's project automatically — that's fine here; the not-latest guard only compares checks on the same `project_file` (the existing `TestCreateCheckDrcUpdate` tests do exactly this).
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `uv run pytest wafer_space/projects/tests/test_models.py::TestCreateCheckCobChange -v`
+Expected: 4 FAILED with `AttributeError: ... has no attribute 'create_check_cob_change'`.
+
+- [ ] **Step 3: Implement the method**
+
+Add to `ManufacturabilityCheck` directly after `create_check_drc_update`:
+
+```python
+ def create_check_cob_change(self) -> "ManufacturabilityCheck":
+ """Create a new pending check after the project's CoB option changed.
+
+ Unlike ``create_check_drc_update`` — which leaves an in-progress check
+ to the scheduled superseded-check cleanup — this cancels an in-progress
+ check itself, so the superseded check can never FINISH with a result
+ computed from the old CoB setting.
+
+ Returns:
+ The newly created ManufacturabilityCheck.
+
+ Raises:
+ ValueError: If this check is not the latest check for its file.
+ """
+ latest = self.project_file.latest_manufacturability_check
+ if latest != self:
+ msg = "Can only create CoB change check from the latest check for a file"
+ raise ValueError(msg)
+
+ if self.is_cancellable:
+ self.mark_cancelling(reason="Chip-on-Board option changed")
+
+ return ManufacturabilityCheck.objects.create(
+ project=self.project,
+ project_file=self.project_file,
+ trigger_reason=self.TriggerReason.COB_CHANGE,
+ parent_check=self,
+ )
+```
+
+Pure ORM — no task imports (models must never import tasks). No digest/version guards (the trigger is the user toggling, not a version change).
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `uv run pytest wafer_space/projects/tests/test_models.py::TestCreateCheckCobChange -v`
+Expected: 4 PASSED.
+
+- [ ] **Step 5: Pre-commit gate + commit**
+
+```bash
+make lint-fix && make lint && make type-check && make test
+git add wafer_space/projects/models.py wafer_space/projects/tests/test_models.py
+git commit -m "feat: add create_check_cob_change model method (#259)"
+```
+
+---
+
+### Task 4: `--cob` in the precheck command (`do_starting`)
+
+**Files:**
+- Modify: `wafer_space/projects/tasks_checks.py` (~line 1070, the `command = [...]` list in `do_starting`)
+- Test: `wafer_space/projects/tests/test_tasks.py` (class `TestDoStarting`; copy the mocking pattern of `test_creates_and_starts_container`, ~line 1256)
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `TestDoStarting`, mirroring `test_creates_and_starts_container`'s setup/mocks exactly (same `shuttle`, `project__project_id="ABCD"`, `tmp_path` file, `get_docker_client` / `create_tar_archive` / `Path` patches) but with `project__chip_on_board=True`, and assert only on the command:
+
+```python
+ @pytest.mark.django_db
+ def test_command_includes_cob_flag_when_requested(self, tmp_path, settings) -> None:
+ """--cob is appended after --id when project.chip_on_board is True."""
+ # ... same setup/mocks as test_creates_and_starts_container, plus:
+ # project__chip_on_board=True on the factory call
+ ...
+ create_call = mock_client.containers.create.call_args
+ command = create_call.kwargs["command"]
+ assert command[-1] == "--cob"
+ assert command[:-1] == [
+ "python3",
+ "precheck.py",
+ "--input",
+ "/input/design.gds",
+ "--output",
+ "/output/design.gds",
+ "--top",
+ "chip_top",
+ "--slot",
+ "1x1",
+ "--id",
+ "G850ABCD",
+ ]
+```
+
+The existing `test_creates_and_starts_container` already asserts the exact command list for the default project (`chip_on_board=False`), so it doubles as the "no `--cob` by default" regression test — do not modify it.
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `uv run pytest wafer_space/projects/tests/test_tasks.py::TestDoStarting::test_command_includes_cob_flag_when_requested -v`
+Expected: FAIL — `command[-1]` is `"G850ABCD"`, not `"--cob"`.
+
+- [ ] **Step 3: Implement**
+
+In `do_starting`, right after the `command = [...]` list is built (before `command_str = " ".join(command)`):
+
+```python
+ if check.project.chip_on_board:
+ command.append("--cob")
+```
+
+(`--cob` is an argparse `store_true` flag in the precheck image — no value. Read live from `check.project`, exactly like `slot_size`/`full_id` above it.)
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `uv run pytest wafer_space/projects/tests/test_tasks.py::TestDoStarting -v`
+Expected: all PASS (including the unmodified default-command test).
+
+- [ ] **Step 5: Pre-commit gate + commit**
+
+```bash
+make lint-fix && make lint && make type-check && make test
+git add wafer_space/projects/tasks_checks.py wafer_space/projects/tests/test_tasks.py
+git commit -m "feat: pass --cob to precheck when chip_on_board is set (#259)"
+```
+
+---
+
+### Task 5: Form field + template rendering
+
+**Files:**
+- Modify: `wafer_space/projects/forms.py` (`ProjectForm.Meta`, ~lines 199-260)
+- Modify: `wafer_space/templates/projects/project_form.html` (~line 65)
+- Test: `wafer_space/projects/tests/test_forms.py` (class `TestProjectForm`, line 21)
+
+**IMPORTANT:** this codebase renders form fields explicitly in the template. Adding the field to `Meta.fields` alone will silently NOT display it — both edits are required.
+
+- [ ] **Step 1: Write the failing tests**
+
+Add to `TestProjectForm` in `test_forms.py`. Note: its `setUp` only creates
+`self.shuttle` — there is no `self.user` or `self.project`, so the second test
+creates its own (all needed imports — `User`, `Project`, `TEST_PASSWORD` —
+already exist at the top of the file):
+
+```python
+ def test_chip_on_board_field_present_and_optional(self):
+ """chip_on_board is on the form, optional, and defaults to False."""
+ form = ProjectForm()
+ assert "chip_on_board" in form.fields
+ assert form.fields["chip_on_board"].required is False
+
+ def test_chip_on_board_editable_for_non_staff_on_existing_project(self):
+ """chip_on_board is a user field — never disabled on edit."""
+ user = User.objects.create_user(
+ username="formuser", email="form@example.com", password=TEST_PASSWORD
+ )
+ project = Project.objects.create(
+ user=user,
+ name="Form Project",
+ shuttle=self.shuttle,
+ project_id="FRMP",
+ )
+ form = ProjectForm(user=user, instance=project)
+ assert form.fields["chip_on_board"].disabled is False
+```
+
+(`ProjectForm()` without `user` is fine — the signature is
+`__init__(*args, user=None, **kwargs)`, and the class's first test constructs
+it the same way.)
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `uv run pytest wafer_space/projects/tests/test_forms.py::TestProjectForm -v -k chip_on_board`
+Expected: 2 FAILED — `AssertionError` (field not in `form.fields`) /
+`KeyError: 'chip_on_board'`.
+
+- [ ] **Step 3: Implement the form changes**
+
+In `ProjectForm.Meta`:
+- `fields`: add `"chip_on_board"` right after `"is_public"`.
+- `widgets`: add `"chip_on_board": forms.CheckboxInput(attrs={"class": "form-check-input"}),`
+- `help_texts`: add `"chip_on_board": ("Run extra Chip-on-Board (CoB) compatibility checks during the manufacturability precheck"),`
+
+(The label comes from the model field's `verbose_name` set in Task 1.)
+
+- [ ] **Step 4: Render in the template**
+
+In `project_form.html`, after `{{ form.is_public|as_crispy_field }}` (line 65):
+
+```html
+ {{ form.chip_on_board|as_crispy_field }}
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `uv run pytest wafer_space/projects/tests/test_forms.py::TestProjectForm -v`
+Expected: PASS (all, including pre-existing tests).
+
+- [ ] **Step 6: Pre-commit gate + commit**
+
+```bash
+make lint-fix && make lint && make type-check && make test
+git add wafer_space/projects/forms.py wafer_space/templates/projects/project_form.html wafer_space/projects/tests/test_forms.py
+git commit -m "feat: add CoB checkbox to project form (#259)"
+```
+
+---
+
+### Task 6: Re-check on toggle (`ProjectUpdateView.form_valid`)
+
+**Files:**
+- Modify: `wafer_space/projects/views.py` (`ProjectUpdateView.form_valid`, ~line 252)
+- Test: `wafer_space/projects/tests/test_views.py` (class `TestProjectUpdateView`, line 251 — reuse its `setUp`; import `ManufacturabilityCheck`, `ManufacturabilityCheckFactory`, `ProjectFileFactory` following the file's import style)
+
+- [ ] **Step 1: Write the failing tests**
+
+Add to `TestProjectUpdateView`. Base form data matches `test_owner_can_update_project_details` (line 284):
+
+```python
+ def _cob_form_data(self, *, chip_on_board: bool) -> dict:
+ """Valid update-form payload toggling only chip_on_board."""
+ data = {
+ "name": "Test Project",
+ "description": "Test project",
+ "repository_url": "",
+ "license_type": "proprietary",
+ "other_license_spdx_id": "",
+ "proprietary_terms_url": "",
+ }
+ if chip_on_board:
+ data["chip_on_board"] = "on"
+ return data
+
+ def _make_submitted_check(self, status):
+ """Attach a submitted file with a check to self.project."""
+ project_file = ProjectFileFactory(project=self.project)
+ self.project.submitted_file = project_file
+ self.project.save()
+ return ManufacturabilityCheckFactory(
+ project=self.project,
+ project_file=project_file,
+ status=status,
+ )
+
+ def test_toggling_cob_creates_cob_change_check(self):
+ """Enabling CoB on a project with a check creates one COB_CHANGE check."""
+ check = self._make_submitted_check(ManufacturabilityCheck.Status.FINISHED)
+ self.client.login(username="testuser", password=TEST_PASSWORD)
+ url = reverse("projects:update", kwargs={"pk": self.project.pk})
+
+ response = self.client.post(url, self._cob_form_data(chip_on_board=True))
+
+ assert response.status_code == HTTP_FOUND
+ self.project.refresh_from_db()
+ assert self.project.chip_on_board is True
+ checks = ManufacturabilityCheck.objects.filter(
+ project_file=check.project_file
+ ).order_by("created_at")
+ assert checks.count() == len([check, "new"]) # exactly one new check
+ new_check = checks.last()
+ assert (
+ new_check.trigger_reason
+ == ManufacturabilityCheck.TriggerReason.COB_CHANGE
+ )
+ assert new_check.parent_check == check
+
+ def test_toggling_cob_cancels_in_progress_check(self):
+ """Enabling CoB while a check is RUNNING marks it CANCELLING."""
+ check = self._make_submitted_check(ManufacturabilityCheck.Status.RUNNING)
+ self.client.login(username="testuser", password=TEST_PASSWORD)
+ url = reverse("projects:update", kwargs={"pk": self.project.pk})
+
+ self.client.post(url, self._cob_form_data(chip_on_board=True))
+
+ check.refresh_from_db()
+ assert check.status == ManufacturabilityCheck.Status.CANCELLING
+
+ def test_toggling_cob_on_draft_only_persists(self):
+ """No submitted file/check: the flag is saved, no check is created."""
+ self.client.login(username="testuser", password=TEST_PASSWORD)
+ url = reverse("projects:update", kwargs={"pk": self.project.pk})
+
+ self.client.post(url, self._cob_form_data(chip_on_board=True))
+
+ self.project.refresh_from_db()
+ assert self.project.chip_on_board is True
+ assert ManufacturabilityCheck.objects.filter(project=self.project).count() == 0
+
+ def test_unchanged_cob_creates_no_check(self):
+ """Submitting the form with CoB unchanged creates no new check."""
+ check = self._make_submitted_check(ManufacturabilityCheck.Status.FINISHED)
+ self.client.login(username="testuser", password=TEST_PASSWORD)
+ url = reverse("projects:update", kwargs={"pk": self.project.pk})
+
+ self.client.post(url, self._cob_form_data(chip_on_board=False))
+
+ assert (
+ ManufacturabilityCheck.objects.filter(
+ project_file=check.project_file
+ ).count()
+ == 1
+ )
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `uv run pytest wafer_space/projects/tests/test_views.py::TestProjectUpdateView -v -k cob`
+Expected: `test_toggling_cob_creates_cob_change_check` and `test_toggling_cob_cancels_in_progress_check` FAIL (no new check / status unchanged); the draft and unchanged tests may already pass — that is fine, they are regression guards.
+
+- [ ] **Step 3: Implement in the view**
+
+Replace `ProjectUpdateView.form_valid` (views can import models; `create_check_cob_change` is pure ORM):
+
+```python
+ def form_valid(self, form):
+ """Save, then re-run the manufacturability check if CoB changed."""
+ cob_changed = "chip_on_board" in form.changed_data
+ response = super().form_valid(form)
+
+ if cob_changed:
+ latest_check = self.object.latest_manufacturability_check
+ if latest_check is not None:
+ latest_check.create_check_cob_change()
+
+ messages.success(
+ self.request,
+ f"Project '{form.instance.name}' updated successfully!",
+ )
+ return response
+```
+
+Notes:
+- `form.changed_data` is computed from bound data vs. `initial`, so it stays valid after save; capturing it before `super().form_valid(form)` just keeps the intent obvious.
+- `self.object.latest_manufacturability_check` (the `Project` property, `models.py:406`) returns the latest check on `submitted_file`, or `None` for drafts — which satisfies `create_check_cob_change`'s latest-check guard by construction.
+- This replaces the old `form_valid` body; the success message moves after the re-check logic but is otherwise unchanged.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `uv run pytest wafer_space/projects/tests/test_views.py::TestProjectUpdateView -v`
+Expected: all PASS (including the pre-existing update tests — the success message must still appear exactly once).
+
+- [ ] **Step 5: Pre-commit gate + commit**
+
+```bash
+make lint-fix && make lint && make type-check && make test
+git add wafer_space/projects/views.py wafer_space/projects/tests/test_views.py
+git commit -m "feat: re-run manufacturability check when CoB toggled (#259)"
+```
+
+---
+
+### Task 7: CoB badges (project detail + trigger-reason chains)
+
+**Files:**
+- Modify: `wafer_space/templates/projects/project_detail.html` (after the Visibility block, ~line 128)
+- Modify: `wafer_space/templates/projects/_file_display.html` (trigger-reason badge chains at ~lines 154-160 and ~188-196 — these have NO `else`, so a COB_CHANGE check currently renders no badge)
+- Modify: `wafer_space/templates/projects/manufacturability_check_status.html` (chains at ~lines 56-66 and ~116-126 — these fall back to a generic light badge)
+- Test: `wafer_space/projects/tests/test_views.py` (detail-view test class)
+
+- [ ] **Step 1: Write the failing tests**
+
+Add to the existing project detail view test class in `test_views.py` (find it via `grep -n "class TestProjectDetail" wafer_space/projects/tests/test_views.py`; reuse its setUp/login pattern):
+
+```python
+ def test_detail_shows_cob_badge_when_requested(self):
+ """Detail page shows the CoB badge when chip_on_board is set."""
+ self.project.chip_on_board = True
+ self.project.save()
+ self.client.login(username="testuser", password=TEST_PASSWORD)
+ url = reverse("projects:detail", kwargs={"pk": self.project.pk})
+
+ response = self.client.get(url)
+
+ assert response.status_code == HTTP_OK
+ assert "Chip-on-Board" in response.content.decode()
+
+ def test_detail_shows_standard_packaging_when_not_requested(self):
+ """Detail page shows standard packaging when chip_on_board is unset."""
+ self.client.login(username="testuser", password=TEST_PASSWORD)
+ url = reverse("projects:detail", kwargs={"pk": self.project.pk})
+
+ response = self.client.get(url)
+
+ assert response.status_code == HTTP_OK
+ assert "Chip-on-Board" not in response.content.decode()
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `uv run pytest wafer_space/projects/tests/test_views.py -v -k "cob_badge or standard_packaging"`
+Expected: `test_detail_shows_cob_badge_when_requested` FAILS ("Chip-on-Board" not in page); the negative test passes already (regression guard).
+
+- [ ] **Step 3: Add the badge**
+
+In `project_detail.html`, after the Visibility `
` (line ~128), matching the surrounding badge markup:
+
+```html
+
+ Packaging:
+ {% if project.chip_on_board %}
+ Chip-on-Board (CoB)
+ {% else %}
+ Standard
+ {% endif %}
+
+```
+
+- [ ] **Step 4: Add `cob_change` to the trigger-reason badge chains**
+
+A COB_CHANGE check must get a styled badge everywhere sibling trigger reasons
+do (`bg-dark` is unused in these chains). Add to all four if/elif chains:
+
+In `_file_display.html` — chain at ~154-160 (after the `admin_rerun` elif) and
+chain at ~188-196 (same position; this second chain uses `hist_check`):
+
+```html
+ {% elif check.trigger_reason == 'cob_change' %}
+ CoB Change
+```
+
+In `manufacturability_check_status.html` — both chains (~56-66 and ~116-126),
+before the `{% else %}` fallback:
+
+```html
+ {% elif check.trigger_reason == 'cob_change' %}
+ CoB Change
+```
+
+Then extend the detail-page test class with a check-history badge test
+(create the check via `_make_submitted_check`-style setup or factories with
+`trigger_reason=ManufacturabilityCheck.TriggerReason.COB_CHANGE` on the
+project's submitted file, GET the detail page, assert "CoB Change" in the
+content). Write this test FIRST and watch it fail, like the others.
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `uv run pytest wafer_space/projects/tests/test_views.py -v -k "cob"`
+Expected: all new tests PASS. (`make lint` runs djlint over templates — fix any template lint it reports.)
+
+- [ ] **Step 6: Pre-commit gate + commit**
+
+```bash
+make lint-fix && make lint && make type-check && make test
+git add wafer_space/templates/projects/project_detail.html wafer_space/templates/projects/_file_display.html wafer_space/templates/projects/manufacturability_check_status.html wafer_space/projects/tests/test_views.py
+git commit -m "feat: show CoB packaging badges (#259)"
+```
+
+---
+
+### Task 8: Final verification + follow-ups
+
+- [ ] **Step 1: Full quality gate**
+
+Run: `make check-all`
+Expected: everything green. Then `make test` once more: expect baseline + ~17 new tests, 0 failures.
+
+- [ ] **Step 2: Verify migrations are consistent**
+
+Run: `uv run python manage.py makemigrations --check --dry-run`
+Expected: `No changes detected`.
+
+- [ ] **Step 3: Update issue #259**
+
+The issue mentions a `--chip-on-board` flag; the real precheck flag is `--cob`. Post a comment on #259 noting the implemented flag name (use `gh issue comment 259 ...`).
+
+- [ ] **Step 4: Finish the branch**
+
+Use @superpowers:finishing-a-development-branch — push and open a PR referencing #259. PR body should call out: new migration(s), the explicit-cancel design (link the spec), and that no new Celery tasks/schedules were added.
diff --git a/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md b/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md
new file mode 100644
index 00000000..abb61823
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md
@@ -0,0 +1,210 @@
+# Chip-on-Board (CoB) Packaging Support — Design
+
+- **Date:** 2026-06-08 (revised 2026-06-10 for
+ [#261](https://github.com/wafer-space/platform.wafer.space/issues/261) /
+ [PR #262](https://github.com/wafer-space/platform.wafer.space/pull/262))
+- **Issue:** [#259](https://github.com/wafer-space/platform.wafer.space/issues/259)
+- **Status:** Approved design, pending spec review
+- **Assumes:** PR #262 (fixes #261) lands before implementation. It replaces
+ the never-scheduled `checks_cleanup()` / `_cancel_superseded_checks()` with a
+ scheduled `checks_cleanup_superseded` beat task (60s) and fixes the
+ `create_check_drc_update` docstring. This spec is written against that
+ post-#262 state. The design also works without #262 — it never *relies* on
+ the superseded-cleanup task — only the backstop notes below would not apply.
+
+## Summary
+
+Let users request **Chip-on-Board (CoB) packaging** for a project. When CoB is
+requested, the manufacturability precheck runs with the precheck's CoB option so
+the design is validated against the extra CoB compatibility checks. The result
+flows through the existing manufacturability pipeline.
+
+## Background: the precheck interface (verified)
+
+The CoB rules live in the separate precheck image repo
+`wafer-space/gf180mcu-precheck` (`precheck.py`), **not** in this repo. Verified
+against that repo on 2026-06-08:
+
+- `precheck.py` already accepts **`--cob`** — an argparse `store_true` flag:
+ `parser.add_argument("--cob", action="store_true", help="Use the CoB
+ (Chip-On-Board) packaging option (extra checks).")`
+- It threads a boolean config var **`WS_COB`** through the librelane flow,
+ sitting alongside `WS_ID` (→ platform `--id`) and `WS_SLOT` (→ platform
+ `--slot`).
+- CoB enables **extra checks within the same flow**, so CoB failures appear as
+ ordinary manufacturability errors — no new result format to parse.
+
+> Note: issue #259 referred to a `--chip-on-board` flag; the real flag is
+> `--cob`. This spec is authoritative. The issue should be updated to match.
+
+The platform already builds the precheck command in
+`wafer_space/projects/tasks_checks.py` (`do_starting`, ~line 1070) as an argument
+list including `--slot ` and `--id `. Adding `--cob` is exactly
+parallel.
+
+## Design decisions (agreed)
+
+1. **Boolean, not a packaging enum.** The precheck models CoB as on/off
+ (`WS_COB: bool`). A multi-value `packaging` choice field would be premature
+ (YAGNI). Use a boolean `chip_on_board`.
+2. **Editable with auto re-check.** `chip_on_board` is NOT a `CORE_FIELD`. A
+ user may toggle it; toggling invalidates the current manufacturability check
+ and re-runs it with/without `--cob`.
+3. **CoB is orthogonal to slot/shuttle.** The precheck treats `WS_COB`
+ independently of `WS_SLOT`. No added slot-size or shuttle eligibility
+ constraints.
+
+## Components
+
+### 1. Data model (one migration)
+
+- `Project.chip_on_board: BooleanField(default=False)` — editable; excluded from
+ `CORE_FIELDS`. Mirrors the precheck's `WS_COB`.
+
+No new field on `ManufacturabilityCheck`. The precheck command reads
+`chip_on_board` **live from `check.project`** (see §2), matching how
+`--slot`/`--id` already read `check.project.slot_size`/`full_id`
+(`tasks_checks.py:1057-1058`). Because toggling CoB cancels any in-flight check
+and creates a fresh one (§3), at most one non-cancelled check is ever active for
+a file, so a live read of `chip_on_board` reflects what that check was created
+with — no snapshot is needed. (A per-check snapshot could be added later if
+historical CoB labelling of finished checks becomes necessary; out of scope
+here.)
+
+### 2. Precheck command wiring
+
+In `tasks_checks.py` `do_starting`, where the command list is built (the same
+place `--slot`/`--id` are appended from `check.project.slot_size`/`full_id`),
+append `"--cob"` iff `check.project.chip_on_board` is True. `store_true` → no
+value.
+
+### 3. Re-check on toggle (precheck-version-change pattern)
+
+CoB toggling reuses the existing **precheck-version-change** mechanism, not the
+file-replacement cancel path:
+
+- Add a model method `ManufacturabilityCheck.create_check_cob_change()`,
+ parallel to the existing `create_check_drc_update()` (`models.py:2269`). It
+ validates that `self` is the latest check for its `project_file`; if
+ `self.is_cancellable` (the latest check is still in progress) it calls
+ `self.mark_cancelling(reason="Chip-on-Board option changed")`; then it creates
+ and returns a **new PENDING** `ManufacturabilityCheck` with
+ `trigger_reason=TriggerReason.COB_CHANGE` and `parent_check=self` (chaining via
+ `parent_check`/`root_check`, like DRC updates and retries). This is pure
+ model/ORM logic — no task import. (`mark_cancelling` is a state transition;
+ the scheduled `checks_cancelling` task (15s) then completes
+ CANCELLING→CANCELLED, and `checks_cleanup_orphaned_docker` (60s) removes the
+ container once the check is CANCELLED — the same machinery the
+ file-replacement path relies on. Unlike `create_check_drc_update` there are
+ no docker-digest/version guards — the trigger is the user toggling.)
+- **Why the explicit cancel:** the scheduled DRC-update requeue only re-checks
+ *finished* checks (`checks_drc_update_requeue` skips in-progress ones), but a
+ CoB toggle can happen while a check is RUNNING, so
+ `create_check_cob_change()` cancels it directly. Post-#262 the scheduled
+ `checks_cleanup_superseded` task (60s) would *eventually* cancel a superseded
+ in-progress check, but we still cancel explicitly because:
+ 1. **Immediacy/correctness** — relying on the 60s backstop leaves a window in
+ which the superseded check keeps running and could even FINISH
+ (ANALYZING→FINISHED is a legal transition, whereas CANCELLING permits only
+ →CANCELLED), recording a result computed with the *old* CoB setting that
+ §1's live read would then mislabel with the new value. Explicit cancel
+ closes that window at toggle time — a stale FINISH becomes impossible.
+ 2. **Audit precision** — `mark_cancelling(reason="Chip-on-Board option
+ changed")` records why, instead of the generic superseded-cleanup reason.
+ 3. **Determinism** — the method's behaviour is self-contained and testable
+ without depending on beat timing.
+
+ `checks_cleanup_superseded` remains a defence-in-depth backstop (it would
+ mop up if the explicit cancel were ever skipped), not the mechanism.
+- The new PENDING check is dispatched by the scheduled `checks_pending` task
+ (15s); the cancelled in-progress check, if any, is marked CANCELLED by the
+ scheduled `checks_cancelling` task (15s), and its container is removed by
+ `checks_cleanup_orphaned_docker` (60s). All are existing, scheduled pollers.
+- The toggle itself lives in the project edit view's form handling: persist the
+ changed `Project.chip_on_board`; if the active file has a latest check, call
+ `latest_check.create_check_cob_change()`. If the project has no check yet
+ (DRAFT), just persist the flag — the first check reads `chip_on_board` live.
+
+Add `COB_CHANGE = "cob_change", "Chip-on-Board Option Changed"` to
+`ManufacturabilityCheck.TriggerReason`.
+
+Because pending-check creation is pure ORM (the queue processor does the
+dispatching), there is no models-import-tasks layering concern and no separate
+service is required.
+
+### 4. UI
+
+- A "Request Chip-on-Board (CoB) packaging" checkbox on the project **create**
+ and **edit** forms, with help text explaining it runs extra CoB compatibility
+ checks. On save, the view creates the re-check (§3) when the value changes.
+- Project detail: a badge indicating CoB requested (yes/no), reusing existing
+ badge components. The manufacturability result already reflects the extra
+ checks.
+
+## Data flow
+
+1. User checks "Request CoB" on create/edit → project edit view.
+2. View persists `Project.chip_on_board`; if the active file has a latest check,
+ calls `create_check_cob_change()` → a new PENDING `COB_CHANGE` check.
+3. `create_check_cob_change()` has already marked any in-progress check as
+ CANCELLING; the scheduled `checks_cancelling` task marks it CANCELLED
+ (container removal follows via `checks_cleanup_orphaned_docker`) and
+ `checks_pending` dispatches the new PENDING check.
+4. `do_starting` builds the precheck command with `--cob` read live from
+ `check.project.chip_on_board`.
+5. The precheck runs the extra CoB checks; results flow through the existing
+ pipeline; the project detail page shows the CoB badge + manufacturability
+ result.
+
+## Error handling
+
+- Toggling on a DRAFT project (no check yet): persist only; the first check
+ reads `chip_on_board` live.
+- `create_check_cob_change()` raises if called on a non-latest check (mirrors
+ `create_check_drc_update`'s latest-check guard); the view only calls it on the
+ active file's latest check.
+- An in-progress latest check is cancelled explicitly inside
+ `create_check_cob_change()` via `mark_cancelling` (gated on `is_cancellable`,
+ which also avoids `InvalidStateTransitionError`); the scheduled
+ `checks_cancelling` task completes the transition to CANCELLED, and
+ `checks_cleanup_orphaned_docker` removes the container. The scheduled
+ `checks_cleanup_superseded` task (post-#262, 60s) is a defence-in-depth
+ backstop only — the explicit cancel is the primary mechanism (see §3 for
+ why).
+- The precheck `ValueError`/failure paths are unchanged; CoB failures are
+ ordinary manufacturability errors.
+
+## Testing (TDD)
+
+- **Model:** `chip_on_board` defaults False; editable (not blocked by
+ `CORE_FIELDS` immutability). `create_check_cob_change()` creates a PENDING
+ check with `trigger_reason=COB_CHANGE` and `parent_check` set to the source
+ check; when the source check is in progress it is marked CANCELLING; when the
+ source check is already finished no cancel occurs; raises when called on a
+ non-latest check.
+- **Toggle (view):** changing CoB on a project with a latest check creates
+ exactly one new pending `COB_CHANGE` check; toggling on a DRAFT only persists;
+ submitting the form with the value unchanged creates no new check.
+- **Command builder:** `--cob` appended iff `check.project.chip_on_board` is
+ True; absent otherwise; placed alongside `--slot`/`--id`.
+- **View/form:** the checkbox renders on create + edit; POSTing it sets the flag
+ and (when changed) creates the re-check; the project detail page shows the
+ CoB badge when the flag is set (and not when unset).
+
+## Out of scope
+
+- The CoB compatibility **rules** (already implemented in the precheck image).
+- A CoB-specific result breakdown UI — CoB failures surface as normal
+ manufacturability errors; a dedicated breakdown can be a follow-up.
+- Per-shuttle or per-slot CoB eligibility constraints.
+
+## Follow-ups
+
+- Update issue #259 to reference the real `--cob` flag.
+- Before implementation: rebase this branch onto `main` once PR #262 merges —
+ #262 touches `tasks_checks.py` and the `create_check_drc_update` area of
+ `models.py`, both adjacent to this feature's edit sites. (#262 also fixes the
+ previously misleading `create_check_drc_update` docstring, so no docstring
+ follow-up remains here; `create_check_cob_change`'s own docstring should
+ state that it cancels in-progress checks *itself*, unlike
+ `create_check_drc_update` which defers to `checks_cleanup_superseded`.)
diff --git a/wafer_space/projects/forms.py b/wafer_space/projects/forms.py
index b9502511..603e8ec8 100644
--- a/wafer_space/projects/forms.py
+++ b/wafer_space/projects/forms.py
@@ -205,6 +205,7 @@ class Meta:
"name",
"description",
"is_public",
+ "chip_on_board",
"repository_url",
"license_type",
"other_license_spdx_id",
@@ -231,6 +232,7 @@ class Meta:
},
),
"is_public": forms.CheckboxInput(attrs={"class": "form-check-input"}),
+ "chip_on_board": forms.CheckboxInput(attrs={"class": "form-check-input"}),
"repository_url": forms.URLInput(
attrs={
"class": "form-control",
@@ -255,6 +257,7 @@ class Meta:
"description": "Optional details about your design",
"slot_size": "Select the die slot size for your design",
"is_public": "Make this design publicly visible on the platform",
+ # chip_on_board: help_text inherited from the model field
"repository_url": "URL to the project's source repository",
"other_license_spdx_id": (
"SPDX identifier (e.g., GPL-3.0-only, LGPL-2.1-or-later)"
diff --git a/wafer_space/projects/migrations/0056_historicalproject_chip_on_board_and_more.py b/wafer_space/projects/migrations/0056_historicalproject_chip_on_board_and_more.py
new file mode 100644
index 00000000..04b500cc
--- /dev/null
+++ b/wafer_space/projects/migrations/0056_historicalproject_chip_on_board_and_more.py
@@ -0,0 +1,23 @@
+# Generated by Django 5.2.6 on 2026-06-10 14:49
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('projects', '0055_add_commit_info_to_precheck_revision'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='historicalproject',
+ name='chip_on_board',
+ field=models.BooleanField(default=False, help_text='Run extra Chip-on-Board (CoB) compatibility checks during the manufacturability precheck.', verbose_name='Request Chip-on-Board (CoB) packaging'),
+ ),
+ migrations.AddField(
+ model_name='project',
+ name='chip_on_board',
+ field=models.BooleanField(default=False, help_text='Run extra Chip-on-Board (CoB) compatibility checks during the manufacturability precheck.', verbose_name='Request Chip-on-Board (CoB) packaging'),
+ ),
+ ]
diff --git a/wafer_space/projects/migrations/0057_alter_manufacturabilitycheck_trigger_reason.py b/wafer_space/projects/migrations/0057_alter_manufacturabilitycheck_trigger_reason.py
new file mode 100644
index 00000000..e9adc1cb
--- /dev/null
+++ b/wafer_space/projects/migrations/0057_alter_manufacturabilitycheck_trigger_reason.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.6 on 2026-06-10 15:00
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('projects', '0056_historicalproject_chip_on_board_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='manufacturabilitycheck',
+ name='trigger_reason',
+ field=models.CharField(choices=[('initial', 'Initial Check'), ('drc_update', 'DRC Rules Updated'), ('admin_rerun', 'Admin Requested Re-run'), ('retry', 'Retry After Error'), ('cob_change', 'Chip-on-Board Option Changed')], default='initial', help_text='Why this check was triggered', max_length=20),
+ ),
+ ]
diff --git a/wafer_space/projects/models.py b/wafer_space/projects/models.py
index 35211aa8..0778ebd6 100644
--- a/wafer_space/projects/models.py
+++ b/wafer_space/projects/models.py
@@ -13,6 +13,7 @@
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator
from django.db import models
+from django.db import transaction
from django.utils import timezone
from django.utils.formats import date_format
from simple_history.models import HistoricalRecords
@@ -160,6 +161,7 @@ class Status(models.TextChoices):
"name",
"description",
"is_public",
+ "chip_on_board",
"repository_url",
"license_type",
"other_license_spdx_id",
@@ -243,6 +245,16 @@ class Status(models.TextChoices):
help_text="Whether this design should be publicly visible on the platform",
)
+ # Chip-on-Board packaging (Issue #259)
+ chip_on_board = models.BooleanField(
+ default=False,
+ verbose_name="Request Chip-on-Board (CoB) packaging",
+ help_text=(
+ "Run extra Chip-on-Board (CoB) compatibility checks during the "
+ "manufacturability precheck."
+ ),
+ )
+
# Repository URL (Issue #137)
repository_url = models.URLField(
blank=True,
@@ -1551,6 +1563,7 @@ class TriggerReason(models.TextChoices):
DRC_UPDATE = "drc_update", "DRC Rules Updated"
ADMIN_RERUN = "admin_rerun", "Admin Requested Re-run"
RETRY = "retry", "Retry After Error"
+ COB_CHANGE = "cob_change", "Chip-on-Board Option Changed"
class FinishedStatus(models.TextChoices):
"""Sub-status for FINISHED checks indicating manufacturability result."""
@@ -2301,6 +2314,49 @@ def create_check_drc_update(self) -> "ManufacturabilityCheck":
parent_check=self,
)
+ def create_check_cob_change(self) -> "ManufacturabilityCheck":
+ """Create a new pending check after the project's CoB option changed.
+
+ Unlike ``create_check_drc_update`` — which leaves an in-progress check
+ to the scheduled superseded-check cleanup — this cancels an in-progress
+ check itself, so the superseded check can never FINISH with a result
+ computed from the old CoB setting.
+
+ Concurrency: the source row is locked with ``select_for_update`` and
+ re-read inside a transaction before any decision, so two simultaneous
+ toggles cannot both create a COB_CHANGE check, and a check that finished
+ in another worker between page load and submit is observed as FINISHED
+ (and left alone) rather than clobbered back to CANCELLING from a stale
+ in-memory status.
+
+ Returns:
+ The newly created ManufacturabilityCheck.
+
+ Raises:
+ ValueError: If this check is not the latest check for its file.
+ """
+ with transaction.atomic():
+ locked_self = ManufacturabilityCheck.objects.select_for_update().get(
+ pk=self.pk
+ )
+
+ latest = locked_self.project_file.latest_manufacturability_check
+ if latest != locked_self:
+ msg = (
+ "Can only create CoB change check from the latest check for a file"
+ )
+ raise ValueError(msg)
+
+ if locked_self.is_cancellable:
+ locked_self.mark_cancelling(reason="Chip-on-Board option changed")
+
+ return ManufacturabilityCheck.objects.create(
+ project=locked_self.project,
+ project_file=locked_self.project_file,
+ trigger_reason=self.TriggerReason.COB_CHANGE,
+ parent_check=locked_self,
+ )
+
@property
def queue_wait_seconds(self) -> float | None:
"""Time spent waiting in queue before running (in seconds).
diff --git a/wafer_space/projects/tasks_checks.py b/wafer_space/projects/tasks_checks.py
index 1d1c5949..b08fa09f 100644
--- a/wafer_space/projects/tasks_checks.py
+++ b/wafer_space/projects/tasks_checks.py
@@ -1081,6 +1081,8 @@ def do_starting(check: ManufacturabilityCheck) -> dict[str, Any]:
"--id",
full_id,
]
+ if check.project.chip_on_board:
+ command.append("--cob")
command_str = " ".join(command)
logger.info("[do_starting] Container command: %s", command_str)
diff --git a/wafer_space/projects/tests/constants.py b/wafer_space/projects/tests/constants.py
index 6475bcea..028af127 100644
--- a/wafer_space/projects/tests/constants.py
+++ b/wafer_space/projects/tests/constants.py
@@ -27,6 +27,7 @@
# Test counts
EXPECTED_IP_RANGE_COUNT = 8
EXPECTED_USER_PROJECTS = 2 # Number of projects created for test user
+EXPECTED_CHECKS_AFTER_COB_TOGGLE = 2 # original check + COB_CHANGE re-check
# Worker tracking test values
TEST_WORKER_PID = 12345
diff --git a/wafer_space/projects/tests/test_forms.py b/wafer_space/projects/tests/test_forms.py
index 40dafbb8..6ddadfa7 100644
--- a/wafer_space/projects/tests/test_forms.py
+++ b/wafer_space/projects/tests/test_forms.py
@@ -81,6 +81,26 @@ def test_form_invalid_with_empty_name(self):
assert not form.is_valid()
assert "name" in form.errors
+ def test_chip_on_board_field_present_and_optional(self):
+ """chip_on_board is on the form and optional."""
+ form = ProjectForm()
+ assert "chip_on_board" in form.fields
+ assert form.fields["chip_on_board"].required is False
+
+ def test_chip_on_board_editable_for_non_staff_on_existing_project(self):
+ """chip_on_board is a user field — never disabled on edit."""
+ user = User.objects.create_user(
+ username="formuser", email="form@example.com", password=TEST_PASSWORD
+ )
+ project = Project.objects.create(
+ user=user,
+ name="Form Project",
+ shuttle=self.shuttle,
+ project_id="FRMP",
+ )
+ form = ProjectForm(user=user, instance=project)
+ assert form.fields["chip_on_board"].disabled is False
+
def test_form_saves_correctly(self):
"""Test form saves project correctly."""
user = User.objects.create_user(
diff --git a/wafer_space/projects/tests/test_models.py b/wafer_space/projects/tests/test_models.py
index 4d7125b5..59a17db2 100644
--- a/wafer_space/projects/tests/test_models.py
+++ b/wafer_space/projects/tests/test_models.py
@@ -2849,6 +2849,17 @@ def test_trigger_reason_can_be_set(self):
assert check.trigger_reason == ManufacturabilityCheck.TriggerReason.DRC_UPDATE
+@pytest.mark.django_db
+class TestCobChangeTriggerReason:
+ """Tests for the COB_CHANGE trigger reason."""
+
+ def test_cob_change_choice_exists(self):
+ """COB_CHANGE is a valid TriggerReason."""
+ reason = ManufacturabilityCheck.TriggerReason.COB_CHANGE
+ assert reason.value == "cob_change"
+ assert reason.label == "Chip-on-Board Option Changed"
+
+
@pytest.mark.django_db
class TestProjectCoreFieldImmutability:
"""Tests for Project core field immutability validation.
@@ -3181,3 +3192,111 @@ def test_create_check_drc_update_works_for_running_check(self):
assert (
new_check.trigger_reason == ManufacturabilityCheck.TriggerReason.DRC_UPDATE
)
+
+
+@pytest.mark.django_db
+class TestCreateCheckCobChange:
+ """Tests for ManufacturabilityCheck.create_check_cob_change()."""
+
+ def test_creates_pending_cob_change_check(self):
+ """Creates a PENDING check with COB_CHANGE reason chained to the source."""
+ old_check = ManufacturabilityCheckFactory(
+ status=ManufacturabilityCheck.Status.FINISHED,
+ )
+
+ new_check = old_check.create_check_cob_change()
+
+ assert new_check.project == old_check.project
+ assert new_check.project_file == old_check.project_file
+ assert (
+ new_check.trigger_reason == ManufacturabilityCheck.TriggerReason.COB_CHANGE
+ )
+ assert new_check.parent_check == old_check
+ assert new_check.status == ManufacturabilityCheck.Status.PENDING
+
+ def test_finished_source_check_is_not_cancelled(self):
+ """A FINISHED source check keeps its status (nothing to cancel)."""
+ old_check = ManufacturabilityCheckFactory(
+ status=ManufacturabilityCheck.Status.FINISHED,
+ )
+
+ old_check.create_check_cob_change()
+
+ old_check.refresh_from_db()
+ assert old_check.status == ManufacturabilityCheck.Status.FINISHED
+
+ def test_in_progress_source_check_is_marked_cancelling(self):
+ """A RUNNING source check is explicitly marked CANCELLING."""
+ running_check = ManufacturabilityCheckFactory(
+ status=ManufacturabilityCheck.Status.RUNNING,
+ )
+
+ new_check = running_check.create_check_cob_change()
+
+ running_check.refresh_from_db()
+ assert running_check.status == ManufacturabilityCheck.Status.CANCELLING
+ assert "Chip-on-Board option changed" in running_check.processing_logs
+ assert new_check.status == ManufacturabilityCheck.Status.PENDING
+
+ def test_raises_when_not_latest_check(self):
+ """Refuses to run on a check that is not the file's latest."""
+ project_file = ProjectFileFactory()
+ old_check = ManufacturabilityCheckFactory(
+ project_file=project_file,
+ status=ManufacturabilityCheck.Status.FINISHED,
+ )
+ ManufacturabilityCheckFactory(
+ project_file=project_file,
+ status=ManufacturabilityCheck.Status.FINISHED,
+ )
+
+ with pytest.raises(ValueError, match="latest check"):
+ old_check.create_check_cob_change()
+
+ def test_concurrent_finish_is_not_clobbered_by_stale_cancel(self):
+ """A concurrent FINISH must not be overwritten from a stale source check.
+
+ Reproduces the TOCTOU race: the source check is held in memory while
+ RUNNING, but another worker transitions it to FINISHED in the database
+ before the CoB re-check runs. The re-check must observe the committed
+ FINISHED status via a locked re-read and leave it untouched, rather than
+ clobbering it back to CANCELLING from the stale in-memory RUNNING value.
+ """
+ check = ManufacturabilityCheckFactory(
+ status=ManufacturabilityCheck.Status.RUNNING,
+ )
+ # Concurrent worker finishes the check; the in-memory object is unaware.
+ ManufacturabilityCheck.objects.filter(pk=check.pk).update(
+ status=ManufacturabilityCheck.Status.FINISHED,
+ )
+
+ check.create_check_cob_change()
+
+ check.refresh_from_db()
+ assert check.status == ManufacturabilityCheck.Status.FINISHED
+
+
+@pytest.mark.django_db
+class TestProjectChipOnBoard:
+ """Tests for the Project.chip_on_board flag."""
+
+ def test_defaults_to_false(self):
+ """chip_on_board defaults to False."""
+ project = ProjectFactory()
+ assert project.chip_on_board is False
+
+ def test_is_editable_after_creation(self):
+ """chip_on_board is a user field, not blocked by core-field immutability."""
+ project = ProjectFactory()
+ project.chip_on_board = True
+ project.full_clean() # core-field immutability is enforced in clean()
+ project.save()
+ # Fetch fresh from the DB: refresh_from_db() would leave the stale
+ # in-memory attribute in place pre-implementation, hiding the RED.
+ reloaded = Project.objects.get(pk=project.pk)
+ assert reloaded.chip_on_board is True
+
+ def test_is_a_user_field(self):
+ """chip_on_board is in USER_FIELDS and not in CORE_FIELDS."""
+ assert "chip_on_board" in Project.USER_FIELDS
+ assert "chip_on_board" not in Project.CORE_FIELDS
diff --git a/wafer_space/projects/tests/test_tasks.py b/wafer_space/projects/tests/test_tasks.py
index 92dc2d55..e5fc9c02 100644
--- a/wafer_space/projects/tests/test_tasks.py
+++ b/wafer_space/projects/tests/test_tasks.py
@@ -1353,6 +1353,82 @@ def test_creates_and_starts_container(self, tmp_path, settings) -> None:
)
assert check.docker_command == expected_cmd
+ @pytest.mark.django_db
+ def test_command_includes_cob_flag_when_requested(self, tmp_path, settings) -> None:
+ """--cob is appended after --id when project.chip_on_board is True."""
+ settings.DOCKER_SERVERS = [
+ {
+ "id": "test-local",
+ "url": "unix:///test.sock",
+ "max_concurrent": 4,
+ "priority": 1,
+ },
+ ]
+
+ test_file = tmp_path / "design.gds"
+ test_file.write_bytes(b"test gds content")
+
+ shuttle = ShuttleFactory(name="G850")
+
+ check = ManufacturabilityCheckFactory(
+ status=ManufacturabilityCheck.Status.STARTING,
+ docker_server_id="test-local",
+ docker_image="ghcr.io/test:latest",
+ project__shuttle=shuttle,
+ project__project_id="ABCD",
+ project__chip_on_board=True,
+ )
+ check.project_file.file.name = str(test_file)
+ check.project_file.top_cell = "chip_top"
+ check.project_file.save()
+
+ ManufacturabilityCheckTask.objects.create(
+ manufacturability_check=check, task_id="test", task_name="do_starting"
+ )
+
+ mock_docker_path = "wafer_space.projects.tasks_checks.get_docker_client"
+ mock_tar_path = "wafer_space.projects.tasks_checks.create_tar_archive"
+ with (
+ patch(mock_docker_path) as mock_get_docker_client,
+ patch(mock_tar_path) as mock_create_tar,
+ patch("wafer_space.projects.tasks_checks.Path") as mock_path,
+ ):
+ mock_client = MagicMock()
+ mock_get_docker_client.return_value = mock_client
+ mock_container = MagicMock()
+ mock_container.id = "container123"
+ mock_container.status = "running"
+ mock_client.containers.create.return_value = mock_container
+
+ mock_path_instance = MagicMock()
+ mock_path_instance.exists.return_value = True
+ mock_path.return_value = mock_path_instance
+
+ mock_tar_stream = MagicMock()
+ mock_create_tar.return_value.__enter__.return_value = mock_tar_stream
+
+ result = do_starting(check.id)
+
+ assert result["status"] == "success"
+
+ create_call = mock_client.containers.create.call_args
+ command = create_call.kwargs["command"]
+ assert command[-1] == "--cob"
+ assert command[:-1] == [
+ "python3",
+ "precheck.py",
+ "--input",
+ "/input/design.gds",
+ "--output",
+ "/output/design.gds",
+ "--top",
+ "chip_top",
+ "--slot",
+ "1x1",
+ "--id",
+ "G850ABCD",
+ ]
+
@pytest.mark.django_db
def test_cleans_up_task_tracking(self, tmp_path) -> None:
"""Deletes ManufacturabilityCheckTask when done."""
diff --git a/wafer_space/projects/tests/test_views.py b/wafer_space/projects/tests/test_views.py
index c6c9d4d6..20b2cf12 100644
--- a/wafer_space/projects/tests/test_views.py
+++ b/wafer_space/projects/tests/test_views.py
@@ -26,6 +26,7 @@
from wafer_space.users.models import User
from wafer_space.users.tests.factories import UserFactory
+from .constants import EXPECTED_CHECKS_AFTER_COB_TOGGLE
from .constants import EXPECTED_USER_PROJECTS
from .constants import FIVE_MB
from .constants import HTTP_FORBIDDEN
@@ -179,6 +180,50 @@ def test_project_detail_staff_access(self):
assert response.context["project"] == self.project
assert response.context["viewing_as_admin"] is True
+ def test_detail_shows_cob_badge_when_requested(self):
+ """Detail page shows the CoB badge when chip_on_board is set."""
+ self.project.chip_on_board = True
+ self.project.save()
+ self.client.login(username="testuser", password=TEST_PASSWORD)
+ url = reverse("projects:detail", kwargs={"pk": self.project.pk})
+
+ response = self.client.get(url)
+
+ assert response.status_code == HTTP_OK
+ assert "Chip-on-Board (CoB)" in response.content.decode()
+
+ def test_detail_shows_bare_die_packaging_when_not_requested(self):
+ """Detail page labels non-CoB packaging as 'Bare Die'."""
+ self.client.login(username="testuser", password=TEST_PASSWORD)
+ url = reverse("projects:detail", kwargs={"pk": self.project.pk})
+
+ response = self.client.get(url)
+
+ assert response.status_code == HTTP_OK
+ content = response.content.decode()
+ assert "Chip-on-Board" not in content
+ assert "Packaging:" in content
+ assert "Bare Die" in content
+
+ def test_detail_shows_cob_change_badge_in_check_history(self):
+ """Detail page shows CoB Change badge for COB_CHANGE trigger in history."""
+ project_file = ProjectFileFactory(project=self.project)
+ self.project.submitted_file = project_file
+ self.project.save()
+ ManufacturabilityCheckFactory(
+ project=self.project,
+ project_file=project_file,
+ status=ManufacturabilityCheck.Status.FINISHED,
+ trigger_reason=ManufacturabilityCheck.TriggerReason.COB_CHANGE,
+ )
+ self.client.login(username="testuser", password=TEST_PASSWORD)
+ url = reverse("projects:detail", kwargs={"pk": self.project.pk})
+
+ response = self.client.get(url)
+
+ assert response.status_code == HTTP_OK
+ assert "CoB Change" in response.content.decode()
+
@pytest.mark.django_db
class TestProjectCreateView(TestCase):
@@ -380,6 +425,117 @@ def test_non_owner_cannot_update(self):
# Should return 403 Forbidden
assert response.status_code == HTTP_FORBIDDEN
+ def _cob_form_data(self, *, chip_on_board: bool) -> dict[str, str]:
+ """Valid update-form payload toggling only chip_on_board."""
+ data = {
+ "name": self.project.name,
+ "description": self.project.description,
+ "repository_url": "",
+ "license_type": "proprietary",
+ "other_license_spdx_id": "",
+ "proprietary_terms_url": "",
+ }
+ if chip_on_board:
+ data["chip_on_board"] = "on"
+ return data
+
+ def _make_submitted_check(
+ self, status: ManufacturabilityCheck.Status
+ ) -> ManufacturabilityCheck:
+ """Attach a submitted file with a check to self.project."""
+ project_file = ProjectFileFactory(project=self.project)
+ self.project.submitted_file = project_file
+ self.project.save()
+ return ManufacturabilityCheckFactory(
+ project=self.project,
+ project_file=project_file,
+ status=status,
+ )
+
+ def test_toggling_cob_creates_cob_change_check(self):
+ """Enabling CoB on a project with a check creates one COB_CHANGE check."""
+ check = self._make_submitted_check(ManufacturabilityCheck.Status.FINISHED)
+ self.client.login(username="testuser", password=TEST_PASSWORD)
+ url = reverse("projects:update", kwargs={"pk": self.project.pk})
+
+ response = self.client.post(url, self._cob_form_data(chip_on_board=True))
+
+ assert response.status_code == HTTP_FOUND
+ self.project.refresh_from_db()
+ assert self.project.chip_on_board is True
+ checks = ManufacturabilityCheck.objects.filter(
+ project_file=check.project_file
+ ).order_by("created_at")
+ assert checks.count() == EXPECTED_CHECKS_AFTER_COB_TOGGLE
+ new_check = checks.last()
+ assert new_check is not None
+ assert (
+ new_check.trigger_reason == ManufacturabilityCheck.TriggerReason.COB_CHANGE
+ )
+ assert new_check.parent_check == check
+
+ def test_toggling_cob_cancels_in_progress_check(self):
+ """Enabling CoB while a check is RUNNING marks it CANCELLING."""
+ check = self._make_submitted_check(ManufacturabilityCheck.Status.RUNNING)
+ self.client.login(username="testuser", password=TEST_PASSWORD)
+ url = reverse("projects:update", kwargs={"pk": self.project.pk})
+
+ self.client.post(url, self._cob_form_data(chip_on_board=True))
+
+ check.refresh_from_db()
+ assert check.status == ManufacturabilityCheck.Status.CANCELLING
+
+ def test_toggling_cob_on_draft_only_persists(self):
+ """No submitted file/check: the flag is saved, no check is created."""
+ self.client.login(username="testuser", password=TEST_PASSWORD)
+ url = reverse("projects:update", kwargs={"pk": self.project.pk})
+
+ self.client.post(url, self._cob_form_data(chip_on_board=True))
+
+ self.project.refresh_from_db()
+ assert self.project.chip_on_board is True
+ assert ManufacturabilityCheck.objects.filter(project=self.project).count() == 0
+
+ def test_unchanged_cob_creates_no_check(self):
+ """Submitting the form with CoB unchanged creates no new check."""
+ check = self._make_submitted_check(ManufacturabilityCheck.Status.FINISHED)
+ self.client.login(username="testuser", password=TEST_PASSWORD)
+ url = reverse("projects:update", kwargs={"pk": self.project.pk})
+
+ self.client.post(url, self._cob_form_data(chip_on_board=False))
+
+ assert (
+ ManufacturabilityCheck.objects.filter(
+ project_file=check.project_file
+ ).count()
+ == 1
+ )
+
+ def test_cob_recheck_failure_does_not_500_or_lose_edit(self):
+ """A ValueError from the re-check must not 500 or revert the saved edit.
+
+ Under ATOMIC_REQUESTS the whole request shares one transaction, so an
+ exception raised by create_check_cob_change *after* the project has been
+ saved would otherwise roll the request back into a 500 and discard the
+ user's valid CoB change. The view must swallow the recoverable error and
+ still redirect with the edit persisted.
+ """
+ self._make_submitted_check(ManufacturabilityCheck.Status.FINISHED)
+ self.client.login(username="testuser", password=TEST_PASSWORD)
+ url = reverse("projects:update", kwargs={"pk": self.project.pk})
+
+ self.client.raise_request_exception = False
+ with patch.object(
+ ManufacturabilityCheck,
+ "create_check_cob_change",
+ side_effect=ValueError("source check is no longer the latest"),
+ ):
+ response = self.client.post(url, self._cob_form_data(chip_on_board=True))
+
+ assert response.status_code == HTTP_FOUND
+ self.project.refresh_from_db()
+ assert self.project.chip_on_board is True
+
@pytest.mark.django_db
class TestProjectDeleteView(TestCase):
diff --git a/wafer_space/projects/views.py b/wafer_space/projects/views.py
index 944bc8f4..ad367a36 100644
--- a/wafer_space/projects/views.py
+++ b/wafer_space/projects/views.py
@@ -250,12 +250,34 @@ def get_context_data(self, **kwargs):
return context
def form_valid(self, form):
- """Show success message."""
+ """Save, then re-run the manufacturability check if CoB changed."""
+ cob_changed = "chip_on_board" in form.changed_data
+ response = super().form_valid(form)
+
+ if cob_changed:
+ latest_check = self.object.latest_manufacturability_check
+ if latest_check is not None:
+ try:
+ latest_check.create_check_cob_change()
+ except ValueError:
+ # The source check stopped being its file's latest between
+ # page load and submit (e.g. a concurrent re-check). The CoB
+ # change is already saved and valid, and the newer check will
+ # pick up the new flag on its own, so log and carry on rather
+ # than 500 the whole (already-committed) request.
+ logger.warning(
+ "CoB re-check skipped for project %s: source check %s is "
+ "no longer the latest for its file.",
+ self.object.pk,
+ latest_check.pk,
+ exc_info=True,
+ )
+
messages.success(
self.request,
f"Project '{form.instance.name}' updated successfully!",
)
- return super().form_valid(form)
+ return response
def get_success_url(self):
"""Redirect to project detail page."""
diff --git a/wafer_space/templates/projects/_file_display.html b/wafer_space/templates/projects/_file_display.html
index c0bcb19b..322b420c 100644
--- a/wafer_space/templates/projects/_file_display.html
+++ b/wafer_space/templates/projects/_file_display.html
@@ -157,6 +157,8 @@ File Hashes
DRC Update
{% elif check.trigger_reason == 'admin_rerun' %}
Admin Re-run
+ {% elif check.trigger_reason == 'cob_change' %}
+ CoB Change
{% endif %}
{% badge_check_status_and_version check %}
@@ -193,6 +195,8 @@
DRC Update
{% elif hist_check.trigger_reason == 'admin_rerun' %}
Admin Re-run
+ {% elif hist_check.trigger_reason == 'cob_change' %}
+ CoB Change
{% endif %}
{% badge_check_status_and_version hist_check %}
diff --git a/wafer_space/templates/projects/manufacturability_check_status.html b/wafer_space/templates/projects/manufacturability_check_status.html
index 8293f56c..8bad692b 100644
--- a/wafer_space/templates/projects/manufacturability_check_status.html
+++ b/wafer_space/templates/projects/manufacturability_check_status.html
@@ -61,6 +61,8 @@
DRC Update
{% elif check.trigger_reason == 'admin_rerun' %}
Admin
+ {% elif check.trigger_reason == 'cob_change' %}
+ CoB Change
{% else %}
{{ check.get_trigger_reason_display }}
{% endif %}
@@ -121,6 +123,8 @@
DRC Update
{% elif check.trigger_reason == 'admin_rerun' %}
Admin
+ {% elif check.trigger_reason == 'cob_change' %}
+ CoB Change
{% else %}
{{ check.get_trigger_reason_display }}
{% endif %}
diff --git a/wafer_space/templates/projects/project_detail.html b/wafer_space/templates/projects/project_detail.html
index 1d5c9a58..6cc30feb 100644
--- a/wafer_space/templates/projects/project_detail.html
+++ b/wafer_space/templates/projects/project_detail.html
@@ -126,6 +126,14 @@ Project Details
Private
{% endif %}
+
+ Packaging:
+ {% if project.chip_on_board %}
+ Chip-on-Board (CoB)
+ {% else %}
+ Bare Die
+ {% endif %}
+
{% if project.repository_url %}
Repository:
diff --git a/wafer_space/templates/projects/project_form.html b/wafer_space/templates/projects/project_form.html
index c220649a..f47218ed 100644
--- a/wafer_space/templates/projects/project_form.html
+++ b/wafer_space/templates/projects/project_form.html
@@ -63,6 +63,7 @@
Project Details
{{ form.name|as_crispy_field }}
{{ form.description|as_crispy_field }}
{{ form.is_public|as_crispy_field }}
+ {{ form.chip_on_board|as_crispy_field }}
{{ form.repository_url|as_crispy_field }}
{{ form.license_type|as_crispy_field }}
{{ form.other_license_spdx_id|as_crispy_field }}