# Events · Slot Posters (Manage)

**Portal:** Manage · **Routes:** `manage.events.series.posters.*` · **Nav:** no new sidebar entry / no new page — this feature adds a **Posters** row action to the existing **Slots** tab on the funnel hub (see [Events · Funnels](/docs/modules_handbook/manage/events/funnels/readMe.md)), which opens the `SlotPosterModal`. Per GUIDELINES §15, a modal on an existing tab needs no sidebar entry and no tab-strip change.

> Sibling doc: this module is grouped under the shared `manage/events/` parent alongside [Funnels](/docs/modules_handbook/manage/events/funnels/readMe.md) and [Funnel WhatsApp](/docs/modules_handbook/manage/events/funnel-whatsapp/readMe.md). Cross-linked from [AI](/docs/modules_handbook/shared/ai/readMe.md) (a new non-`AiClient` `ai_requests` producer) and [Media](/docs/modules_handbook/shared/media/readMe.md) (a new consumer, collection `slot_poster`).

## What it does
Gives each **slot** (`Src\Event\EventSeries`) a **poster folder**: an admin uploads a poster image directly, or **generates** one with OpenAI's image API, **`@`-referencing** earlier posters on the same slot to iterate ("keep the first one's palette, the second one's layout") until one is good. One poster per slot is flagged **final** — it becomes the slot landing's hero image and its public `og:image` (see [Funnels](/docs/modules_handbook/manage/events/funnels/readMe.md) → *Public landing*). Every generation attempt — success or failure — is logged to `ai_requests`.

Built in 7 phases (0: provider spike · 1: schema/models · 2: OpenAI client + logging · 3: queued job · 4: controller/routes/validation · 5: poster modal UI · 6: landing wiring · 7: this doc + tests). The original plan document and the provider spike scripts are **developer-local and deliberately not in the repo** — the spikes make real billable OpenAI calls, so they are not something to have lying around runnable. **This handbook is the authoritative record**; every measurement the plan produced is reproduced inline below or in the `config/services.php` comments.

## How it works

### The model — `SlotPoster` (+ `slot_poster_references`)
- **`Src\Event\SlotPoster`** — a §7 standard key model (`SoftDeleteModel` + `HasUuid` + `RecordsBlame`): `event_series_id` (the owning slot, no schema-level FK), `media_id` (nullable — **null while a generation is still in flight**), `source` (`SOURCE_UPLOAD` / `SOURCE_GENERATED`), `status` (`STATUS_PENDING` / `PROCESSING` / `READY` / `FAILED`), **`version`** — a permanently unique **internal** id, NOT the `#N` an admin sees (see *Numbering* below) — `prompt`, `ai_request_id`, `error`, `is_final` (at most one `true` per slot), `is_favorite` (the admin shortlist — **many** per slot; see *`is_favorite` vs `is_final`* below), `meta`. Relations: `slot()`, `media()` (belongsTo `Src\Common\Media` — the image is attached to the **slot**, not the poster row, via `MediaService::store($slot, ...)`; `SlotPoster::media()` is a plain `belongsTo` FK lookup, while `EventSeries::media()` is the actual `morphMany`), `aiRequest()`, and the self-referencing `references()` / `derivatives()` ordered lineage through the pivot.
- **`slot_poster_references`** — an ordered pivot (`slot_poster_id`, `reference_poster_id`, `position`). Order is meaningful: Phase 0 confirmed `gpt-image-2` honours per-reference positional instructions ("the palette of the FIRST, the layout of the SECOND"), so `@Poster2` really does mean reference slot 2.
- **The `(event_series_id, version)` unique index is load-bearing, not decorative.** `SlotPoster::nextVersion()`'s `lockForUpdate()` (in `SlotPosterRepository::create()`) covers a slot that already has posters, but a `SELECT … FOR UPDATE` matching **zero** rows only takes a gap lock — so a slot's very **first** two concurrent generations can both compute `version = 1`. The unique index turns that into a duplicate-key error the repository catches and retries once with a recomputed version (`tests/Unit/Event/SlotPosterTest.php` proves this for both an empty slot and a slot with existing posters).

### Numbering — `#N` is POSITIONAL, and `version` is not it
**Decided 2026-07-31. Two different numbers, and conflating them is the mistake this section exists to prevent.**

| | `version` (column) | `number` / `displayNumber()` |
|---|---|---|
| What it is | permanently unique **internal** id, monotonic, `withTrashed()` | 1-based **position** among the slot's live posters, ordered by `version` |
| Who sees it | nobody — debugging only | every `#N` badge, every `@PosterN` token, every `flash()` message |
| On delete | reserved forever | the gap **closes immediately**; later posters shift down one |
| Why it is that way | makes `(event_series_id, version)` a usable concurrency guarantee, and lets a row keep one identity across its own soft delete | the sequence must always read 1..N from #1, with no hole at any moment |

`SlotPostersController::index()` numbers the already-ordered collection **by offset** and ships it as `number`; `SlotPoster::displayNumber()` (a `WHERE version <= ?` count) serves the single-poster paths — the `flash()` messages — where there is no collection to count against. On `destroy` it is resolved **before** the delete, or the count would name whichever poster has taken the deleted one's place. The client reads `number` and nothing else.

> **The cost, stated plainly.** A positional number is a view of the slot's *current* contents, not an identity — so deleting a poster shifts every later poster down one, and a `@PosterN` already written into an **older poster's stored `prompt`** can end up naming a different image. Only the *label* drifts: what a past generation actually SENT is recorded in `slot_poster_references` keyed on `id`, `GenerateSlotPosterJob::resolveReferences()` walks that pivot, and every live reference the composer submits travels as a **uuid**. This was accepted knowingly — the alternative (reserving numbers) leaves a visible gap in the grid until something backfills it, which is what the change set out to remove.
- **`EventSeries`** gained `posters(): HasMany`, `media(): MorphMany` (new — slots didn't have a `Media` relation before), and `finalPoster(): ?SlotPoster` (defensively re-filters `status = READY && media_id IS NOT NULL`, mirroring `EventFunnel::ogBanner()`).

### Generation — `OpenAiImageClient` + `SlotPosterGenerator` + `GenerateSlotPosterJob`
A **brand-new** client (`App\Helpers\OpenAiImageClient`) — `config/ai.php`'s OpenAI wiring is chat-only, and the only existing `/images/generations` caller is the unrelated `SeedreamClient` (AI Video), which this feature does **not** touch. Two real endpoints, branched on whether `@`-references exist:

| References | Endpoint | Body |
|---|---|---|
| none (a slot's poster `#1`) | `POST /v1/images/generations` | JSON |
| one or more | `POST /v1/images/edits` | **multipart**, repeated **`image[]`** field (not `images` — the API's own rejection message names it) |

**Streaming is mandatory, not an optimisation.** There is a ~60s **idle-connection** cutoff on this network that kills any non-streamed generation above `quality:low` — the connection sits silent for the whole render. Sending `stream: true` (+ `partial_images`) makes OpenAI emit `*.partial_image` frames as it works, so the connection never idles. `OpenAiImageClient` therefore consumes an SSE stream, not one JSON body.

> **Two things about the stream were wrong in the plan until measured live (2026-07-30). Both cost a fully-generated, fully-billed image that was then discarded. Do not "simplify" either back.** Both were established by capturing the raw SSE event stream off real renders on each endpoint; the findings are reproduced in full here and in `OpenAiImageClient`'s docblock, because the capture script itself is developer-local (it bills).
>
> 1. **Terminal event names are per-endpoint.** `/images/generations` emits `image_generation.completed`; `/images/edits` — the branch **every `@`-referenced iteration** takes — emits **`image_edit.completed`** (and `image_edit.partial_image`). The client matches the **`.completed` suffix** rather than any single literal name, so both branches work and a third would too.
> 2. **It is the IDLE GAP that kills, not total duration — so frame COUNT is the safety margin, and `partial_images` must be 3.** Frames are spread across the render, so more frames = smaller gaps. Measured on `edits` at `quality:high` with 2 references:
>
> | `partial_images` | headers → first frame | outcome |
> |---|---|---|
> | 2 | 24.9s → 85.5s = **60.6s** | connection killed; image billed and lost |
> | **3** | 30.7s → 72.6s = **41.9s** | frames at 72.6 / 116.0 / 163.5s, **completed at 164s** ✅ |
>
> Phase 0's note that "2 is the floor" came from the `generations` branch, where the first frame lands much earlier relative to the header. It does not hold on `edits`.

A 170.8s `quality:high` referenced render is proven end-to-end on the multipart branch, and that run also re-confirmed positional addressing there (palette of the first reference, composition of the second) with correct CJK typography. **Pinned model: `gpt-image-2`** (`config/services.php → openai_image`, never `-mini` — it mangles poster text, CJK included) at `quality: medium` (~54s, chosen over `high`'s 152s for the iterate-until-final loop). The HTTP timeout budget nests **240s (client) < 300s (`GenerateSlotPosterJob::$timeout`, declared explicitly — it does not extend `App\Jobs\Ai\AiJob`) < 320s (Horizon `supervisor-ai`) < 390s (`redis-ai` `retry_after`)**, so a hung call is cut by the client, then failed by the job, and can never be re-delivered mid-run and double-billed.

`Src\Event\Services\SlotPosterGenerator` is the one place the provider call and the `ai_requests` log are wrapped together — two-phase (`AiRequestRepository::start()` opens a `processing` row the instant the call is submitted, `complete()` settles it), the lifecycle pattern borrowed from `AiClient::begin()`/`finish()`; the *placement* precedent (logging a **non-`AiClient`** path) is `LoggingTranscriber`. It composes the final prompt (registered art-direction default + the admin's sanitised, quoted text — same injection defence as `GeneratePresenterPortraitJob::portraitPrompt()`), and enforces two hard rules: **(a) image bytes never reach the log** — a returned inline base64 image is replaced with a `[image omitted — N KB]` placeholder in `response_raw` (the *new* hazard vs. a JSON-body provider: OpenAI can return the generated image inline), and reference bytes never appear in the logged `request` at all, only the referenced posters' **uuids**; **(b) the API key never touches the log** — stored as `[redacted]`. Logs under the **existing** `AiCredential::PROVIDER_OPENAI` slug ("OpenAI GPT") — no new provider constant, catalog entry, or validator arm was needed. Cost is computed **text/image/output token-aware** (`config('ai.pricing.openai.gpt-image-2')` → `input` / `image_input` / `output`, USD per 1M) because reference images dominate the input both in volume and in rate (Phase 0: two references were 98% of one call's input tokens, and image input bills at $8/1M against text's $5) — collapsing to a flat `input` rate would systematically undercount exactly the calls this feature makes most.

`App\Jobs\Event\GenerateSlotPosterJob` (queued `redis-ai`/`ai`, modelled on `GeneratePresenterPortraitJob`) resolves each `@`-referenced poster's image bytes from GCS in **pivot `position` order**, downscales them (`ImageDownscaler::toJpeg()`, falling back to the original bytes), calls `SlotPosterGenerator::generate()`, stores the result via `MediaService::store($poster->slot, $bytes, ['collection' => SlotPoster::COLLECTION, 'mime' => 'image/png'])` (always PNG — every image `gpt-image-2` returned in Phase 0 was one), and flips the poster to `ready`. Any `Throwable` → `markFailed()` + the reason surfaced on the poster's card with a **Retry** button; a poster is never left stuck in `processing` (both the `catch` block and the job's own `failed()` hook, for the case the job is killed by its own timeout, guard this). `attachAiRequest()` runs on **both** outcomes — a failed poster needs its `ai_requests` link at least as much as a successful one.

### HTTP surface — `SlotPostersController`
Seven thin methods under `manage.events.series.posters.*`, nested inside the existing `series` prefix group:

| Method | Route | Notes |
|---|---|---|
| `index` | `GET {id}/posters` | **JSON only** — the modal's lazy fetch + ~5s poll while anything is pending/processing. **`reorder('version')`, oldest first** — `EventSeries::posters()` ships its own `orderByDesc('version')`, so a plain `orderBy` would only be a secondary sort and `#1` would land on the NEWEST poster. Each row carries its positional `number` (see *Numbering*). View-level (no `manage-events` gate, matching the `series` index). |
| `upload` | `POST {id}/posters/upload` | Direct upload → immediately `ready`. |
| `store` | `POST {id}/posters` | Generate: creates the `pending` row (+ ordered references) and dispatches `GenerateSlotPosterJob`. |
| `markFinal` | `POST {id}/posters/{posterId}/final` | Only a `READY` poster with `media_id !== null` may become final (checked in the controller AND, race-safe, under the repository's lock). |
| `toggleFavorite` | `POST {id}/posters/{posterId}/favorite` | Star/unstar for the modal's pinned row. `READY`-only for the same reason (a pinned row of spinners helps nobody), but **many per slot** and unlocked — see *`is_favorite` vs `is_final`* below. |
| `retry` | `POST {id}/posters/{posterId}/retry` | Only a `FAILED` **generated** poster — reuses the SAME row (same `#N` + ordered references), re-dispatches the job. Anything else is rejected with `flash()->error()` + `back()` (a plain 302, **not** a 422 JSON response). |
| `destroy` | `DELETE {id}/posters/{posterId}` | See *Delete semantics* below. |

**⚠️ Every write route carries its OWN `permission:manage-events` middleware** — the `series` group (and its nested `posters` sub-group) has **no** group-level permission middleware, only `view-events` at the outer `events` prefix. A route added without its own `permission:manage-events` would be reachable by any signed-in manage user who can merely *view* events. `tests/Feature/Events/SlotPostersControllerTest.php` asserts this **per route**, using a role holding only `view-events` (not `Role::ADMIN`, which this app's `RolesSeeder` pre-grants nearly every permission to — a trap that would make every gate silently pass).

`StoreRequest` scopes `references.*` to **this slot's own `READY` posters** (`Rule::exists` alone would let an admin `@`-reference another slot's poster) and caps the count at 4 (Phase 0 hit a server-side processing timeout on large reference sets, and each reference adds ~1,500 **image** input tokens — 4.6× what the same pair costs on `-mini` — billed at the higher `image_input` rate, so the cap is a cost control as much as a latency one). `UploadRequest` validates the image HTTP-boundary rules (`MediaService::storeUpload()` deliberately does not validate uploads) — generic `image` rule + `max:8192` KB + a `600×600` minimum-size floor (a poster has no single fixed aspect ratio, unlike the funnel's OG banner crop).

### Delete semantics — settled during implementation, diverges from the plan's original text
**The plan text described a plain hard delete of the poster row. That changed.** `SlotPosterRepository::delete()` **hard-destroys the GCS object** (`MediaService::delete()`, run **outside** the transaction — a bucket delete cannot be rolled back, log-and-continue on failure) but only **soft-deletes** the poster **row**. A pure hard delete would let the very next `create()` reuse the freed `version`, which has to stay permanently unique for the index above to mean anything; the soft delete also keeps the lineage in `slot_poster_references` resolvable, since a trashed poster may still be legitimately `@`-referenced by another poster's recorded history (`resolveReferences()` reads it `withTrashed()`). None of that is visible to the admin — the `#N` they see is positional, so this delete closes its gap immediately. `media_id`, `is_final` and `is_favorite` are cleared **before** the soft delete (a trashed poster must never point at a destroyed file, nor keep claiming to be the slot's final hero — `markFinal()`'s clear-query runs through the default, non-trashed scope and could never reach an already-trashed row otherwise). Lineage pivot rows (`slot_poster_references`) are deliberately left untouched on either side — a soft-deleted poster may still be legitimately `@`-referenced by another poster's history; a caller resolving a possibly-deleted reference's own label/thumbnail must query `->withTrashed()` explicitly (`GenerateSlotPosterJob::resolveReferences()` does this).

**No cascade exists for the parent slot/funnel, and none is needed.** Both `EventFunnelRepository::delete()` and `EventSeriesRepository::delete()` are plain **soft** deletes — verified, no `forceDelete`/purge path exists anywhere in `src/Event/`. A soft-deleted slot therefore keeps its posters and their `Media` rows fully intact and recoverable (`tests/Feature/Events/SlotPosterDeleteTest.php`); the only residual concern is unbounded storage growth on a permanently-archived slot, not a leak.

### `markFinal()` — locked, not merely transactional
A transaction alone gives atomicity, not mutual exclusion: under MySQL's default `REPEATABLE READ`, two admins clicking *Set as final* on two different posters of one slot concurrently could each clear the other's sibling and set their own, committing **two** finals. `SlotPosterRepository::markFinal()` takes a `lockForUpdate()` over the **whole slot's** posters, in one deterministic `orderBy('id')` (so two concurrent calls can never lock in opposite orders and deadlock), clears any existing final, then sets the target — **clear-then-set**, so the slot is never momentarily final-less to a landing-page read. Critically, it **re-reads the target from the locked set** rather than trusting the caller's own `$poster` instance: a stale in-memory copy whose `is_final` already reads `true` (because *this same admin's own* earlier call set it, before a *different* concurrent call cleared it) would otherwise look "already set" to Eloquent's dirty-tracking and the UPDATE would be silently skipped — leaving the slot with **zero** finals. `tests/Feature/Events/SlotPosterFinalConcurrencyTest.php` pins both the exactly-one-final invariant and this specific stale-instance case.

### `is_favorite` vs `is_final` — two flags, deliberately
`is_final` is a slot-wide, **mutually exclusive, public** decision: it drives the landing hero and `og:image`, so it is locked (see *`markFinal()`* above) and at most one poster per slot holds it. `is_favorite` is a **many-per-slot admin shortlist** that never leaves the Manage surface — no lock, no invariant, no public consumer; `SlotPosterRepository::toggleFavorite()` is an ordinary transactional write whose only guard is the `READY` + `media_id` check. Collapsing the two into one flag would make shortlisting a poster silently republish the slot's public hero, which is why the migration adds a column instead. It lives in the database rather than in `localStorage` because the poster folder is a **team** surface: a shortlist held in one admin's browser would be invisible to the colleague who actually picks the final.

### The poster modal UI
`Pages/Manage/Events/Series/Partials/SlotPosterModal.vue`, opened from a **Posters** row action (`Images` icon) on the funnel hub's existing **Slots** tab (`Funnels/Partials/Tabs/SlotsTab.vue`) — no new page, no new route. Lazy-fetches + polls `GET …/posters` (JSON) every ~5s while anything is pending/processing, paused while the tab is hidden.

**It is a wide `Components/Modal.vue` (`size="full"`), not the original `Drawer`.** A poster folder is a *browsing* surface, and a `max-w-xl` drawer could only ever show two large cards at a time — so an admin a dozen posters in was scrolling past the one they wanted. The layout is now the master-detail shape photo apps use, and three structural rules hold it together:

- **The image is the browsing unit.** `SlotPosterCard.vue` renders a dense thumbnail grid (3–6 columns by density) with the actions in a **hover/focus overlay** rather than a permanent button bar; `focus-within` mirrors every `group-hover` rule so the overlay is keyboard-reachable. What must stay legible *without* hovering stays a pinned badge: the `#N` label (positional — `poster.number`, see *Numbering*), the ★ Final marker, the favourite heart, the reference position, and any generating/failed status.
- **Status drives position.** The final poster and any favourites float into a **Pinned** row above the drafts (final first), and leave the "All posters" grid. Sections appear **only** while browsing everything unfiltered — once a chip or a search has already narrowed the board, a section header would label an empty half.
- **The control bar and the composer never scroll.** The bar lives in Modal's `#header` slot and the composer sits below the grid region, both **outside** the scrolling body — pinned structurally rather than by a `sticky` offset a nested scroll container could break. So `n/4 references` stays readable at any scroll depth. (The `@`-mention menu opens **upward** for the same reason: a downward menu off a bottom-pinned composer would be clipped by the panel.)

The bar carries a **search box** matching a poster's `prompt`, its derived title and its `#N` (`utils/slotPoster.js` — one shared haystack, so a poster an admin *can see* the words of is a poster they can find), **filter chips** (All · ★ Final · Favorites · References — the last counting what the prompt currently addresses), a **density toggle** persisted in `localStorage`, and Upload.

### Poster shape — the ratio picker
An admin picks the poster's shape before generating: **Portrait 2:3 · Square 1:1 · Landscape 4:3 · Wide 16:9**. The table lives in `config/services.php → openai_image.ratios` (with `default_ratio`), NOT in a model CONST, because the pixel sizes are a **provider constraint**: `gpt-image-2` accepts an arbitrary `WIDTHxHEIGHT` but only when **both dimensions are divisible by 16 and the ratio sits between 1:3 and 3:1** — unlike `gpt-image-1`, which allows only `1024x1024` / `1536x1024` / `1024x1536`. Every configured size is exact-ratio **and** /16 (`1024x1536`, `1024x1024`, `1408x1056`, `1536x864`), and the table is config so a model change or a rejected size is an edit, not a deploy.

The chosen slug is stored in `slot_posters.meta['aspect_ratio']` — the column the migration already earmarked for "image size, model, seed" — rather than in a new column, because nothing queries or filters by it. That also means **`retry()` reuses the shape for free**: it never touches `meta`, so a regenerated poster keeps the shape the admin picked. `SlotPosterGenerator` resolves slug → `size` via `SlotPoster::sizeForRatio()` and passes it to the client; a poster with **no** stored ratio (every one created before the picker existed) falls back to the configured default, so its size is exactly what it would have been. `StoreRequest` validates against `array_keys(SlotPoster::ratios())`, and the picker's options ride on the `index` payload so a config edit reaches the UI with no page change.

**The grid had to change with it.** A card now takes its OWN aspect (`posterAspect()` in `utils/slotPoster.js`), preferring the stored image's real `width`/`height` — which is the only thing that works for **uploads**, since those never had a chosen ratio — then the requested ratio's label while a generation is still in flight and there is no file yet, then portrait. The grid carries `items-start`, or a 16:9 card would be stretched to match the portrait one beside it.

### The shared image viewer
The browse-large half is **not owned by this module**: `Components/ImageLightbox.vue` is shared with the WhatsApp / Messenger conversation threads, and its contract — the `images` entry shape, why the **host** owns the list and the index, why it is built on the shared `Modal` — is documented once in **[shared/image-lightbox](/docs/modules_handbook/shared/image-lightbox/readMe.md)**. Read that before changing it; a poster-shaped change to it lands in the inbox too.

What belongs to *this* module is `SlotPosterLightbox.vue`, now only the poster **`#panel`** for that viewer: click any thumbnail for the full-size image plus every action, with **← / →** stepping through *exactly the list the grid is currently showing* (so a filtered board flips through only its matches). The open poster is tracked **by uuid, not index** — a poll can insert a finished poster, and favouriting from inside the lightbox moves it between sections; either would silently swap the poster on screen under index tracking. `SlotPosterModal` therefore translates the viewer's index back to a uuid on every step (`goToLightboxIndex`) rather than storing the number.

**Referencing is a thumbnail action, not a detail-view one** — the whole point of the `n/4` flow is not having to open each poster. The card's Reference button (and the lightbox's) is the *same* operation as picking from the `@` autocomplete: it edits the **prompt text**, and the reference list is derived from that. Clicking a referenced poster again removes its token, so one control covers both directions. That derivation is the load-bearing rule of the composer — the `references` array is **never** a separately-mutated list, so the visible mention tokens are always the actual contract sent to the server: token **order** maps to reference order, a duplicate `@PosterN` collapses to one reference, matching is **case-insensitive** (`@poster1` resolves), and submit is blocked once references exceed the 4-reference cap. `SlotPosterModal.test.js` pins all of that plus the search, the pinned ordering, the chips and the thumbnail reference toggle.

### The final poster on the slot landing
`Main\LandingController::slotPosterMedia()` resolves the slot's `finalPoster()` to its `Media`, which the controller turns into a URL on the existing public `og-image` streaming route (`main.og-image` — private-bucket media can't go straight in a `<meta>` tag; signed URLs expire), preferred over the funnel's banner (`$posterUrl ?? ogImageUrl($funnel)`). It returns the `Media` rather than a bare URL because the page needs the intrinsic `width`/`height` too, and re-resolving `finalPoster()` would cost a second query on every public landing view. Three **optional** props reach `SlotLanding` — `poster` (the URL), `poster_width`, `poster_height` — optional because bespoke per-slot `Landings/{funnel}/{slot}/Index.vue` files predate this feature and must not break. `DefaultSlot.vue` sets the size pair as the hero `<img>`'s `width`/`height` attributes so the browser reserves the right box and the public page doesn't shift as the image loads; it emits them **only as a pair** (`media.width`/`height` are nullable columns, and a lone attribute would constrain the rendered size instead of supplying a ratio) and posters have no fixed aspect ratio, so the ratio must come from the record rather than be hardcoded. **`Main\OgImageController` — the one security-sensitive change.** Its route was `og_banner`-only by design ("uuid lookups never expose other media"); widening it to the `slot_poster` collection additionally requires the media to belong to a poster that is `is_final = true`, `status = READY`, not soft-deleted, **and** whose slot is **not** `VISIBILITY_MEMBERS` — otherwise every draft poster in the private bucket becomes publicly fetchable by uuid the moment it exists, and a members-only slot's poster (which carries the same gated title/date/CTA the landing 404 exists to withhold) would leak through a route that bypasses `LandingController` entirely. Drafts stay private and are viewed in the poster modal through `MediaService::temporaryUrl()`.

**Revocation is immediate at the origin, but the image is cached for 24h.** The route answers with `Cache-Control: public, max-age=86400` and an `ETag` of the media uuid — deliberate, because og:images are re-fetched constantly by Facebook / LinkedIn / WhatsApp scrapers and every hit streams the object out of the private bucket. The consequence: **the guard above revokes access at the origin the instant a poster stops being final, but anything that already fetched that uuid — a browser, a CDN, a social platform's scraper cache — keeps serving the old bytes for up to a day.** Un-finalling a poster (or deleting it) therefore does not un-share an image that has already circulated, and a link preview on a post made yesterday can still show it. This is normal, wanted behaviour for a public share image and should NOT be "fixed" by lowering `max-age` — but it means:

- **When verifying the guard by hand, a plain browser reload proves nothing.** It is answered from disk cache without ever reaching the server, so a revoked poster appears to still load. Use a hard reload (Ctrl+Shift+R), DevTools with *Disable cache*, an Incognito window, or `curl -I` — `tests/Feature/Main/OgImageControllerTest.php` has no cache layer, which is why the suite catches this and a manual pass can miss it.
- **A poster is not a secrecy mechanism.** If artwork must never be publicly visible, don't mark it final in the first place; un-finalling later closes the origin but cannot recall what was already fetched.

**Visibility is not a gate on authoring.** A members-only slot's Posters modal works exactly like a public slot's — upload, generate, iterate, mark final — because that gating is a **public-consumption** concern only: `LandingController::slot()` already 404s a members-only slot before it renders, so its final poster simply never reaches a public `og:image`. No `visibility` check appears anywhere in the repository, controller, or job.

## GUIDELINES alignment
- **§2 Repository** — `SlotPosterRepository`: every write in `DB::transaction()` (the one deliberate exception is `markFinal()`'s in-transaction lock-acquiring read, commented as intentional per §2's own allowance for a lock that must live inside the transaction whose life it bounds), `data_only()` before the transaction, nested `slot_poster` input, refreshed-model returns.
- **§3 Constants** — `SOURCE_*`/`SOURCES`, `STATUS_*`/`STATUSES` on `SlotPoster`, both with UI name+colour metadata.
- **§6 Naming** — routes `manage.events.series.posters.*`; the module's actual controller-authorization pattern (route model binding by **uuid** via `where('uuid', $id)->firstOrFail()`, not `findOrFail($id)`) follows the codebase's established `FunnelsController`/`SeriesController` convention over GUIDELINES §3's literal `findOrFail($id)` text — no controller in this codebase resolves a `HasUuid` model that way.
- **§7 Standard key model** — `SlotPoster extends SoftDeleteModel` + `HasUuid` + `RecordsBlame`; FKs reference `id`, no schema-level FK constraints.
- **§13/§14** — Inertia + Tailwind only; destructive actions go through `ConfirmModal`, never native `confirm()`; downloads use a plain `<a href>` (the one `<Link>` exception).

## Known caveats
- **The `cost` column is priced off published rates, not off an invoice.** `config('ai.pricing.openai.gpt-image-2')` is `input` $5 / `image_input` $8 / `output` $30 per 1M tokens — **verified against OpenAI's published `gpt-image-2` tiers on 2026-07-30**, correcting an earlier transplant of `gpt-image-1`'s rates ($10 / $40), which over-estimated every poster by ~33%. Two things are deliberately not modelled, both of which make the figure a slight **over**-estimate rather than an under: the discounted `cached_input` tiers ($1.25 text / $2.00 image — the usage block does not break cached tokens out) and Batch pricing (50% off; this path never batches). **Open question — the streamed keep-alive frames.** OpenAI's docs state each `partial_images` frame incurs an extra 100 image output tokens, but a controlled A/B on 2026-07-31 (identical prompt and 2 references, `quality:low`, `partial_images` 3 vs 0) returned a **byte-identical `usage` block** both times — 1,426 in / 158 out. So whatever the frames cost, the API does not report it, and `estimateCost()` cannot see it. If they do bill, every render is short by `100 × partial_images` output tokens (~$0.009 at 3). **The adjustment has deliberately NOT been added** — OpenAI's prose is not worth encoding as arithmetic when one invoice settles it. To settle it: the two A/B calls above are the only `gpt-image-2` activity on 2026-07-31, and they are not in `ai_requests` (spike scripts don't log), so that day's Costs bucket grouped by model is a clean two-call reconciliation — **$0.0318 means the frames are free and the code is already right; $0.0408 means they bill and the adjustment is owed.** Separately, nobody has yet reconciled a real `slot_poster` row against the dashboard either, so treat the column as a good estimate rather than an audited figure.
- **`SlotPosterGenerator` must never be bound as a singleton.** `lastRequest()` is per-instance mutable state, safe only because every caller resolves a fresh instance (`GenerateSlotPosterJob` type-hints it on `handle()`, one poster per job). A singleton binding would let a second poster in the same worker read the first's `ai_requests` row and file one poster's provider transcript against another — silently, nothing throws. There is no binding today; the method's docblock carries the full warning.
- **No rate limiter / circuit breaker** on this generation path (`GenerateSlotPosterJob` deliberately does not extend `App\Jobs\Ai\AiJob` — that base is built around `AiClient`'s `AiResponse`, which this non-chat path never produces). Acceptable at poster volume (a handful of manual generations per slot); revisit if posters are ever generated in bulk.

## Related files

**Backend — Models**
- [src/Event/SlotPoster.php](/src/Event/SlotPoster.php) — `SOURCE_*`/`STATUS_*` + metadata, `COLLECTION`, `nextVersion()`, `references()`/`derivatives()`.
- [database/migrations/2026_07_29_000001_create_slot_posters_tables.php](/database/migrations/2026_07_29_000001_create_slot_posters_tables.php) · […_add_is_favorite_to_slot_posters.php](/database/migrations/2026_07_31_000001_add_is_favorite_to_slot_posters.php) — the two tables, then the admin-shortlist flag.
- [src/Event/EventSeries.php](/src/Event/EventSeries.php) — added `posters()`, `media()`, `finalPoster()`.

**Backend — Services & Helpers**
- [app/Helpers/OpenAiImageClient.php](/app/Helpers/OpenAiImageClient.php) — the streaming SSE transport (`generations` / `edits`); no DB, no business logic.
- [app/Helpers/ImageGenerationResult.php](/app/Helpers/ImageGenerationResult.php) — the normalized value object (`bytes`/`url`/`httpRequest`/`responseRaw`/`usage`).
- [app/Helpers/OpenAiImageException.php](/app/Helpers/OpenAiImageException.php) — carries the redacted wire request that failed, for the FAILED log row.
- [src/Event/Services/SlotPosterGenerator.php](/src/Event/Services/SlotPosterGenerator.php) — the provider call + two-phase `ai_requests` log wrapped together.

**Backend — Repository & Job**
- [src/Event/Repositories/SlotPosterRepository.php](/src/Event/Repositories/SlotPosterRepository.php) — `create` (locked + duplicate-key retry) / `attachMedia` / `markProcessing` / `markFailed` / `attachAiRequest` / `retry` / `markFinal` (locked) / `toggleFavorite` / `delete` (split soft/hard semantics).
- [src/Event/Exceptions/PosterNotFinalisable.php](/src/Event/Exceptions/PosterNotFinalisable.php).
- [app/Jobs/Event/GenerateSlotPosterJob.php](/app/Jobs/Event/GenerateSlotPosterJob.php) — `redis-ai`/`ai`, `$timeout = 300`, resolves ordered references + downscales + stores + attaches.

**Backend — Controller & Form Requests**
- [app/Http/Controllers/Manage/Events/SlotPostersController.php](/app/Http/Controllers/Manage/Events/SlotPostersController.php).
- [app/Http/Requests/Manage/Events/SlotPosters/StoreRequest.php](/app/Http/Requests/Manage/Events/SlotPosters/StoreRequest.php) · [UploadRequest.php](/app/Http/Requests/Manage/Events/SlotPosters/UploadRequest.php).

**Frontend (Vue)**
- [resources/js/Pages/Manage/Events/Series/Partials/SlotPosterModal.vue](/resources/js/Pages/Manage/Events/Series/Partials/SlotPosterModal.vue) — the browsing surface: control bar (search / chips / density) + pinned row + grid + upload + composer (`@`-mentions) + retry + polling.
- [resources/js/Pages/Manage/Events/Series/Partials/SlotPosterCard.vue](/resources/js/Pages/Manage/Events/Series/Partials/SlotPosterCard.vue) — one thumbnail: always-on badges, hover/focus action overlay.
- [resources/js/Pages/Manage/Events/Series/Partials/SlotPosterLightbox.vue](/resources/js/Pages/Manage/Events/Series/Partials/SlotPosterLightbox.vue) — the poster-specific `#panel` for the shared viewer.
- [resources/js/Components/ImageLightbox.vue](/resources/js/Components/ImageLightbox.vue) — the **shared** viewer (stage, ←/→, filmstrip, download, `#panel` slot); owned by [shared/image-lightbox](/docs/modules_handbook/shared/image-lightbox/readMe.md), not by this module.
- [resources/js/utils/slotPoster.js](/resources/js/utils/slotPoster.js) — shared `posterLabel()` / `posterSearchText()` / `posterAspect()` so the card, the lightbox and the search index name and shape a poster the same way.
- [resources/js/Pages/Manage/Events/Funnels/Partials/Tabs/SlotsTab.vue](/resources/js/Pages/Manage/Events/Funnels/Partials/Tabs/SlotsTab.vue) — the new **Posters** row action.
- [resources/js/Components/Modal.vue](/resources/js/Components/Modal.vue) — gained an opt-in `padded` prop (default `true`) so the lightbox image can bleed to the panel edge.
- [resources/js/Pages/SlotLanding.vue](/resources/js/Pages/SlotLanding.vue) + [resources/js/Pages/Landings/DefaultSlot.vue](/resources/js/Pages/Landings/DefaultSlot.vue) — the optional `poster` hero prop.

**Landing / public surface**
- [app/Http/Controllers/Main/LandingController.php](/app/Http/Controllers/Main/LandingController.php) — `slotPosterMedia()` + the `poster` / `poster_width` / `poster_height` props.
- [app/Http/Controllers/Main/OgImageController.php](/app/Http/Controllers/Main/OgImageController.php) — widened scope + the `is_final`/`READY`/public-slot guard.

**Config / Prompt registry**
- `config/services.php` → `openai_image` (model/size/quality/timeout/`partial_images`, env-overridable).
- `config/ai.php` → `pricing.openai.gpt-image-2` (see *Known caveats*).
- `AiRequest::PROMPT_SLOT_POSTER` + `config/ai_prompts.php` entry (`model_note` pointing at `config/services.php`, since the AI Prompts page's model picker only offers chat models) + [resources/prompts/slot_poster.md](/resources/prompts/slot_poster.md) (the default art-direction body — admin-editable/versioned for free via the registry).

**Migrations**
- `database/migrations/2026_07_29_000001_create_slot_posters_tables.php` — `slot_posters` + `slot_poster_references` in one file; the composite `unique(['event_series_id', 'version'])`.

**Routes**
- [routes/web.php](/routes/web.php) — `manage.events.series.posters.*`, nested inside the `series` group; every write route carries its own `permission:manage-events`.

**Tests**
- [tests/Feature/Events/SlotPostersControllerTest.php](/tests/Feature/Events/SlotPostersControllerTest.php) — upload/store/markFinal/retry/destroy, per-route permission gating, cross-slot reference rejection.
- [tests/Unit/Event/SlotPosterGeneratorTest.php](/tests/Unit/Event/SlotPosterGeneratorTest.php) — logs on success *and* failure, no leaked bytes/key, provider/model correctness.
- [tests/Unit/Event/SlotPosterTest.php](/tests/Unit/Event/SlotPosterTest.php) — `nextVersion()` monotonic across soft-deletes; `displayNumber()` positional, closing a gap immediately and restarting at #1 on an emptied slot; the duplicate-key retry on both an empty slot and a populated one.
- [tests/Feature/Events/SlotPosterDeleteTest.php](/tests/Feature/Events/SlotPosterDeleteTest.php) — the soft-row/hard-file delete split; a soft-deleted slot leaves posters/media intact.
- [tests/Feature/Events/SlotPosterFinalConcurrencyTest.php](/tests/Feature/Events/SlotPosterFinalConcurrencyTest.php) — exactly-one-final + the stale-instance case.
- [tests/Feature/Main/OgImageControllerTest.php](/tests/Feature/Main/OgImageControllerTest.php) — the widened-but-still-narrow public scope (ready-not-final, soft-deleted final, foreign collection, `og_banner` regression, members-only slot all 404/200 as expected).
- [resources/js/Pages/Manage/Events/Series/Partials/SlotPosterModal.test.js](/resources/js/Pages/Manage/Events/Series/Partials/SlotPosterModal.test.js) — `@`-mention token order, dedup, cap, case-insensitivity; plus search, pinned ordering, the filter chips and the thumbnail reference toggle.

**See also:** [Events · Funnels](/docs/modules_handbook/manage/events/funnels/readMe.md) (the Slots tab this modal hangs off) · [AI](/docs/modules_handbook/shared/ai/readMe.md) (the `ai_requests` logging convention this follows) · [Media](/docs/modules_handbook/shared/media/readMe.md) (the storage layer this stores posters through).
