From de285b3951b0afbd8b25fd4b01981d845c4bb273 Mon Sep 17 00:00:00 2001
From: Tim 'mithro' Ansell
Date: Mon, 8 Jun 2026 22:51:36 +0930
Subject: [PATCH 01/23] docs: add Chip-on-Board packaging design spec
Design for issue #259: editable boolean Project.chip_on_board, a snapshot
field on ManufacturabilityCheck, --cob precheck wiring (the image already
supports it), and a service-layer re-check on toggle (TriggerReason.COB_CHANGE).
Co-Authored-By: Claude Opus 4.8
---
...26-06-08-chip-on-board-packaging-design.md | 134 ++++++++++++++++++
1 file changed, 134 insertions(+)
create mode 100644 docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md
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..f0f67e85
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md
@@ -0,0 +1,134 @@
+# Chip-on-Board (CoB) Packaging Support — Design
+
+- **Date:** 2026-06-08
+- **Issue:** [#259](https://github.com/wafer-space/platform.wafer.space/issues/259)
+- **Status:** Approved design, pending spec review
+
+## 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 `WS_COB`.
+- `ManufacturabilityCheck.chip_on_board: BooleanField(default=False)` — a
+ **snapshot** of the value the check actually ran with. Parallels the existing
+ slot/DRC-version context recorded on checks; makes a finished result
+ unambiguous and lets us detect when a toggle has made the latest result stale.
+
+### 2. Precheck command wiring
+
+In `tasks_checks.py` `do_starting`, where the command list is built, append
+`"--cob"` iff the check's `chip_on_board` is True. `store_true` → no value. The
+snapshot is read from the `ManufacturabilityCheck` so the command matches what
+the check records.
+
+### 3. Re-check on toggle (service layer)
+
+Queuing a task must not happen in `models.py` (layering rule: models never
+import tasks). A service method owns this:
+
+- `ProjectService.set_chip_on_board(project, *, value, user)` (or equivalent in
+ the existing project service package).
+- On an actual change of value:
+ - If the active file has a **cancellable** check, `check.mark_cancelling(
+ reason="Chip-on-Board option changed")` (mirrors
+ `file_service._handle_file_replacement`).
+ - Queue a fresh manufacturability check carrying a new
+ `ManufacturabilityCheck.TriggerReason.COB_CHANGE` and the new snapshot.
+ - If the project is still DRAFT (no check yet), just persist the flag.
+
+Add `COB_CHANGE = "cob_change", "Chip-on-Board Option Changed"` to
+`ManufacturabilityCheck.TriggerReason`.
+
+### 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. The view's form-save calls the service when the value changes so the
+ re-check fires.
+- 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 → view → `set_chip_on_board`.
+2. Service persists `Project.chip_on_board`, cancels any in-flight check, and
+ queues a manufacturability check (`COB_CHANGE`) snapshotting `chip_on_board`.
+3. `do_starting` builds the precheck command with `--cob` from the snapshot.
+4. 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 when no check is cancellable / project is DRAFT: persist only, no
+ cancel (guarded by `is_cancellable` and `InvalidStateTransitionError`, as in
+ `_handle_file_replacement`).
+- 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); snapshot field on `ManufacturabilityCheck`.
+- **Service:** toggling CoB on a project with a running/finished check cancels
+ the check and queues a new `COB_CHANGE` check with the correct snapshot;
+ toggling on a DRAFT only persists.
+- **Command builder:** `--cob` appended iff the check's `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 invokes the service.
+
+## 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.
From 40667ecab22bd11773f3932126c26b889cbc3048 Mon Sep 17 00:00:00 2001
From: Tim 'mithro' Ansell
Date: Mon, 8 Jun 2026 22:55:27 +0930
Subject: [PATCH 02/23] docs: address spec review - read --cob live, drop
snapshot field
Spec reviewer flagged a false 'slot/DRC-version snapshot' precedent: do_starting
reads slot_size/full_id live from check.project, not a snapshot. Drop the
proposed ManufacturabilityCheck.chip_on_board snapshot field and read --cob live
from check.project (consistent with --slot/--id); fix service class reference.
Co-Authored-By: Claude Opus 4.8
---
...26-06-08-chip-on-board-packaging-design.md | 46 +++++++++++--------
1 file changed, 28 insertions(+), 18 deletions(-)
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
index f0f67e85..646f1dc1 100644
--- 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
@@ -51,26 +51,35 @@ parallel.
### 1. Data model (one migration)
- `Project.chip_on_board: BooleanField(default=False)` — editable; excluded from
- `CORE_FIELDS`. Mirrors `WS_COB`.
-- `ManufacturabilityCheck.chip_on_board: BooleanField(default=False)` — a
- **snapshot** of the value the check actually ran with. Parallels the existing
- slot/DRC-version context recorded on checks; makes a finished result
- unambiguous and lets us detect when a toggle has made the latest result stale.
+ `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 queues a new one (§3), a running check's project value is stable, so a live
+read always reflects what the check was queued 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, append
-`"--cob"` iff the check's `chip_on_board` is True. `store_true` → no value. The
-snapshot is read from the `ManufacturabilityCheck` so the command matches what
-the check records.
+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 (service layer)
Queuing a task must not happen in `models.py` (layering rule: models never
import tasks). A service method owns this:
-- `ProjectService.set_chip_on_board(project, *, value, user)` (or equivalent in
- the existing project service package).
+- A new `ProjectService.set_chip_on_board(project, *, value, user)` in
+ `wafer_space/projects/services/project_service.py`. (The existing
+ `ProjectFileService` in `services/file_service.py` owns the file-replacement
+ variant of this cancel+re-check logic; the shared cancel step —
+ `check.is_cancellable` → `check.mark_cancelling(...)` guarded by
+ `InvalidStateTransitionError` — should be reused rather than duplicated.)
- On an actual change of value:
- If the active file has a **cancellable** check, `check.mark_cancelling(
reason="Chip-on-Board option changed")` (mirrors
@@ -96,8 +105,9 @@ Add `COB_CHANGE = "cob_change", "Chip-on-Board Option Changed"` to
1. User checks "Request CoB" on create/edit → view → `set_chip_on_board`.
2. Service persists `Project.chip_on_board`, cancels any in-flight check, and
- queues a manufacturability check (`COB_CHANGE`) snapshotting `chip_on_board`.
-3. `do_starting` builds the precheck command with `--cob` from the snapshot.
+ queues a manufacturability check (`COB_CHANGE`).
+3. `do_starting` builds the precheck command with `--cob` read live from
+ `check.project.chip_on_board`.
4. The precheck runs the extra CoB checks; results flow through the existing
pipeline; the project detail page shows the CoB badge + manufacturability
result.
@@ -113,12 +123,12 @@ Add `COB_CHANGE = "cob_change", "Chip-on-Board Option Changed"` to
## Testing (TDD)
- **Model:** `chip_on_board` defaults False; editable (not blocked by
- `CORE_FIELDS` immutability); snapshot field on `ManufacturabilityCheck`.
+ `CORE_FIELDS` immutability).
- **Service:** toggling CoB on a project with a running/finished check cancels
- the check and queues a new `COB_CHANGE` check with the correct snapshot;
- toggling on a DRAFT only persists.
-- **Command builder:** `--cob` appended iff the check's `chip_on_board` is True;
- absent otherwise; placed alongside `--slot`/`--id`.
+ the check and queues a new `COB_CHANGE` check; toggling on a DRAFT only
+ persists; no re-check when the submitted value is unchanged.
+- **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 invokes the service.
From f30fc64e76e2d8636bd191c9fc9cddd875e1512a Mon Sep 17 00:00:00 2001
From: Tim 'mithro' Ansell
Date: Mon, 8 Jun 2026 23:31:08 +0930
Subject: [PATCH 03/23] docs: model CoB re-check on the precheck-version-change
pattern
Per review feedback, CoB toggling now mirrors the DRC_UPDATE/precheck-version
mechanism instead of the file-replacement cancel path: a new model method
create_check_cob_change() (parallel to create_check_drc_update) creates a new
PENDING check with parent_check linkage and TriggerReason.COB_CHANGE; the
existing _cancel_superseded_checks() auto-cancels any in-progress older check.
Pure ORM, so no service/task-import layer is needed.
Co-Authored-By: Claude Opus 4.8
---
...26-06-08-chip-on-board-packaging-design.md | 91 +++++++++++--------
1 file changed, 55 insertions(+), 36 deletions(-)
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
index 646f1dc1..7490a4ca 100644
--- 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
@@ -56,9 +56,10 @@ parallel.
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 queues a new one (§3), a running check's project value is stable, so a live
-read always reflects what the check was queued with — no snapshot is needed. (A
+(`tasks_checks.py:1057-1058`). Because toggling CoB creates a new check that
+supersedes any in-flight one (§3), a running check's project value is stable, so
+a live read always reflects what the check was queued 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.)
@@ -69,68 +70,86 @@ 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 (service layer)
-
-Queuing a task must not happen in `models.py` (layering rule: models never
-import tasks). A service method owns this:
-
-- A new `ProjectService.set_chip_on_board(project, *, value, user)` in
- `wafer_space/projects/services/project_service.py`. (The existing
- `ProjectFileService` in `services/file_service.py` owns the file-replacement
- variant of this cancel+re-check logic; the shared cancel step —
- `check.is_cancellable` → `check.mark_cancelling(...)` guarded by
- `InvalidStateTransitionError` — should be reused rather than duplicated.)
-- On an actual change of value:
- - If the active file has a **cancellable** check, `check.mark_cancelling(
- reason="Chip-on-Board option changed")` (mirrors
- `file_service._handle_file_replacement`).
- - Queue a fresh manufacturability check carrying a new
- `ManufacturabilityCheck.TriggerReason.COB_CHANGE` and the new snapshot.
- - If the project is still DRAFT (no check yet), just persist the flag.
+### 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`, then creates
+ 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). It is a plain ORM
+ create — no task import and no manual cancellation. (Unlike
+ `create_check_drc_update`, there are no docker-digest/version guards; the
+ trigger is the user toggling, and it is valid whether the latest check is
+ finished or still in progress.)
+- The new pending check is dispatched by the normal check-queue processor. Any
+ still-in-progress older check is auto-cancelled by the existing
+ `_cancel_superseded_checks()` logic (`tasks_checks.py:1922`), which cancels an
+ in-progress check once a newer check exists for the same file — exactly what
+ DRC updates rely on.
+- 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. The view's form-save calls the service when the value changes so the
- re-check fires.
+ 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 → view → `set_chip_on_board`.
-2. Service persists `Project.chip_on_board`, cancels any in-flight check, and
- queues a manufacturability check (`COB_CHANGE`).
-3. `do_starting` builds the precheck command with `--cob` read live from
+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. The check-queue processor dispatches the pending check; any in-progress older
+ check is auto-cancelled by `_cancel_superseded_checks()`.
+4. `do_starting` builds the precheck command with `--cob` read live from
`check.project.chip_on_board`.
-4. The precheck runs the extra CoB checks; results flow through the existing
+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 when no check is cancellable / project is DRAFT: persist only, no
- cancel (guarded by `is_cancellable` and `InvalidStateTransitionError`, as in
- `_handle_file_replacement`).
+- 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.
+- Superseding an in-progress check is handled by the existing
+ `_cancel_superseded_checks()` cleanup (guarded by `InvalidStateTransitionError`),
+ not by new code.
- 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).
-- **Service:** toggling CoB on a project with a running/finished check cancels
- the check and queues a new `COB_CHANGE` check; toggling on a DRAFT only
- persists; no re-check when the submitted value is unchanged.
+ `CORE_FIELDS` immutability). `create_check_cob_change()` creates a PENDING
+ check with `trigger_reason=COB_CHANGE` and `parent_check` set to the source
+ check; 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 invokes the service.
+ and (when changed) creates the re-check.
## Out of scope
From 57443a99670ff66a605c425dea667a36301b0438 Mon Sep 17 00:00:00 2001
From: Tim 'mithro' Ansell
Date: Tue, 9 Jun 2026 00:31:05 +0930
Subject: [PATCH 04/23] docs: fix CoB re-check to cancel in-progress checks
explicitly
Spec review found _cancel_superseded_checks() is only called from
checks_cleanup(), which is NOT in CELERY_BEAT_SCHEDULE, so the relied-upon
auto-cancel never runs. DRC updates never hit this because they only re-check
FINISHED checks. Since a CoB toggle can happen mid-check, create_check_cob_change()
now marks an in-progress latest check as CANCELLING explicitly (mark_cancelling),
relying only on the scheduled checks_pending/checks_cancelling pollers.
Co-Authored-By: Claude Opus 4.8
---
...26-06-08-chip-on-board-packaging-design.md | 61 +++++++++++--------
1 file changed, 37 insertions(+), 24 deletions(-)
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
index 7490a4ca..a5952944 100644
--- 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
@@ -56,12 +56,12 @@ parallel.
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 creates a new check that
-supersedes any in-flight one (§3), a running check's project value is stable, so
-a live read always reflects what the check was queued 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.)
+(`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
@@ -77,19 +77,27 @@ 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`, then creates
- a **new PENDING** `ManufacturabilityCheck` with
+ 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). It is a plain ORM
- create — no task import and no manual cancellation. (Unlike
- `create_check_drc_update`, there are no docker-digest/version guards; the
- trigger is the user toggling, and it is valid whether the latest check is
- finished or still in progress.)
-- The new pending check is dispatched by the normal check-queue processor. Any
- still-in-progress older check is auto-cancelled by the existing
- `_cancel_superseded_checks()` logic (`tasks_checks.py:1922`), which cancels an
- in-progress check once a newer check exists for the same file — exactly what
- DRC updates rely on.
+ `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 performs the container teardown, exactly as
+ the file-replacement path relies on it. Unlike `create_check_drc_update` there
+ are no docker-digest/version guards — the trigger is the user toggling.)
+- **Why the explicit cancel:** DRC updates only ever re-check a *finished* check
+ (`checks_drc_update_requeue` skips in-progress ones), so they never needed to
+ cancel a running check. A CoB toggle can happen while a check is RUNNING, so
+ `create_check_cob_change()` cancels it directly. We deliberately do **not**
+ rely on `_cancel_superseded_checks()` (`tasks_checks.py:1922`): though it would
+ cancel an in-progress check that has a newer sibling, it runs only inside
+ `checks_cleanup()`, which is **not** in `CELERY_BEAT_SCHEDULE` and therefore
+ never executes.
+- The new PENDING check is dispatched by the scheduled `checks_pending` task
+ (15s); the cancelled in-progress check, if any, is torn down by the scheduled
+ `checks_cancelling` task (15s). Both 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
@@ -116,8 +124,9 @@ service is required.
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. The check-queue processor dispatches the pending check; any in-progress older
- check is auto-cancelled by `_cancel_superseded_checks()`.
+3. `create_check_cob_change()` has already marked any in-progress check as
+ CANCELLING; the scheduled `checks_cancelling` task tears it down 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
@@ -131,9 +140,11 @@ service is required.
- `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.
-- Superseding an in-progress check is handled by the existing
- `_cancel_superseded_checks()` cleanup (guarded by `InvalidStateTransitionError`),
- not by new code.
+- An in-progress latest check is cancelled explicitly inside
+ `create_check_cob_change()` via `mark_cancelling` (gated on `is_cancellable`,
+ which also avoids `InvalidStateTransitionError`); teardown is done by the
+ scheduled `checks_cancelling` task. The unscheduled
+ `_cancel_superseded_checks()` is deliberately not relied upon.
- The precheck `ValueError`/failure paths are unchanged; CoB failures are
ordinary manufacturability errors.
@@ -142,7 +153,9 @@ service is required.
- **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; raises when called on a non-latest check.
+ 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.
From 10e2bfb395fed7cb99e2ae5571a1c3eb885fdc4c Mon Sep 17 00:00:00 2001
From: Tim 'mithro' Ansell
Date: Tue, 9 Jun 2026 00:33:10 +0930
Subject: [PATCH 05/23] docs: note stale create_check_drc_update docstring as
impl follow-up
Spec reviewer (approved) flagged that the existing create_check_drc_update
docstring repeats the unscheduled-cleanup belief; capture as a follow-up so it
is not copied into create_check_cob_change.
Co-Authored-By: Claude Opus 4.8
---
.../specs/2026-06-08-chip-on-board-packaging-design.md | 6 ++++++
1 file changed, 6 insertions(+)
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
index a5952944..8be49db7 100644
--- 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
@@ -174,3 +174,9 @@ service is required.
## Follow-ups
- Update issue #259 to reference the real `--cob` flag.
+- When implementing, correct the misleading `create_check_drc_update` docstring
+ (`models.py:2272-2273`), which claims in-progress checks are "automatically
+ cancelled by the existing superseded check cleanup logic" — that cleanup
+ (`checks_cleanup` / `_cancel_superseded_checks`) is not scheduled. It is
+ harmless for DRC (which only re-checks FINISHED checks), but the comment must
+ not be copied into `create_check_cob_change`.
From c025392278688a5908a86d652de70c202d02ca8f Mon Sep 17 00:00:00 2001
From: Tim 'mithro' Ansell
Date: Wed, 10 Jun 2026 17:22:12 +0930
Subject: [PATCH 06/23] docs: revise CoB spec for scheduled superseded cleanup
(#261/PR #262)
Assuming PR #262 lands, checks_cleanup_superseded runs on a 60s beat and
_cancel_superseded_checks/checks_cleanup no longer exist. Update the spec's
rationale: the explicit cancel in create_check_cob_change() is kept for
immediacy (a superseded check must not finish with a stale CoB flag),
audit-precise cancel reason, and test determinism; the scheduled cleanup is
demoted to a defence-in-depth backstop. Drop the now-obsolete docstring
follow-up (fixed by #262) and note the rebase-after-#262 dependency.
Co-Authored-By: Claude Fable 5
---
...26-06-08-chip-on-board-packaging-design.md | 54 +++++++++++++------
1 file changed, 37 insertions(+), 17 deletions(-)
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
index 8be49db7..a8681281 100644
--- 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
@@ -1,8 +1,16 @@
# Chip-on-Board (CoB) Packaging Support — Design
-- **Date:** 2026-06-08
+- **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
@@ -87,14 +95,24 @@ file-replacement cancel path:
scheduled `checks_cancelling` task performs the container teardown, exactly as
the file-replacement path relies on it. Unlike `create_check_drc_update` there
are no docker-digest/version guards — the trigger is the user toggling.)
-- **Why the explicit cancel:** DRC updates only ever re-check a *finished* check
- (`checks_drc_update_requeue` skips in-progress ones), so they never needed to
- cancel a running check. A CoB toggle can happen while a check is RUNNING, so
- `create_check_cob_change()` cancels it directly. We deliberately do **not**
- rely on `_cancel_superseded_checks()` (`tasks_checks.py:1922`): though it would
- cancel an in-progress check that has a newer sibling, it runs only inside
- `checks_cleanup()`, which is **not** in `CELERY_BEAT_SCHEDULE` and therefore
- never executes.
+- **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, recording
+ a result computed with the *old* CoB setting. That would break §1's
+ live-read invariant ("at most one non-cancelled check is ever active").
+ Explicit cancel closes the window at toggle time.
+ 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 torn down by the scheduled
`checks_cancelling` task (15s). Both are existing, scheduled pollers.
@@ -143,8 +161,9 @@ service is required.
- An in-progress latest check is cancelled explicitly inside
`create_check_cob_change()` via `mark_cancelling` (gated on `is_cancellable`,
which also avoids `InvalidStateTransitionError`); teardown is done by the
- scheduled `checks_cancelling` task. The unscheduled
- `_cancel_superseded_checks()` is deliberately not relied upon.
+ scheduled `checks_cancelling` task. 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.
@@ -174,9 +193,10 @@ service is required.
## Follow-ups
- Update issue #259 to reference the real `--cob` flag.
-- When implementing, correct the misleading `create_check_drc_update` docstring
- (`models.py:2272-2273`), which claims in-progress checks are "automatically
- cancelled by the existing superseded check cleanup logic" — that cleanup
- (`checks_cleanup` / `_cancel_superseded_checks`) is not scheduled. It is
- harmless for DRC (which only re-checks FINISHED checks), but the comment must
- not be copied into `create_check_cob_change`.
+- 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`.)
From 02abaaab37c10b69993dc064d207bb0df99e558f Mon Sep 17 00:00:00 2001
From: Tim 'mithro' Ansell
Date: Wed, 10 Jun 2026 17:27:49 +0930
Subject: [PATCH 07/23] docs: fix CoB spec teardown sequence and tighten
stale-FINISH rationale
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Spec review findings: checks_cancelling only transitions CANCELLING->CANCELLED;
container removal is done by checks_cleanup_orphaned_docker (60s) once the
check is CANCELLED — correct all four passages that attributed teardown to
checks_cancelling. Recast the explicit-cancel justification in terms of legal
state transitions (ANALYZING->FINISHED vs CANCELLING->CANCELLED only) instead
of the imprecise 'active' invariant, and add the detail-page CoB badge to the
test plan.
Co-Authored-By: Claude Fable 5
---
...26-06-08-chip-on-board-packaging-design.md | 40 +++++++++++--------
1 file changed, 24 insertions(+), 16 deletions(-)
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
index a8681281..abb61823 100644
--- 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
@@ -91,10 +91,12 @@ file-replacement cancel path:
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 performs the container teardown, exactly as
- the file-replacement path relies on it. Unlike `create_check_drc_update` there
- are no docker-digest/version guards — the trigger is the user toggling.)
+ 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
@@ -102,10 +104,11 @@ file-replacement cancel path:
`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, recording
- a result computed with the *old* CoB setting. That would break §1's
- live-read invariant ("at most one non-cancelled check is ever active").
- Explicit cancel closes the window at toggle time.
+ 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
@@ -114,8 +117,9 @@ file-replacement cancel path:
`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 torn down by the scheduled
- `checks_cancelling` task (15s). Both are existing, scheduled pollers.
+ (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
@@ -143,7 +147,8 @@ service is required.
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 tears it down and
+ 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`.
@@ -160,10 +165,12 @@ service is required.
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`); teardown is done by the
- scheduled `checks_cancelling` task. 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).
+ 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.
@@ -181,7 +188,8 @@ service is required.
- **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.
+ 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
From 6a209dfab0db9037b820024fe4d3a7ecfc8901cd Mon Sep 17 00:00:00 2001
From: Tim 'mithro' Ansell
Date: Wed, 10 Jun 2026 17:43:31 +0930
Subject: [PATCH 08/23] docs: add CoB packaging implementation plan
8 TDD tasks: model field + migration, COB_CHANGE trigger + migration,
create_check_cob_change(), --cob command wiring, form + template, view
re-check hook, detail badge, final verification.
Co-Authored-By: Claude Fable 5
---
.../2026-06-10-chip-on-board-packaging.md | 661 ++++++++++++++++++
1 file changed, 661 insertions(+)
create mode 100644 docs/superpowers/plans/2026-06-10-chip-on-board-packaging.md
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..13d9e0e1
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-10-chip-on-board-packaging.md
@@ -0,0 +1,661 @@
+# 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()
+ project.refresh_from_db()
+ assert project.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` / `AssertionError` (no `chip_on_board` attribute).
+
+- [ ] **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 2341)
+- 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` (match the class's existing style for constructing the form):
+
+```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(user=self.user)
+ 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."""
+ form = ProjectForm(user=self.user, instance=self.project)
+ assert form.fields["chip_on_board"].disabled is False
+```
+
+(If `TestProjectForm.setUp` lacks a `self.project`, create one the same way neighbouring tests do.)
+
+- [ ] **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 — `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 badge on the project detail page
+
+**Files:**
+- Modify: `wafer_space/templates/projects/project_detail.html` (after the Visibility block, ~line 128)
+- 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: Run tests to verify they pass**
+
+Run: `uv run pytest wafer_space/projects/tests/test_views.py -v -k "cob_badge or standard_packaging"`
+Expected: 2 PASSED. (`make lint` runs djlint over templates — fix any template lint it reports.)
+
+- [ ] **Step 5: 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/projects/tests/test_views.py
+git commit -m "feat: show CoB packaging badge on project detail (#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.
From 26658c8ce11da82d8d6f6f6240e5e9d303b8835d Mon Sep 17 00:00:00 2001
From: Tim 'mithro' Ansell
Date: Wed, 10 Jun 2026 17:51:09 +0930
Subject: [PATCH 09/23] docs: address plan review - fix red-phase tests and
line refs
Make Task 1's editability test fetch fresh from the DB so it genuinely fails
before the field exists; give Task 5's form tests their own user/project
(TestProjectForm.setUp only creates a shuttle) and correct both tasks'
expected-failure descriptions; fix the create_check_drc_update end-line
reference.
Co-Authored-By: Claude Fable 5
---
.../2026-06-10-chip-on-board-packaging.md | 36 ++++++++++++++-----
1 file changed, 27 insertions(+), 9 deletions(-)
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
index 13d9e0e1..38040a14 100644
--- a/docs/superpowers/plans/2026-06-10-chip-on-board-packaging.md
+++ b/docs/superpowers/plans/2026-06-10-chip-on-board-packaging.md
@@ -62,8 +62,10 @@ class TestProjectChipOnBoard:
project.chip_on_board = True
project.full_clean() # core-field immutability is enforced in clean()
project.save()
- project.refresh_from_db()
- assert project.chip_on_board is True
+ # 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."""
@@ -74,7 +76,8 @@ class TestProjectChipOnBoard:
- [ ] **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` / `AssertionError` (no `chip_on_board` attribute).
+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**
@@ -169,7 +172,7 @@ 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 2341)
+- 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**
@@ -374,27 +377,42 @@ git commit -m "feat: pass --cob to precheck when chip_on_board is set (#259)"
- [ ] **Step 1: Write the failing tests**
-Add to `TestProjectForm` in `test_forms.py` (match the class's existing style for constructing the form):
+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(user=self.user)
+ 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."""
- form = ProjectForm(user=self.user, instance=self.project)
+ 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
```
-(If `TestProjectForm.setUp` lacks a `self.project`, create one the same way neighbouring tests do.)
+(`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 — `KeyError: 'chip_on_board'`.
+Expected: 2 FAILED — `AssertionError` (field not in `form.fields`) /
+`KeyError: 'chip_on_board'`.
- [ ] **Step 3: Implement the form changes**
From 316b9787c46dcdf9769c660bec83ea730d184311 Mon Sep 17 00:00:00 2001
From: Tim 'mithro' Ansell
Date: Thu, 11 Jun 2026 00:23:38 +0930
Subject: [PATCH 10/23] feat: add Project.chip_on_board field (#259)
Add boolean USER_FIELD for Chip-on-Board packaging opt-in. Defaults
to False, always user-editable (not subject to core-field immutability).
Migration adds the field to both project and historicalproject tables.
Co-Authored-By: Claude Fable 5
---
...istoricalproject_chip_on_board_and_more.py | 23 ++++++++++++++++
wafer_space/projects/models.py | 11 ++++++++
wafer_space/projects/tests/test_models.py | 26 +++++++++++++++++++
3 files changed, 60 insertions(+)
create mode 100644 wafer_space/projects/migrations/0056_historicalproject_chip_on_board_and_more.py
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/models.py b/wafer_space/projects/models.py
index 35211aa8..c0a3b5f4 100644
--- a/wafer_space/projects/models.py
+++ b/wafer_space/projects/models.py
@@ -160,6 +160,7 @@ class Status(models.TextChoices):
"name",
"description",
"is_public",
+ "chip_on_board",
"repository_url",
"license_type",
"other_license_spdx_id",
@@ -243,6 +244,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,
diff --git a/wafer_space/projects/tests/test_models.py b/wafer_space/projects/tests/test_models.py
index 4d7125b5..6cdbc902 100644
--- a/wafer_space/projects/tests/test_models.py
+++ b/wafer_space/projects/tests/test_models.py
@@ -3181,3 +3181,29 @@ 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 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
From 98e9f875de05e95cb7bbfe4611361a5ecb03c7f0 Mon Sep 17 00:00:00 2001
From: Tim 'mithro' Ansell
Date: Thu, 11 Jun 2026 00:34:54 +0930
Subject: [PATCH 11/23] feat: add COB_CHANGE manufacturability trigger reason
(#259)
Extends ManufacturabilityCheck.TriggerReason with COB_CHANGE ("cob_change")
to support re-triggering checks when a project's chip_on_board option changes.
Co-Authored-By: Claude Fable 5
---
...er_manufacturabilitycheck_trigger_reason.py | 18 ++++++++++++++++++
wafer_space/projects/models.py | 1 +
wafer_space/projects/tests/test_models.py | 11 +++++++++++
3 files changed, 30 insertions(+)
create mode 100644 wafer_space/projects/migrations/0057_alter_manufacturabilitycheck_trigger_reason.py
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 c0a3b5f4..66b3475f 100644
--- a/wafer_space/projects/models.py
+++ b/wafer_space/projects/models.py
@@ -1562,6 +1562,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."""
diff --git a/wafer_space/projects/tests/test_models.py b/wafer_space/projects/tests/test_models.py
index 6cdbc902..6adbe0f2 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.
From 7280b5ef6f6fc57619b1c8d4c4e6bcaf39e0fe56 Mon Sep 17 00:00:00 2001
From: Tim 'mithro' Ansell
Date: Thu, 11 Jun 2026 00:44:22 +0930
Subject: [PATCH 12/23] docs: extend plan Task 7 to cover trigger-reason badge
chains
Task 2's quality review found that _file_display.html's trigger-reason badge
chains have no else fallback (a COB_CHANGE check would render no badge) and
manufacturability_check_status.html falls back to a generic label. Add
cob_change branches to all four chains plus a check-history badge test.
Co-Authored-By: Claude Fable 5
---
.../2026-06-10-chip-on-board-packaging.md | 43 ++++++++++++++++---
1 file changed, 36 insertions(+), 7 deletions(-)
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
index 38040a14..147e7431 100644
--- a/docs/superpowers/plans/2026-06-10-chip-on-board-packaging.md
+++ b/docs/superpowers/plans/2026-06-10-chip-on-board-packaging.md
@@ -589,10 +589,12 @@ git commit -m "feat: re-run manufacturability check when CoB toggled (#259)"
---
-### Task 7: CoB badge on the project detail page
+### 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**
@@ -643,17 +645,44 @@ In `project_detail.html`, after the Visibility `` (line ~128), matching the
```
-- [ ] **Step 4: Run tests to verify they pass**
+- [ ] **Step 4: Add `cob_change` to the trigger-reason badge chains**
-Run: `uv run pytest wafer_space/projects/tests/test_views.py -v -k "cob_badge or standard_packaging"`
-Expected: 2 PASSED. (`make lint` runs djlint over templates — fix any template lint it reports.)
+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:
-- [ ] **Step 5: Pre-commit gate + commit**
+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/projects/tests/test_views.py
-git commit -m "feat: show CoB packaging badge on project detail (#259)"
+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)"
```
---
From 04806a2892855c249cb6b6e7895a7a43afc27312 Mon Sep 17 00:00:00 2001
From: Tim 'mithro' Ansell
Date: Thu, 11 Jun 2026 00:50:17 +0930
Subject: [PATCH 13/23] feat: add create_check_cob_change model method (#259)
Add ManufacturabilityCheck.create_check_cob_change() which creates a
PENDING check with TriggerReason.COB_CHANGE, explicitly cancels any
in-progress source check (unlike DRC update which relies on superseded-
check cleanup), and guards against being called on a non-latest check.
Co-Authored-By: Claude Fable 5
---
wafer_space/projects/models.py | 29 +++++++++++
wafer_space/projects/tests/test_models.py | 60 +++++++++++++++++++++++
2 files changed, 89 insertions(+)
diff --git a/wafer_space/projects/models.py b/wafer_space/projects/models.py
index 66b3475f..fdd1dc5c 100644
--- a/wafer_space/projects/models.py
+++ b/wafer_space/projects/models.py
@@ -2313,6 +2313,35 @@ 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.
+
+ 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,
+ )
+
@property
def queue_wait_seconds(self) -> float | None:
"""Time spent waiting in queue before running (in seconds).
diff --git a/wafer_space/projects/tests/test_models.py b/wafer_space/projects/tests/test_models.py
index 6adbe0f2..c54e0a34 100644
--- a/wafer_space/projects/tests/test_models.py
+++ b/wafer_space/projects/tests/test_models.py
@@ -3194,6 +3194,66 @@ def test_create_check_drc_update_works_for_running_check(self):
)
+@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()
+
+
@pytest.mark.django_db
class TestProjectChipOnBoard:
"""Tests for the Project.chip_on_board flag."""
From ac9e26549818b0ec6d0e8c4f4d3273e0820005b6 Mon Sep 17 00:00:00 2001
From: Tim 'mithro' Ansell
Date: Thu, 11 Jun 2026 01:00:36 +0930
Subject: [PATCH 14/23] feat: pass --cob to precheck when chip_on_board is set
(#259)
Append --cob to the Docker precheck command in do_starting when
project.chip_on_board is True; add TDD test confirming the flag is
present (cob=True) and absent (cob=False via existing regression test).
Co-Authored-By: Claude Fable 5
---
wafer_space/projects/tasks_checks.py | 2 +
wafer_space/projects/tests/test_tasks.py | 76 ++++++++++++++++++++++++
2 files changed, 78 insertions(+)
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/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."""
From 34243d91e057e7db54c91a0a0e6fe2f36a59d950 Mon Sep 17 00:00:00 2001
From: Tim 'mithro' Ansell
Date: Thu, 11 Jun 2026 01:08:31 +0930
Subject: [PATCH 15/23] feat: add CoB checkbox to project form (#259)
Add chip_on_board field to ProjectForm.Meta.fields/widgets/help_texts
and render it in project_form.html after is_public. The field is a
user field (not a CORE_FIELD) so it stays enabled for non-staff on
existing projects.
Co-Authored-By: Claude Fable 5
---
wafer_space/projects/forms.py | 6 ++++++
wafer_space/projects/tests/test_forms.py | 20 +++++++++++++++++++
.../templates/projects/project_form.html | 1 +
3 files changed, 27 insertions(+)
diff --git a/wafer_space/projects/forms.py b/wafer_space/projects/forms.py
index b9502511..42b2bbb2 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,10 @@ 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": (
+ "Run extra Chip-on-Board (CoB) compatibility checks during the"
+ " manufacturability precheck"
+ ),
"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/tests/test_forms.py b/wafer_space/projects/tests/test_forms.py
index 40dafbb8..08de5f85 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, 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
+
def test_form_saves_correctly(self):
"""Test form saves project correctly."""
user = User.objects.create_user(
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 }}
From 804845a08168b9232026ee99a58ade5cc540e81f Mon Sep 17 00:00:00 2001
From: Tim 'mithro' Ansell
Date: Thu, 11 Jun 2026 01:15:54 +0930
Subject: [PATCH 16/23] refactor: inherit CoB help text from model field
Quality review polish: drop the Meta.help_texts duplicate (ModelForm inherits
the model field's help_text) and trim an overstated test docstring.
Co-Authored-By: Claude Fable 5
---
wafer_space/projects/forms.py | 5 +----
wafer_space/projects/tests/test_forms.py | 2 +-
2 files changed, 2 insertions(+), 5 deletions(-)
diff --git a/wafer_space/projects/forms.py b/wafer_space/projects/forms.py
index 42b2bbb2..603e8ec8 100644
--- a/wafer_space/projects/forms.py
+++ b/wafer_space/projects/forms.py
@@ -257,10 +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": (
- "Run extra Chip-on-Board (CoB) compatibility checks during the"
- " manufacturability precheck"
- ),
+ # 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/tests/test_forms.py b/wafer_space/projects/tests/test_forms.py
index 08de5f85..6ddadfa7 100644
--- a/wafer_space/projects/tests/test_forms.py
+++ b/wafer_space/projects/tests/test_forms.py
@@ -82,7 +82,7 @@ def test_form_invalid_with_empty_name(self):
assert "name" in form.errors
def test_chip_on_board_field_present_and_optional(self):
- """chip_on_board is on the form, optional, and defaults to False."""
+ """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
From f23c5b4a46bf59b200e528993082c1f2bfcc8d38 Mon Sep 17 00:00:00 2001
From: Tim 'mithro' Ansell
Date: Thu, 11 Jun 2026 01:21:05 +0930
Subject: [PATCH 17/23] feat: re-run manufacturability check when CoB toggled
(#259)
Add form_valid override on ProjectUpdateView that detects chip_on_board
changes via form.changed_data and calls create_check_cob_change() on the
latest check, cancelling any in-progress check and queuing a new PENDING
COB_CHANGE check for the scheduled dispatcher to pick up.
Co-Authored-By: Claude Fable 5
---
wafer_space/projects/tests/test_views.py | 84 ++++++++++++++++++++++++
wafer_space/projects/views.py | 12 +++-
2 files changed, 94 insertions(+), 2 deletions(-)
diff --git a/wafer_space/projects/tests/test_views.py b/wafer_space/projects/tests/test_views.py
index c6c9d4d6..9b97070c 100644
--- a/wafer_space/projects/tests/test_views.py
+++ b/wafer_space/projects/tests/test_views.py
@@ -380,6 +380,90 @@ 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:
+ """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 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
+ )
+
@pytest.mark.django_db
class TestProjectDeleteView(TestCase):
diff --git a/wafer_space/projects/views.py b/wafer_space/projects/views.py
index 944bc8f4..8cc04a25 100644
--- a/wafer_space/projects/views.py
+++ b/wafer_space/projects/views.py
@@ -250,12 +250,20 @@ 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:
+ latest_check.create_check_cob_change()
+
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."""
From 6ee5ab8ce6dbfce07e35b213e248e93f92d27927 Mon Sep 17 00:00:00 2001
From: Tim 'mithro' Ansell
Date: Thu, 11 Jun 2026 01:29:51 +0930
Subject: [PATCH 18/23] refactor: use named constant and type hints in CoB
toggle tests
Quality review fixes: replace the len([check, 'new']) lint-evasion idiom with
EXPECTED_CHECKS_AFTER_COB_TOGGLE in tests/constants.py, annotate the
_make_submitted_check helper, and derive the form payload's name/description
from setUp's project instead of duplicating the strings.
Co-Authored-By: Claude Fable 5
---
wafer_space/projects/tests/constants.py | 1 +
wafer_space/projects/tests/test_views.py | 13 ++++++++-----
2 files changed, 9 insertions(+), 5 deletions(-)
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_views.py b/wafer_space/projects/tests/test_views.py
index 9b97070c..b9b763d2 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
@@ -380,11 +381,11 @@ 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:
+ def _cob_form_data(self, *, chip_on_board: bool) -> dict[str, str]:
"""Valid update-form payload toggling only chip_on_board."""
data = {
- "name": "Test Project",
- "description": "Test project",
+ "name": self.project.name,
+ "description": self.project.description,
"repository_url": "",
"license_type": "proprietary",
"other_license_spdx_id": "",
@@ -394,7 +395,9 @@ def _cob_form_data(self, *, chip_on_board: bool) -> dict:
data["chip_on_board"] = "on"
return data
- def _make_submitted_check(self, status):
+ 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
@@ -419,7 +422,7 @@ def test_toggling_cob_creates_cob_change_check(self):
checks = ManufacturabilityCheck.objects.filter(
project_file=check.project_file
).order_by("created_at")
- assert checks.count() == len([check, "new"]) # exactly one new check
+ assert checks.count() == EXPECTED_CHECKS_AFTER_COB_TOGGLE
new_check = checks.last()
assert new_check is not None
assert (
From 264022c7738a7a770419bbf6693aea98d25f9f58 Mon Sep 17 00:00:00 2001
From: Tim 'mithro' Ansell
Date: Thu, 11 Jun 2026 01:35:50 +0930
Subject: [PATCH 19/23] feat: show CoB packaging badges (#259)
Add Chip-on-Board badge to project detail page and CoB Change trigger-reason
badges to all four if/elif chains in _file_display.html and
manufacturability_check_status.html.
Co-Authored-By: Claude Fable 5
---
wafer_space/projects/tests/test_views.py | 41 +++++++++++++++++++
.../templates/projects/_file_display.html | 4 ++
.../manufacturability_check_status.html | 4 ++
.../templates/projects/project_detail.html | 8 ++++
4 files changed, 57 insertions(+)
diff --git a/wafer_space/projects/tests/test_views.py b/wafer_space/projects/tests/test_views.py
index b9b763d2..18cd1ae0 100644
--- a/wafer_space/projects/tests/test_views.py
+++ b/wafer_space/projects/tests/test_views.py
@@ -180,6 +180,47 @@ 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" 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()
+
+ 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):
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..4a0157bd 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 %}
+ Standard
+ {% endif %}
+
{% if project.repository_url %}
Repository:
From 7ca1322b79caae4d556529807a71802ee7c27261 Mon Sep 17 00:00:00 2001
From: Tim 'mithro' Ansell
Date: Thu, 11 Jun 2026 01:43:14 +0930
Subject: [PATCH 20/23] test: tighten CoB badge assertions
Quality review polish: assert the unambiguous 'Chip-on-Board (CoB)' string in
the positive test, and make the standard-packaging test assert the Packaging
block and Standard badge actually render rather than only absence.
Co-Authored-By: Claude Fable 5
---
wafer_space/projects/tests/test_views.py | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/wafer_space/projects/tests/test_views.py b/wafer_space/projects/tests/test_views.py
index 18cd1ae0..ab9d8840 100644
--- a/wafer_space/projects/tests/test_views.py
+++ b/wafer_space/projects/tests/test_views.py
@@ -190,7 +190,7 @@ def test_detail_shows_cob_badge_when_requested(self):
response = self.client.get(url)
assert response.status_code == HTTP_OK
- assert "Chip-on-Board" in response.content.decode()
+ assert "Chip-on-Board (CoB)" 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."""
@@ -200,7 +200,10 @@ def test_detail_shows_standard_packaging_when_not_requested(self):
response = self.client.get(url)
assert response.status_code == HTTP_OK
- assert "Chip-on-Board" not in response.content.decode()
+ content = response.content.decode()
+ assert "Chip-on-Board" not in content
+ assert "Packaging:" in content
+ assert "Standard" 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."""
From aefbe51b447204598f22e1c91467639f45863b8d Mon Sep 17 00:00:00 2001
From: Tim 'mithro' Ansell
Date: Thu, 18 Jun 2026 15:29:20 +0930
Subject: [PATCH 21/23] fix: lock source check row in create_check_cob_change
Addresses a CodeRabbit finding on PR #265: "latest" and is_cancellable
were read off an unlocked in-memory instance, so a concurrent transition
could (a) clobber a freshly FINISHED check back to CANCELLING from a stale
in-memory status, or (b) let two simultaneous CoB toggles each create a
COB_CHANGE check.
Wrap the body in transaction.atomic() and re-read the row with
select_for_update() before any decision, so the choice is made against
committed state and concurrent toggles serialize on the row lock.
The new test reproduces the FINISHED-clobber deterministically (no
threads): it updates the row out-of-band, then asserts the locked re-read
leaves the committed FINISHED status untouched.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01M1FsYafhYvbwqWVCkGDPYJ
---
wafer_space/projects/models.py | 39 ++++++++++++++++-------
wafer_space/projects/tests/test_models.py | 22 +++++++++++++
2 files changed, 49 insertions(+), 12 deletions(-)
diff --git a/wafer_space/projects/models.py b/wafer_space/projects/models.py
index fdd1dc5c..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
@@ -2321,26 +2322,40 @@ def create_check_cob_change(self) -> "ManufacturabilityCheck":
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.
"""
- 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)
+ with transaction.atomic():
+ locked_self = ManufacturabilityCheck.objects.select_for_update().get(
+ pk=self.pk
+ )
- if self.is_cancellable:
- self.mark_cancelling(reason="Chip-on-Board option changed")
+ 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)
- return ManufacturabilityCheck.objects.create(
- project=self.project,
- project_file=self.project_file,
- trigger_reason=self.TriggerReason.COB_CHANGE,
- parent_check=self,
- )
+ 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:
diff --git a/wafer_space/projects/tests/test_models.py b/wafer_space/projects/tests/test_models.py
index c54e0a34..59a17db2 100644
--- a/wafer_space/projects/tests/test_models.py
+++ b/wafer_space/projects/tests/test_models.py
@@ -3253,6 +3253,28 @@ def test_raises_when_not_latest_check(self):
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:
From ae7ec14e7cd7b65afa47a759d18d91ccf02dd40d Mon Sep 17 00:00:00 2001
From: Tim 'mithro' Ansell
Date: Thu, 18 Jun 2026 15:29:26 +0930
Subject: [PATCH 22/23] fix: don't 500 when CoB re-check creation fails
post-save
Addresses a CodeRabbit finding on PR #265: create_check_cob_change() can
raise ValueError when the source check is no longer its file's latest.
Because it runs after super().form_valid() has already saved the project,
and ATOMIC_REQUESTS wraps the whole request in one transaction, the raise
rolled the request back into a 500 and discarded the user's valid CoB edit.
Catch the recoverable ValueError, log a warning, and let the request finish
normally. The model's transaction.atomic() block means the inner savepoint
rolls back cleanly, leaving the committed project save intact; the newer
check that superseded this one picks up the new flag on its own.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01M1FsYafhYvbwqWVCkGDPYJ
---
wafer_space/projects/tests/test_views.py | 25 ++++++++++++++++++++++++
wafer_space/projects/views.py | 16 ++++++++++++++-
2 files changed, 40 insertions(+), 1 deletion(-)
diff --git a/wafer_space/projects/tests/test_views.py b/wafer_space/projects/tests/test_views.py
index ab9d8840..e533e995 100644
--- a/wafer_space/projects/tests/test_views.py
+++ b/wafer_space/projects/tests/test_views.py
@@ -511,6 +511,31 @@ def test_unchanged_cob_creates_no_check(self):
== 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 8cc04a25..ad367a36 100644
--- a/wafer_space/projects/views.py
+++ b/wafer_space/projects/views.py
@@ -257,7 +257,21 @@ def form_valid(self, form):
if cob_changed:
latest_check = self.object.latest_manufacturability_check
if latest_check is not None:
- latest_check.create_check_cob_change()
+ 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,
From ebc828abe1c42ed2323d8945a368143e6f7889b7 Mon Sep 17 00:00:00 2001
From: Tim 'mithro' Ansell
Date: Sun, 21 Jun 2026 03:10:34 +0930
Subject: [PATCH 23/23] fix: label non-CoB packaging as "Bare Die" instead of
"Standard"
The project detail page showed non-Chip-on-Board projects as "Standard"
packaging. The correct term for an unpackaged die is "Bare Die", so rename
the badge label and update the detail-view test accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01M1FsYafhYvbwqWVCkGDPYJ
---
wafer_space/projects/tests/test_views.py | 6 +++---
wafer_space/templates/projects/project_detail.html | 2 +-
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/wafer_space/projects/tests/test_views.py b/wafer_space/projects/tests/test_views.py
index e533e995..20b2cf12 100644
--- a/wafer_space/projects/tests/test_views.py
+++ b/wafer_space/projects/tests/test_views.py
@@ -192,8 +192,8 @@ def test_detail_shows_cob_badge_when_requested(self):
assert response.status_code == HTTP_OK
assert "Chip-on-Board (CoB)" 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."""
+ 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})
@@ -203,7 +203,7 @@ def test_detail_shows_standard_packaging_when_not_requested(self):
content = response.content.decode()
assert "Chip-on-Board" not in content
assert "Packaging:" in content
- assert "Standard" 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."""
diff --git a/wafer_space/templates/projects/project_detail.html b/wafer_space/templates/projects/project_detail.html
index 4a0157bd..6cc30feb 100644
--- a/wafer_space/templates/projects/project_detail.html
+++ b/wafer_space/templates/projects/project_detail.html
@@ -131,7 +131,7 @@ Project Details
{% if project.chip_on_board %}
Chip-on-Board (CoB)
{% else %}
- Standard
+ Bare Die
{% endif %}
{% if project.repository_url %}