# Media, composer & delivery (M3)

## Lifecycle of a post

```
compose (SavePost) ──► version N (immutable, content_hash)
   │  material edit after review/approval → approvals superseded, delivery cancelled, back to review
submit (SubmitForReview) ──► approval_request bound to version + hash (auto-approved if the client has no stage)
approve (DecideApproval) ──► posts.approved_version_id = current version
schedule / publish now (SchedulePost) ──► targets → scheduled + outbox events (one tenant transaction)
outbox:relay ──► master publish_dispatch_index (idempotent insert by dispatch key)
publishing:dispatch-due ──► claims due rows FOR UPDATE SKIP LOCKED → PublishTarget job (queue `publishing`)
PublishTarget ──► Publisher: re-check version/approval/account/capability/media → claim (CAS) → attempt row → provider
```

Both scheduled commands run every minute (`publishing:dispatch-due --relay`). Locally, `composer dev` runs the scheduler.

## Guarantees and how they're enforced

| Guarantee | Mechanism |
|---|---|
| Only approved content is sent | `post_version_id` and `approved_version_id` are re-checked in the job. Versions are immutable (updates throw) |
| No double send under replay/concurrency | Dispatch key is unique in master. The target claim is a conditional `UPDATE … WHERE status IN (…)`. `PublishTarget` is `ShouldBeUnique` on the dispatch key |
| Crash evidence | The `publishing_attempts` row (`in_flight`) is committed **before** the provider call |
| No blind retry after ambiguity | Dropped connection / HTTP 500 on a publish call → `UnknownOutcome` → `unknown_outcome` → `ReconcileTarget` asks the provider. Found → published; absent → normal retry; can't tell → `failed: manual_check_required` |
| Survives restarts | Retries go through the outbox (durable), not queue retries. `publishing:sweep-stale` (5 min) re-queues lost dispatches, sends stale `in_flight` attempts to reconciliation, and resets targets that never reached the provider |
| Backoff | Transient: exponential with full jitter (60 s × 2ⁿ, cap 1 h, 5 attempts). Rate limits: wait `Retry-After`, no attempt consumed. Per-account cap `PUBLISH_ACCOUNT_RATE_LIMIT`/hour |
| Auth failures | Target and account → `needs_reconnect`, owner/admins/connector e-mailed |
| Suspended workspace | Delivery postponed (`tenant_suspended`), never dropped |

## Scheduling and time

`ScheduleTime::fromLocal()` stores the instant (UTC) **and** the wall-clock intent (local time + IANA zone):
- Times in a spring-forward gap are rejected ("does not exist").
- Fall-back overlaps resolve to the earlier instant; "use the second occurrence" picks the later one.
- The default zone is the client's timezone, else the agency's.

## Media

- Files are stored on `MEDIA_DISK` under `tenants/{tenant_id}/media/YYYY/MM/{uuid}.ext`. MIME types are sniffed from content. Limits come from `config/media.php`.
- Scanning (`media` queue) uses ClamAV `INSTREAM` (`MEDIA_SCANNER=clamav`, required outside local/testing). Locally, `signature` detects only the EICAR test file. Infected files move to `quarantine/` and can't be attached.
- Metadata: GD dimensions for images; ffprobe dimensions and duration for video. A missing ffprobe is recorded and nothing is guessed. Thumbnails use GD or ffmpeg.
- URLs are always signed and expiring:
  - **Members** get tenant-host links that also require login and `media.view` on the client.
  - **Providers that pull media** (Instagram, Threads, Pinterest, TikTok, Google Business Profile) get a pre-signed object-store URL on S3, otherwise a signed central route whose signature covers the tenant id.
- Dedupe is by SHA-256 within the same client scope. The storage quota comes from the plan's `storage.gb`.

## Provider publishing support (adapters)

| Provider | Implemented | Not yet (shown as *configurable*) | Reconcile |
|---|---|---|---|
| Facebook Page | text/link, photo, multi-photo, video (`file_url`) | Reels, stories | ✅ recent posts by message |
| Instagram | image, carousel, Reel, story (container flow) | — | ✅ recent media by caption |
| Threads | text, image, video, carousel | — | ✅ |
| LinkedIn | text/article, single image (member + org) | video, documents, multi-image | org pages only |
| YouTube | video (resumable upload; title + privacy required) | — | ✅ recent uploads |
| Google Business Profile | text, photo, call-to-action link | events/offers | ✅ |
| X | text, images | video (chunked upload) | ✅ recent posts |
| TikTok | video (Direct Post, `PULL_FROM_URL`; privacy chosen per post from the creator's allowed options) | photo posts | ❌ manual check |
| Pinterest | image pin (board required) | video pins | ❌ manual check |

Before go-live, verify for each provider:
- TikTok `PULL_FROM_URL` requires the media domain to be verified in the TikTok app.
- Instagram/Threads fetch media by URL, so the provider URL TTL (`MEDIA_PROVIDER_URL_TTL`) must cover their processing.
- LinkedIn commentary escaping keeps `#` for hashtags.
