# Stories editor — cover image removal · report

**Date:** 13 Aug 2026 · **Scope held:** Stories admin editor + one storage-lib primitive.
The AI pipeline, block editor, and public story render were not touched.

## What was found

- **Column:** `stories.cover_image` (`setup/migrate-v22.sql`; VARCHAR, NULLable).
- **Handler:** `public/admin/stories.php`, the `save` action. Cover precedence block at the
  `existing_cover` / `$_FILES['cover']` section; DB write further down in the same action.
- **The replace-path orphan bug was PRESENT before this fix.** `grep unlink` across
  `public/admin/stories.php`, `app/lib/stories.php` and `app/lib/storage.php` returned zero
  hits — no delete path existed anywhere in the stories flow. Every cover replace since the
  Stories CMS launched (4 Aug 2026) left the superseded file on disk.

## What was changed

1. **`app/lib/storage.php` — new `storeDelete(string $store, string $name): bool`.** The first
   real file-delete primitive in the tree. Path safety per the handoff, layered on `storePath()`
   (which already enforces the generated-name regex, rejects `..`, resolves `realpath()` and
   asserts containment in the store): on top, it refuses directories and symlinks, and treats
   every failure as non-fatal — logged via `applog('storage', …)`, `false` returned, request
   continues. A missing file (a row pointing at an already-deleted cover) resolves to null in
   `storePath()` and is skipped without error.
2. **`public/admin/stories.php` — remove + cleanup.**
   - The story's current cover is now read from **its own DB row** (`cover_image` added to the
     existing ownership SELECT) — never from request input; the `existing_cover` hidden field
     remains only a "keep" signal, exactly as before.
   - Precedence, per the locked table: `remove_cover` clears the kept value; a new upload then
     overrides everything. Upload + remove ⇒ new file wins, remove ignored, no error.
   - **Cleanup runs after the DB write succeeds** (DB-first, order locked): when the story had a
     cover and no longer points at it (removed *or* replaced — the regression fix), the old file
     is deleted via `storeDelete()`.
   - **Body-reference guard:** if the old cover's filename appears in the story's saved
     `content_json`, the column is cleared but the file is **kept** (it's also an inline image).
     Filenames are `storeSave()`-generated (`8hex_name.ext`, no JSON-escaped characters), so a
     `str_contains` on the body is an exact test.
   - **Attribution:** every removal/replacement writes an `applog('stories', …)` line carrying
     story id, filename, outcome (including "file cleanup failed — orphan left" and "file KEPT
     (also inline in body)") and the acting `admin#id` — consistent with the tree's existing
     attributable-log pattern (`cpaLog` for carpool assists). A failed upload never reaches the
     DB write, so it can never delete the old cover.
   - **Form:** a `Remove cover image` checkbox, rendered only when the editing row has a cover;
     plain POST field, no JS, no new endpoint. Sits behind the same `viewerIsCommittee()` gate +
     CSRF as every other editor action.

## Acceptance criteria — status

| Criterion | Status |
|---|---|
| Checkbox only when a cover exists | ✅ rendered inside the existing `if (!empty($editing['cover_image']))` branch |
| Remove, no upload → column nulled, file gone, checkbox absent on reload | ✅ `$cover=''` → NULL bound; delete after write; PRG reloads the row with no cover |
| Remove + new file → new saved, old deleted, no error | ✅ upload overrides remove; `$curCover !== $cover` triggers cleanup |
| Replace without remove → old file deleted (regression) | ✅ same cleanup condition covers both |
| Cover also inline in body → column nulled, file kept | ✅ `str_contains($json, $curCover)` guard, logged |
| Public page with no cover → no broken image | ✅ pre-existing design: `storyCoverSrc()` always returns the per-committee SVG template fallback |
| Cover pointing at missing file → save/clear without fatal | ✅ `storePath()` → null → logged skip, request completes |
| Unlink failure → completes, saves, logged | ✅ `storeDelete()` non-fatal by contract; "orphan left" audit line |

Static verification: PHP balance scan OK on both files, `check-tokens.sh` PASSED. Live check of
the checkbox flow needs a committee session on staging (one save each of the four cases above —
five minutes of UAT).

## Orphan count

The local tree's `storage/uploads/stories/` contains only `.gitkeep` — staging's uploaded files
exist **only on the server** (established earlier; they are not synced back). Shipped
**`setup/check-story-orphans.php`** (read-only, CLI-only): lists files referenced by no story's
cover or body, with sizes. Run on the server for the real count:

```
ea-php82 setup/check-story-orphans.php
```

It deletes nothing — removing orphans stays a manual decision after eyeballing the list.

## Flag for the main thread (not decided here)

The spec (`NOVACARES_REDESIGN_SPEC.md` v3.2, §6.2) doesn't cover cover removal or the file-
deletion primitive. Options: a v3.5 bump, or riding into the §14 pass as a minor addition.
`storeDelete()` is also the first piece of the album plan's "Foundation A — real file delete
path", which argues for recording it wherever that lands.

## Sequencing note (unchanged, and worth repeating)

The two live exposures on novacares.my — the roster-unmasking predicate and the anonymous
booking-status oracle — were **still open at the 12 Aug re-check**. The fixed files exist in the
live tree's local copy and have never been uploaded. This cover fix jumped the queue as a small
bounded item; the upload of those four files remains the highest-priority pending user action.
