diff --git a/fusion_plating/docs/superpowers/specs/2026-06-03-wo-grouping-by-recipe-combined-cert-design.md b/fusion_plating/docs/superpowers/specs/2026-06-03-wo-grouping-by-recipe-combined-cert-design.md
new file mode 100644
index 00000000..fe559280
--- /dev/null
+++ b/fusion_plating/docs/superpowers/specs/2026-06-03-wo-grouping-by-recipe-combined-cert-design.md
@@ -0,0 +1,425 @@
+# WO Grouping by Recipe + Combined Multi-Part Certificate
+
+**Date:** 2026-06-03
+**Module(s):** `fusion_plating_jobs`, `fusion_plating_certificates`, `fusion_plating_reports`
+**Author:** Gurpreet (Nexa Systems Inc.)
+**Status:** Approved — ready for implementation plan
+
+## Summary
+
+Today a confirmed sale order with N plating lines creates N work orders
+(`fp.job` / "WO-NNN"), even when every line runs the same plating
+process. The shop wants **one work order per recipe** — different parts
+that go through the same process should ride one traveller and one
+physical batch, splitting into separate WOs **only when the process
+actually differs**.
+
+The blocker is the **Certificate of Conformance**: a `fp.job` carries a
+single `part_catalog_id` / `customer_spec_id`, and the CoC PDF renders
+exactly one part row. Collapsing four parts onto one WO would certify
+only the first and silently ship the other three uncertified — the exact
+"silent mis-attestation" the 2026-05-13 sticker spec was built to
+prevent.
+
+This spec resolves that by making the **certificate multi-part**: one
+combined CoC per WO that lists every part in a table, each with its own
+part #, spec, serial, and quantities. The grouping change and the
+multi-part cert ship together because neither is safe alone.
+
+## Audit findings (live entech, db=admin, read-only, 2026-06-03)
+
+Pulled the real numbers before designing — they overturned the obvious
+"group by `recipe_id`" approach.
+
+| Order | Lines | WOs today | Distinct recipes | WOs after |
+|-------|-------|-----------|------------------|-----------|
+| SO-30092 | 2 | 2 | 2 (`ENP ALUM BASIC HP`) | **1** |
+| SO-30083 | 4 | 4 | 4 (`ENP-STEEL-MP-BASIC`) | **1** |
+| SO-30079 | 4 | 4 | 4 (2 parts × 2 lines) | **1** |
+| SO-30071 | 3 | 3 | 3 (`ENP-STEEL-MP-BASIC`) | **1** |
+
+- 23 confirmed SOs total; 4 are multi-plating-line. 13 plating lines
+ across those 4 orders collapse from **13 WOs → 4 WOs**.
+- **Root cause:** every part gets its own *clone* of a base recipe,
+ renamed ` — ` (the ` — ` is stamped by
+ `_clone_subtree` in `fp_part_composer_controller.py`). So each line
+ resolves to a *distinct* `fusion.plating.process.node` record →
+ grouping by `recipe_id` merges **nothing**.
+- The clones are **byte-identical in structure** — 9 (or 11) descendant
+ nodes, same `node_type` + `kind_id.code` + name in the same order.
+ Verified across all 4 orders. So merging is **faithful**: every part
+ follows the identical steps.
+- `process_type_id` is **empty** on all of them → not a usable signal.
+- `cloned_from_id` exists as a field but is **empty on all 13** lines →
+ not usable for existing data without a backfill.
+- **13 existing `fp.certificate` rows** → migration size.
+
+**Conclusion:** the only signals that work on real data are *identical
+step structure* and *shared base-name prefix*. We group by **identical
+step structure** (truthful, naming-independent, no backfill).
+
+## Locked decisions (from brainstorming, 2026-06-03)
+
+| Q | Decision |
+|---|----------|
+| One WO covers many parts — how do certs work? | **One combined cert** listing every part in a table. |
+| How much varies between parts in one order? | **Varies by order** → build the full per-part model (handles uniform and per-part-divergent orders). |
+| Is "same recipe" one shared record or per-part copies? | **Audited:** per-part clones, structurally identical. Group by structure, not record id. |
+| Grouping signal? | **Identical step structure** (recipe structural signature). |
+| Two recipes "the same"? | Same `node_type` + `kind_id.code` + name sequence across descendant steps. Numeric targets (thickness/temp/time) are **excluded** — they're per-part attestation data on the cert, not a batch splitter. |
+
+## Goals / non-goals
+
+**Goals**
+- One WO per distinct plating process; same-process parts share one WO.
+- A single combined CoC per WO listing each part's own identity + spec +
+ quantities.
+- No silent loss of any part's certification when parts share a WO.
+- Per-part masking/bake differences split the WO (never silently merge).
+- Existing WOs and certs keep working unchanged; the 13 existing certs
+ render identically after migration.
+
+**Non-goals**
+- Re-grouping already-created WOs (only new confirmations regroup).
+- Removing the per-part recipe-cloning mechanism (root-cause fix to the
+ Part Composer — separate, larger, riskier; out of scope).
+- Per-part thickness rendering, per-part box stickers, per-part issue
+ gate → **Phase 2** (see below).
+- Per-physical-box serial tracking (unchanged from prior specs).
+
+## Architecture
+
+### Phase 1 — compliance-safe MVP
+
+#### Change 1 — Grouping by recipe structural signature
+
+File: `fusion_plating_jobs/models/sale_order.py`, method
+`_fp_auto_create_job` (the `groups` block around line 439-470).
+
+Replace the 5-tuple key `(recipe, part, spec, thickness, serial)` with a
+**structural signature** key. New helpers on `sale.order`:
+
+```python
+def _fp_recipe_signature(self, recipe):
+ """Hashable structural signature of a recipe's step tree.
+
+ Two recipes with the same signature have identical processing
+ steps and can share one work order. Excludes the recipe ROOT name
+ (carries the per-part ' — ' suffix) and all numeric targets
+ (thickness/temp/time/voltage) — those are per-part attestation
+ data captured on the cert, not a reason to split the batch.
+ Returns None for a missing recipe.
+ """
+ if not recipe:
+ return None
+ Node = self.env['fusion.plating.process.node']
+ kids = Node.search(
+ [('id', 'child_of', recipe.id),
+ ('node_type', 'in', ('sub_process', 'operation', 'step'))],
+ order='parent_path, sequence')
+ return tuple(
+ (k.node_type,
+ (k.kind_id.code if k.kind_id else '') or '',
+ (k.name or '').strip().lower())
+ for k in kids)
+
+def _fp_line_express_signature(self, line):
+ """Per-line Express override flags that change physical processing
+ (masking on/off, bake setpoint/duration, etc.). Lines that differ
+ here must NOT merge even when the recipe structure matches, or the
+ shared WO would silently drop one part's masking/bake.
+
+ The exact field set is enumerated from sale.order.line's Express
+ Orders fields at implementation time (x_fc_masking_enabled + the
+ bake override fields); all reads are field-guarded.
+ """
+ F = line._fields
+ bits = []
+ for fname in self._FP_EXPRESS_OVERRIDE_FIELDS:
+ if fname in F:
+ bits.append((fname, line[fname]))
+ return tuple(bits)
+
+def _fp_line_group_key(self, line):
+ recipe = self._fp_resolve_recipe_for_line(line)
+ if not recipe:
+ return ('no_recipe', line.id) # never merges
+ return ('recipe',
+ self._fp_recipe_signature(recipe),
+ self._fp_line_express_signature(line))
+```
+
+The grouping loop becomes:
+
+```python
+groups = {}
+for line in plating_lines:
+ key = self._fp_line_group_key(line)
+ groups[key] = groups.get(key, self.env['sale.order.line']) | line
+```
+
+Everything downstream of `groups` is unchanged: `ordered_keys` still
+sorts by min line sequence, `n_groups` still drives single-vs-suffixed
+WO naming (`WO-` vs `WO--NN`), and the per-group job
+create loop already sums qty, carries `sale_order_line_ids`, and copies
+SO header fields.
+
+**Representative recipe:** the WO's `recipe_id` is the first line's
+recipe in the group. Because every recipe in the group is structurally
+identical, step generation (`fp.job.action_confirm` →
+`_generate_steps_from_recipe`) produces the correct steps for all parts.
+
+**Job singular fields:** `part_catalog_id` / `customer_spec_id` keep
+pointing at the first line's values (display + back-compat). The
+per-part truth lives in `sale_order_line_ids` and the cert part-lines.
+
+#### Change 2 — `fp.certificate.part` (new child model)
+
+File: `fusion_plating_certificates/models/fp_certificate_part.py` (new).
+
+```python
+class FpCertificatePart(models.Model):
+ _name = 'fp.certificate.part'
+ _description = 'Certificate Part Line'
+ _order = 'certificate_id, sequence, id'
+
+ certificate_id = fields.Many2one(
+ 'fp.certificate', required=True, ondelete='cascade', index=True)
+ sequence = fields.Integer(default=10)
+ sale_order_line_id = fields.Many2one('sale.order.line') # traceability
+ part_catalog_id = fields.Many2one('fp.part.catalog')
+ part_number = fields.Char() # snapshot
+ part_name = fields.Char() # snapshot of catalog .name
+ description = fields.Char() # customer-facing description snapshot
+ serial = fields.Char() # comma-joined serial names snapshot
+ customer_spec_id = fields.Many2one('fusion.plating.customer.spec')
+ spec_reference = fields.Char() # snapshot 'CODE Rev X'
+ quantity_shipped = fields.Integer()
+ nc_quantity = fields.Integer()
+ # Phase 2: thickness_reading_ids (inverse certificate_part_id)
+```
+
+On `fp.certificate`:
+
+```python
+part_line_ids = fields.One2many(
+ 'fp.certificate.part', 'certificate_id', string='Parts')
+```
+
+Views: add an editable `part_line_ids` list to the certificate form
+(so the issuer can review/adjust before issuing). ACL rows for
+`fp.certificate.part` mirror `fp.certificate`'s groups (operator read +
+manager write, matching the existing cert ACL).
+
+#### Change 3 — `_fp_create_certificates` fills part-lines
+
+File: `fusion_plating_jobs/models/fp_job.py` (method around line 2716).
+
+- **Requirement union** — `_resolve_required_cert_types` currently reads
+ the *first* part's `certificate_requirement`. Walk **all** plating
+ lines on the job; union each part's wanted set (part-level override
+ else partner inherit). Recipe suppression + CoC/thickness bundling are
+ unchanged (uniform — one recipe per WO).
+- **Cert create** — still one cert per resulting type. Cert-level fields
+ (po_number, customer_job_no, process_description = base recipe name,
+ certified_by_id, contact, entech_wo_number, sale_order_id, x_fc_job_id)
+ unchanged. **Legacy singular fields** (part_number, spec_reference,
+ quantity_shipped, nc_quantity) keep being set from the **first** line
+ for back-compat.
+- **Part-lines** — build one `fp.certificate.part` per plating line on
+ the job (`_fp_cert_source_lines()` = `sale_order_line_ids` filtered to
+ lines with a part):
+
+```python
+seq = 10
+part_cmds = []
+for sol in self._fp_cert_source_lines():
+ part = sol.x_fc_part_catalog_id
+ spec = sol.x_fc_customer_spec_id if 'x_fc_customer_spec_id' in sol._fields else False
+ part_cmds.append((0, 0, {
+ 'sequence': seq,
+ 'sale_order_line_id': sol.id,
+ 'part_catalog_id': part.id if part else False,
+ 'part_number': (part.part_number if part else '') or '',
+ 'part_name': (part.name if part else '') or '',
+ 'description': sol.fp_customer_description()
+ if hasattr(sol, 'fp_customer_description') else (sol.name or ''),
+ 'serial': ', '.join(sol.x_fc_serial_ids.mapped('name'))
+ if 'x_fc_serial_ids' in sol._fields else '',
+ 'customer_spec_id': spec.id if spec else False,
+ 'spec_reference': self._fp_format_spec_ref(spec),
+ 'quantity_shipped': int(sol.product_uom_qty or 0),
+ 'nc_quantity': 0,
+ }))
+ seq += 10
+vals['part_line_ids'] = part_cmds
+```
+
+**Per-part quantities:** `quantity_shipped` defaults to the **line**
+qty (naturally per-part). `nc_quantity` defaults to **0** — scrap /
+visual rejects are tracked at job level only, not per part, so we do not
+auto-split them; the issuer edits per-part NC at issue if needed. The
+job-level NC total remains on the cert's legacy `nc_quantity` field.
+
+**Idempotency:** the existing per-type idempotency guard is unchanged;
+re-running `_fp_create_certificates` does not duplicate certs or lines.
+
+#### Change 4 — CoC report renders the parts table as a loop
+
+File: `fusion_plating_reports/report/report_coc.xml` (tbody at line
+297-321).
+
+```xml
+
+
+
+ |
+
+
+
+ |
+
+
+
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+ ... existing _fp_resolve_part_identity() / _fp_resolve_customer_facing_description() row ...
+
+
+```
+
+Process / PO / Customer-Job columns: PO and Customer Job No. are SO-level
+(uniform), kept cert-level. The Process column shows each part's own
+customer-facing description + spec_reference (per 2026-05-28 policy).
+`page-break-inside: avoid` stays on each `` (per CLAUDE.md) so a part
+row never splits across a page.
+
+#### Change 5 — Traveller lists all parts
+
+File: `fusion_plating_jobs/report/report_fp_job_traveller.xml`.
+
+The Item Information block today shows one part (`job.part_catalog_id`).
+Loop `job.sale_order_line_ids` (plating lines) so the operator sees every
+part in the batch with its qty. The routing/process table is unchanged
+(one shared recipe). Field reads stay defensively guarded.
+
+#### Change 6 — Migration backfill
+
+File: `fusion_plating_certificates/migrations//post-migrate.py`.
+
+For each existing `fp.certificate` with no `part_line_ids`, create one
+part-line from its current singular fields so old certs render
+identically:
+
+```python
+for cert in env['fp.certificate'].search([]):
+ if cert.part_line_ids:
+ continue
+ pid = cert._fp_resolve_part_identity() # (number, name, serials)
+ env['fp.certificate.part'].create({
+ 'certificate_id': cert.id, 'sequence': 10,
+ 'part_catalog_id': (cert.x_fc_job_id.part_catalog_id.id
+ if cert.x_fc_job_id and cert.x_fc_job_id.part_catalog_id else False),
+ 'part_number': cert.part_number or (pid[0] or ''),
+ 'part_name': pid[1] or '',
+ 'description': cert._fp_resolve_customer_facing_description() or cert.process_description or '',
+ 'serial': pid[2] or '',
+ 'customer_spec_id': cert.customer_spec_id.id if cert.customer_spec_id else False,
+ 'spec_reference': cert.spec_reference or '',
+ 'quantity_shipped': cert.quantity_shipped or 0,
+ 'nc_quantity': cert.nc_quantity or 0,
+ })
+```
+
+Idempotent (skips certs that already have part-lines). 13 certs → 13
+single-part certs.
+
+### Phase 2 — per-part refinement (separate plan)
+
+- **Per-part thickness:** add `certificate_part_id` to
+ `fp.thickness.reading`; associate readings + page-2 Fischerscope PDF
+ merges per part; render a per-part thickness block under each part row;
+ extend the `action_issue` thickness gate to require data on each part
+ that needs thickness.
+- **Per-part box stickers:** today's consolidated "Multiple Line Items"
+ sticker gains per-part detail / per-part labels.
+- **Cert form polish:** richer part-line editing UX.
+
+Phase 2 is deferred and gets its own spec + plan once Phase 1 is live and
+validated on entech.
+
+## Files touched (Phase 1)
+
+| # | File | Change |
+|---|------|--------|
+| 1 | `fusion_plating_jobs/models/sale_order.py` | New `_fp_recipe_signature` / `_fp_line_express_signature` / `_fp_line_group_key`; rewrite the `groups` key; define `_FP_EXPRESS_OVERRIDE_FIELDS`. |
+| 2 | `fusion_plating_certificates/models/fp_certificate_part.py` | New model. |
+| 3 | `fusion_plating_certificates/models/fp_certificate.py` | `part_line_ids` O2M. |
+| 4 | `fusion_plating_certificates/models/__init__.py` | import new model. |
+| 5 | `fusion_plating_certificates/security/ir.model.access.csv` | ACL for `fp.certificate.part`. |
+| 6 | `fusion_plating_certificates/views/fp_certificate_views.xml` | Part-lines list on the cert form. |
+| 7 | `fusion_plating_jobs/models/fp_job.py` | `_resolve_required_cert_types` union over all parts; `_fp_cert_source_lines`; `_fp_format_spec_ref`; part-line build in `_fp_create_certificates`. |
+| 8 | `fusion_plating_reports/report/report_coc.xml` | tbody loop over `part_line_ids` + legacy fallback row. |
+| 9 | `fusion_plating_jobs/report/report_fp_job_traveller.xml` | Item Information loops all parts. |
+| 10 | `fusion_plating_certificates/migrations//post-migrate.py` | Backfill one part-line per existing cert. |
+| 11 | `__manifest__.py` × (jobs, certificates, reports) | Version bumps. |
+
+## Migration
+
+- New `fp.certificate.part` table created on install/upgrade.
+- Post-migrate backfills the 13 existing certs (idempotent).
+- Existing jobs/WOs untouched — `_fp_auto_create_job`'s `if existing:
+ return` guard means only **new** confirmations regroup.
+- No re-grouping tool for open orders in Phase 1 (out of scope; can be a
+ one-off odoo-shell script later if the shop wants it).
+
+## Testing
+
+These modules require Enterprise deps and **cannot install on the local
+Community box** (`fusion_plating` shows `installed=0` on `modsdev`), so:
+
+- **Static checks (local):** `pyflakes` on every changed `.py`; lxml
+ parse on changed XML; `node --check` not needed (no JS).
+- **Unit (where installable):** the grouping helpers are pure functions
+ of a recipe/line — `_fp_recipe_signature` returns equal tuples for two
+ structurally-identical recipes and unequal for divergent ones;
+ `_fp_line_group_key` merges same-structure lines and splits on
+ differing express overrides.
+- **Live verification (entech via odoo shell, read-only first):**
+ 1. Re-run the audit signature on SO-30083/30079/30071/30092 →
+ confirm each collapses to 1 group.
+ 2. On a **clone** (or a fresh test SO), confirm SO with 4 same-process
+ lines → 1 WO carrying 4 `sale_order_line_ids`; SO with 2 different
+ processes → 2 WOs.
+ 3. Confirm `_fp_create_certificates` produces one CoC with 4
+ part-lines; render the CoC PDF → 4 part rows, correct per-part
+ part#/serial/spec/qty.
+ 4. Render an existing (migrated) single-part cert → identical to
+ before.
+ 5. A line with masking ON + a line with masking OFF, same recipe →
+ **2** WOs (express-signature split).
+
+## Edge cases & open questions
+
+| Item | Decision |
+|------|----------|
+| No-recipe lines | Each its own WO (unchanged). |
+| Same recipe structure, different express masking/bake | **Split** (express signature in the key). |
+| Repeated same part across lines (SO-30079) | One cert part-line **per line** (not per distinct part) — each carries that line's serial/qty. |
+| Part with `certificate_requirement='none'` on a WO whose other part needs a CoC | Combined CoC is produced (union) and **lists all shipped parts** — the cert documents the physical shipment. (Confirmed 2026-06-03.) |
+| Per-part NC qty | Default 0 (job-level scrap not split per part); editable at issue. (Confirmed 2026-06-03.) |
+| Job `part_catalog_id` when multi-part | First line (display/back-compat). |
+| WO naming | `WO-` (1 group) / `WO--NN` (N groups) — unchanged. |
+| Existing open multi-line SOs already split into WOs | Left as-is; no auto re-group. |
+
+**Confirmed during review (2026-06-03):** the union-cert "list all
+shipped parts even if one part opted out" behaviour, and the "per-part
+NC defaults to 0, editable at issue" behaviour are both approved.