# 10 · Gotchas

Known sharp edges, so you don't rediscover them. When one bites anyway, add it here in the same PR that fixes it.

## X API

- **Recent search covers only the trailing 7 days.** A `since_id` older than 7d silently returns nothing useful: when `last_seen` is stale (downtime), switch to `start_time = now − 7d` and accept the gap. Bootstrap and any backfill beyond 7d must use `/search/all`.
- **`sort_order=relevancy` sometimes omits `next_token`** even when more results exist. Treat missing token as end-of-results; never loop-retry. The recency pull is the completeness guarantee, relevancy is gravy.
- **Retweet text is truncated** (`RT @user: …` cut around 140 chars). Never classify RT text. For amplification grouping, group by `referenced_tweets[type=retweeted].id` when present; the normalized-text-hash fallback is only for copy-paste spam.
- **`impression_count` can be 0/absent** on posts the auth context can't see metrics for. `0 impressions` with nonzero likes = "unavailable": render "—", never 0, and exclude from view-ranked lists.
- **Counts vs search totals disagree slightly** (deletions, visibility filtering, timing). Expected; the methodology page says counts are the volume source of truth. Don't "fix" one to match the other.
- **Counts hourly buckets are UTC-aligned and the newest bucket is partial.** Drop the in-progress bucket from charts and z-score math, or you'll detect a "fade" every hour.
- **`conversation_id:` search is also 7-day bounded.** Thread completion for an older conversation is impossible via recent search; complete threads while they're hot (the daily top-20 pull does this) and don't retro-fix.
- **Rate-limit handling**: prefer the `x-rate-limit-reset` response header (epoch seconds) over blind exponential backoff; sleep-until-reset wastes less budget than retry storms.
- **Metrics re-fetch returns partial success**: `/2/tweets?ids=` puts dead IDs in an `errors` array while `data` has the rest. Handle both; dead IDs feed the daily compliance sweep (drop from public JSON).
- **`lang:en` is best-effort.** Code-mixed and short posts misclassify both ways. Don't chase it; it's noted as a v1 boundary.
- **Quoted phrases in queries match case-insensitively and ignore some punctuation**; "fable-5" also matches "fable 5". Don't add redundant alias variants that only differ by case/hyphen: wastes query budget against the 512-char cap.
- **Pay-per-use 24h dedup is billing-level, not API-level**: you still receive duplicate posts across pulls; pipeline dedup is mandatory regardless of tier.

## Cloudflare

- **R2 has no rename** — copy then delete. The manifest-pointer publish (08 §7) exists partly because of this.
- **R2 S3 endpoint needs region `auto`** and path-style addressing with aws4fetch; signature mismatches are almost always a clock/region/path-style issue, in that order.
- **Workers subrequest limit** (1000/invocation on paid): a scheduler tick at 20 entities uses ~25-50; fine. But never fan out per-post fetches inside one invocation: that's what queues are for.
- **Queue consumers get batches**; a thrown error retries the whole batch. Catch per-message, ack what succeeded (explicit `msg.ack()`), retry only the failed message, or one poison post blocks its whole batch to DLQ.
- **D1 `batch()` is atomic; there is no interactive transaction.** Design single-statement guards (the budget UPDATE in 08 §6) instead of read-then-write.
- **D1 is a view, raw is truth.** Never let anything irreplaceable live only in D1: the 10GB ceiling plus the 90-day prune are safe *because* a replay from raw rebuilds everything. If you're about to store something in D1 that can't be re-derived, it belongs in R2.
- **Cron `scheduledTime` is the identity of a tick** — derive runIds from it, never `Date.now()`, or retried crons double-debit and double-write.
- **Workers CPU limit** (~30s on paid plans for cron/queue): LLM/X calls are wall-clock (fine), but JSON.parse on huge raw files is CPU: keep raw objects per-pull (a few hundred KB), never one giant daily file.
- **Pages + R2 cross-origin**: site domain ≠ data domain needs CORS headers on `public/` responses (`Access-Control-Allow-Origin: <site-origin>`). Set it at the Worker/route serving R2, not in app code.

## Embeds

- **widgets.js must be told about injected content**: call `twttr.widgets.load(container)` after hydration inserts blockquotes, or embeds render only on full page loads.
- **Adblockers block widgets.js** for a meaningful fraction of visitors: the 4s fallback card (06) is a primary path, not an edge case. Test with the script blocked in CI.
- **One embed ≈ 1MB+ of third-party JS/iframes.** Never render more than ~6 live embeds per viewport; below the fold use fallback cards that upgrade on scroll.
- **oEmbed (`publish.x.com`) has no auth but is informally rate-limited**: cache responses forever (they're immutable per post) and only call it at read-authoring time, never per-page-view.
- **Deleted tweets render as empty space** in widgets.js: the daily dead-ID sweep keeps public JSON clean, and the fallback card handles the race window.

## LLM

- **JSON mode still occasionally wraps output in markdown fences or adds prose**: strip ```json fences, then zod-parse, then one repair retry with the error message. Build this into the client (08 §8), not each caller.
- **Batch position bias**: accuracy drops for posts late in a long batch. ≤ 50 posts per call, 500-char truncation, and goldens include posts at positions 1, 25, and 50.
- **Classifier overconfidence on sarcasm/irony** is the main sentiment error class (the BillyM2k token-limit post reads as neutral-funny to humans, negative-pricing to the rubric: that's *correct* per our definitions; the prompt's "sarcasm about a flaw counts as that flaw" line is load-bearing — don't remove it).
- **Don't let the summary/verdict prompts see raw engagement numbers** you don't want echoed: the validator rejects uncited digits, and the cheapest fix is not supplying temptations.

## Data & product

- **`unique_authors` doesn't sum across windows or entities** (same author in many buckets). Never display an aggregation of it; compute distinct counts per scope.
- **All timestamps UTC ISO-8601 `Z`** in every artifact; render local-time only in the browser with a `title` attr showing UTC. Timezone bugs are an acceptance-failing paper cut (07).
- **The Fable fixture's `volume_by_hour` has only 2 hours** of history (capture started at launch). Charts must handle short series gracefully: this is also true for every newly added entity's first day.
- **Registry placeholder names** (every non-Fable model) MUST be verified before bootstrap pulls; wrong aliases burn budget on garbage and poison the field medians. The `verified: false` flag in the seed is a hard gate: bootstrap refuses unverified entries.
- **Score formula changes are version bumps, never silent edits** — even "obvious" calibration tweaks. The published-methodology trust model dies the first time a number changes without a version note.
- **Don't backfill sentiment history after a prompt change** beyond the active window: historical windows keep the prompt version that produced them (the publish-log records which).
