# `program_targets` — add `isDelete` guards to mastery paths and bulk update

**Filed:** Batch 56, 2026-08-02.
**Status:** deferred to post-demo. No known live impact today.

## Problem

Several ABA-module reads of `program_targets` don't filter on
`isDelete`. Most are intentional (see "Safe by design" below). Four
are wrong and should be fixed: the mastery-evaluation path can read
and write to a soft-deleted target, and a program-level bulk UPDATE
touches soft-deleted rows.

None causes user-visible harm today because the only soft-deleted
target (`id=64 "test 5"`, filed Batch 56 §4) has no sessions and no
mastery activity. But the pattern is the one that would let a
future test artifact silently influence real mastery evaluations.

## Sites to fix

**Add `->where('isDelete', 0)` (or `->where('pt.isDelete', 0)` on the
joined alias) at each of these:**

| Site | What it does | Why it's wrong |
|------|--------------|----------------|
| [SessionDataController.php:329](../../app/Http/Controllers/SessionDataController.php#L329) | `checkMasteryCriteria` — fetches target by id | Runs mastery evaluation logic against a soft-deleted target. |
| [SessionDataController.php:384](../../app/Http/Controllers/SessionDataController.php#L384) | UPDATE `status` on target by id | Flips phase on a soft-deleted target as a side effect of mastery evaluation. |
| [SessionDataController.php:1161](../../app/Http/Controllers/SessionDataController.php#L1161) | Mastery recompute — reads prev status | Same read pattern as #329, invoked from the recompute entry point. |
| [SessionDataController.php:1163](../../app/Http/Controllers/SessionDataController.php#L1163) | Mastery recompute — reads new status | Same as #1161. |
| [ProgramController.php:323](../../app/Http/Controllers/ProgramController.php#L323) | UPDATE on `programs.id = ?` (all program's targets) | Blanket update; touches soft-deleted rows too. Not always wrong (some fields are intentionally reset for archival), but should be an explicit decision, not an omission. Verify per field before adding the filter. |

Suggested fix shape:

```php
DB::table('program_targets')
    ->where('id', $targetId)
    ->where('isDelete', 0)       // ← add
    ->first();
```

For the bulk update at 323 — read the surrounding context first. If
the update legitimately needs to include soft-deleted rows (e.g. it's
part of a cascade), leave it and add a comment explaining why. If
not, add the filter.

## Safe by design — do NOT "fix" these

These sites read `program_targets` without an `isDelete` filter
intentionally. Fixing them would break historical display or the
authz contract.

| Site | Why it's fine |
|------|---------------|
| [SessionDataController.php:46](../../app/Http/Controllers/SessionDataController.php#L46) | LEFT JOIN in the session list. Keeps target name visible in old sessions even after the target is deleted. |
| [SessionDataController.php:112](../../app/Http/Controllers/SessionDataController.php#L112) | LEFT JOIN in the review-queue list. Same reasoning. |
| [SessionDataController.php:497](../../app/Http/Controllers/SessionDataController.php#L497) | LEFT JOIN in session show. Same reasoning. |
| [SessionDataController.php:420](../../app/Http/Controllers/SessionDataController.php#L420) | `value('programId')` from a target id, used for authz. `programId` doesn't change when a target is soft-deleted; the authz check is still valid. |
| [ProgramTargetController.php:145,317](../../app/Http/Controllers/ProgramTargetController.php#L145) | Post-mutation reads — the caller just INSERT/UPDATE'd the row by id, so `isDelete` is definitionally 0. |

**Principle**: a session's history includes what it was recording,
even if that target is later deleted. Stripping the join would render
empty labels on historical rows. The rule "always filter `isDelete`"
applies to *product surfaces* (pickers, dashboards, active-record
lists) — not to *historical reads*.

## Related

- Batch 56 §4 — soft-deleted `program_targets.id=64` (the artifact
  that triggered this audit).
- `docs/post-demo/programs-authorname-column-drop.md` — same batch,
  same "one bug spawns an audit" pattern.
