Ctrl K
ComunidadGuías…

ROADMAP.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Roadmap — peanutsplit

The engineering backlog and feature map, so nothing lives only in a chat session.
Product status/milestones/decision log stay in the Notion project (linked from
`mono/projects/peanut-split/`); this file is what's built, building, queued, and
deliberately not built — with enough context to pick any item up cold.

Owner of record for each open item is in brackets. Last full update: 2026-08-12.

## Open — SEO issues tracker from the 2026-08-14 audit [Konrad]

- A 14-agent audit (every finding reproduced by an independent verifier) confirmed 49
  issues across live HTTP I/O, metadata/hreflang, structured data, the content engine,
  the production pipeline, the landing page, and the internal link graph.
- `docs/SEO-ISSUES.md` is the single open list for all Split SEO work — the guide
  tracker's open items were consolidated into it on 2026-08-14. mono
  `projects/peanut-split/guides-seo-tracker.md` keeps the decisions, the guides state
  table, the traps, and the verify commands.
- Headline items: claims-ID enforcement missing for the native corpus, presence-based
  hreflang that would advertise a draft translation, no search-engine submission loop,
  and zero analytics on content pages. Two audit items were closed the same day by guide
  tracker decisions 12 and 13; the file marks them CLOSED rather than deleting them.

## Production-verified 2026-08-12 — one-domain rollback to peanutsplit.com [Konrad]

- `peanutsplit.com` is again the only canonical origin for the app, PWA, native
  SEO pages, sitemap, metadata, schema, and newly generated room links.
- `split.peanut.me` may remain in DNS, but it is a compatibility alias only: all
  matched paths redirect one way to the same path on `peanutsplit.com`. The
  cross-origin localStorage bridge, reinstall banner, and old-origin worker
  retirement were removed because this cutover had no users to migrate.
- The generated `peanut.me/{locale}/split/*` publishing path remains dark and is
  preserved as deferred work. It must be deliberately retargeted before any
  generated artifact is published; no Peanut UI routing is part of the live
  architecture. Decision record: `apps/web/docs/SEO-DOMAIN-DECISIONS.md`.
- Release commit `541dce23549b3984343cc201dab52052957be18e` passed the
  complete local/CI gate and two independent production QA passes, including a
  real room/member/expense flow and persistent-Chrome PWA installability. The
  exact evidence and administrative Peanut UI cleanup are recorded in
  `docs/DOMAIN-ROLLBACK-2026-08-12.md`.

## Code-complete 2026-08-06 — exact-zero Former-member lifecycle

- A used active member whose authoritative room-currency balance is exactly zero
  can now be marked Former without deleting any ledger or social history. The
  room-locked transition keeps at least one active person, invalidates the old
  identity proof, drops push, writes audit history, and has short Undo plus
  durable same-ID reactivation with token rotation.
- New activity, identity, counts, and share art use the active roster. History,
  exports, recap, balances, and settlements use the full ledger directory.
  Historical corrections can reopen a Former balance and keep it settleable.
- Open money forms survive identity recovery. Offline expenses blocked by a
  membership change remain durable until reviewed: role remapping preserves
  exact amounts/weights and per-room ordering; only Retry success or explicit
  Discard removes the draft.

**State:** code-complete on `qa/qa-sesh-former`; production verification and
deployment remain separate release gates. Canonical invariants are in
[`member-removal-design.md`](./member-removal-design.md).

## Code-complete 2026-08-04 — append imports to an existing room

- Room settings now opens `/r/[slug]/import`, where a reviewed Splitwise or
  Split Pro export can be appended without replacing the room link, currency,
  roster, or existing ledger. Source people must be mapped one-to-one to an
  existing person or to a new provisional person created atomically with the
  expenses. The global `/import` create-a-new-room flow is unchanged.
- Each successful append gets an immutable `ImportBatch`, a server timestamp,
  and millisecond-distinct expense timestamps. A room-scoped canonical source
  fingerprint makes exact and concurrent retries successful no-ops while
  allowing genuinely different imports into the same room. Changed exports are
  appended in full; the review warns that partially overlapping history can
  therefore duplicate because the supported source projections have no stable
  expense IDs.
- The append path uses the ordinary exact-money/FX builder inside one
  room-locked transaction and emits one audit/realtime event only after a real
  commit. PostgreSQL integration coverage includes populated-ledger
  preservation, rollback, concurrency, replay, missing-room refusal,
  target-currency conversion, audit attribution, precise ordering, and rooms
  whose existing roster already exceeds the per-file 20-person parser cap.

**State:** code-complete on `feat/import-existing-room`; production verification
and deployment remain separate release gates.

## User-visible — receipt scanning

The camera/gallery scanner is live in public production.
Its expense-drawer entry point is the small camera/sparkle action inside the
amount row; opening it requests the camera immediately and keeps upload in the
bottom pullup. An installed Android PWA also advertises an image-only share
target, so a receipt can be sent from Photos, Gallery or Files directly into a
room's scanner. That OS route intentionally bypasses the provider-terms note on
the camera screen; Konrad accepted that product trade-off on 2026-08-05.

**Owner: Konrad.** Decided 2026-07-28 (Hugo): the v1/v2 boundary —
`splitV2Enabled()` and everything behind it (scan and Splitwise import)
— is Konrad's call to flip, and nobody else's. Konrad explicitly requested the
production activation on 2026-08-05. The release Dockerfile now bakes the flag
on so an absent or stale Dokploy build arg cannot silently keep the public
artifact dark; rollback is a source revert plus the ordinary main deploy.

**Historical status 2026-07-28, second pass.** The tap trap was root-caused and
fixed while the flag remained off. Three defects made the one symptom, all of
them in how a `document.body` portal coexists with a modal Radix layer:

1. The expense drawer is a modal Radix layer, so while it is open Radix sets
   `document.body { pointer-events: none }` and re-enables pointer events only
   inside its own content. The scan overlay is portalled to `document.body` (it
   must be — vaul transforms the sheet, and a `position: fixed` child of a
   transformed ancestor is positioned against that ancestor), so it was a
   sibling of that content and inherited `none`: drawn on top, taking no taps,
   with every tap falling through to the live drawer underneath. Fixed with
   `pointer-events-auto` on the overlay root.
2. Radix decides "outside" by containment, so the first tap that DID land was an
   outside interaction and dismissed the drawer — which clears the URL state,
   so the reviewed bill came back to a sheet that no longer existed and took the
   user's form with it. Fixed by vetoing that one interaction by TARGET
   (`onPointerDownOutside` on `DrawerContent`), not by a state flag: Radix
   dispatches it on the click after the pointer-down, by which time any
   `scanFile`-shaped guard has already cleared.
3. The read could deadlock before the review ever appeared. A ref that
   remembered "already scanned this file" meant a remount cancelled the first
   read and skipped the second, leaving "Reading the bill…" forever. Fixed by
   memoising the PROMISE rather than a flag — one vision call per photo, and
   whichever run is alive applies it.

The mobile regression test is `apps/web/e2e-v2/scan.spec.ts`, run with
`pnpm --dir apps/web e2e:v2` against its own flag-on dev server
(`playwright.v2.config.ts`, port 3101 — a build-time flag cannot be a project in
the v1 config, and the v1 suite asserts the absence of what this one drives).
Verified as a real gate: reverting the `pointer-events-auto` fix fails it with
all nine viewport probes landing outside the overlay.

**Release verification 2026-08-05.** The rebased flag-on dark suite passes all
13 Chromium journeys. It covers camera lifecycle and upload fallback, modal
focus/inert ownership, retry and cancellation, Back/Forward symmetry,
contextual receipt-row names and post-delete focus, draft-only handoff, and the
single ordinary expense write. A flag-on production Docker build is also a CI
gate.

**Activation hardening 2026-08-05.** OpenRouter requests now require providers
with data collection denied and zero data retention. Direct Gemini stays
disabled unless the operator explicitly confirms paid-tier handling. The API
accepts at most two scans concurrently, before body reading, and the prepared
image ceiling is 4 MiB. The installed Android app now advertises an image-only
Web Share Target on the same build-time flag as the scanner. Commit `3d02268` is
live, its public capability probe answers `enabled: true`, and a synthetic
two-line EUR receipt passed the production provider/proxy path with the exact
total while leaving the QA room with zero saved expenses. Real iOS/Android
camera, permission, rotation and background lifecycle verification is still
required before the capability can be called production-verified.

## Shipped (beyond the 07-25 launch state)

- **Correctness wave (2026-07-28):** service worker never caches `/api/*`
  (stale RoomState = money bug); FX rate genuinely locked at expense creation
  (edits reuse the stored rate, only a currency change re-prices); buttons obey
  the haptics setting; per-IP rate ceilings on all unauthenticated writes
  (`src/server/rateLimit.ts`); client-side Sentry with room-slug redaction
  (`src/lib/redact.ts` — slugs are credentials, they never leave the device in
  telemetry); demo script and deploy docs made honest.
- **Analytics + error tracking:** PostHog project "Peanut Split" (Squirrel Labs,
  id 234225) and Sentry project `peanut-split` exist; keys are Dokploy build
  args. `src/lib/analytics.ts` activates on `NEXT_PUBLIC_POSTHOG_KEY`;
  `src/instrumentation-client.ts` on `NEXT_PUBLIC_SENTRY_DSN`.
- **Web-push backend (live since 2026-07-29 — VAPID + proxy are set in prod;
  the two-device loop below is still unexercised):** subscriptions bound to proven members
  (`POST /api/rooms/[slug]/push-subscriptions`, endpoint host allowlist),
  SW push/click handlers, idempotency-ledger send pipeline
  (`src/server/push.ts`, `NotificationSend` claim rows), event triggers on
  expense/settlement/all-settled with a 3-per-room-per-day cap on expense
  pushes. Inert until VAPID env vars are set (see "To light up").

## Shipped 2026-07-28, second wave

- **Trip recap share card** — at all-settled, a second artefact: green field,
  the total as the headline, a shape-drawn SETTLED stamp, avatar row, and
  "María fronted the most". Data in `src/server/og/recapCard.ts` (one lean
  query; settled fold mirrors `roomState.balancesOf` with a drift test), art in
  `recapCardArt.tsx`, screen at `/r/[slug]/recap` (noindex, members-only).
  **The URL is never shared** — it carries the room slug, which is the room's
  credential — so "Share the story" fetches the PNG and shares the FILE
  (native → clipboard → download), with the domain printed on the card as the
  acquisition path. A public, slug-free recap URL needs a second read-only
  token; deliberately not V1. Events: `recap_viewed`, `recap_shared` (+ tier).
- **i18n** — en / es-419 / pt-BR live product-wide (cookie-resolved, no URL
  prefixes), Intl money/date formatting, error codes → localized client
  messages, CI key-parity audit. SEO pages and OG images stay English by design.
- **Polish** — whoosh/blip/error cues + master ducking, haptic vocabulary
  (confirm/error/success, iOS-tuned), squash-and-stretch all-settled, expense
  pop, error shake, drawer entrances, staggered reveals, `animationsEnabled`
  setting (OS reduced-motion always wins), standardized toast durations.
- **Smart currency** — offline inference (timezone beats language; ranked, max
  3, honest about ambiguity), suggestion chips on create, flag+symbol+localized-
  name picker over a native select, styled symbol runs, foreign-conversion
  strip in both split modes.
- **Auditable balances** — tap any balance → its complete derivation
  (paid/shares/settlements, chronological, signed, foreign originals), pair
  view that refuses to fake pairwise attribution and shows both sheets instead;
  proven by a 300-random-room property test against the server's own fold.
- **Push UI** — gated by VAPID build args: six-state push opt-in row (never
  burns iOS's permission one-shot), token-proven subscribe with rollback on
  server failure.
- **Infra** — `split-egress` squid pinhole live on the box and verified
  (push gateways and configured model providers only, port 443 CONNECT,
  everything else 403); VAPID keys set as runtime env; all `NEXT_PUBLIC_*`
  build args now have Dockerfile ARG/ENV pairs (the missing PostHog pair was
  found and fixed — a configured build arg the Dockerfile doesn't declare is
  silently dropped).

## Shipped 2026-07-28, third wave

- **Splitwise import** (`/import`) — a group export becomes a room: expenses,
  payers, shares and balances, with the link moment at the end. The parser
  (`src/lib/splitwise-csv.ts`, no CSV dependency — RFC 4180 is ~40 lines and the
  repo has a 14-day release-age floor) inverts Splitwise's per-member NET back
  into payer + shares; the identity that makes it exact is the same statement as
  "the row sums to zero". Rows fronted by two people are genuinely ambiguous, so
  they become one expense per payer, cut by interval overlap so no cent is
  created or lost — balances exact, per-expense attribution flagged in the UI as
  a reconstruction. `POST /api/import` re-validates everything with zod and
  writes the whole room in ONE transaction (createMany for expenses and shares,
  FX table read once, no query in a loop). The CSV never reaches the server: the
  browser parses it and posts structured data. 103 tests, the load-bearing one
  being the round trip — parse a fixture, create the room through the real
  handler, and compare Split's balances against the file's own "Total balance"
  row — plus a Playwright spec that does the same through a real file input.
  Registered in `static-pages.ts`, linked from the footer and from
  `/splitwise-alternative` (whose FAQ used to say Split had no import).
  Known limits, all surfaced in the UI before anything is written: historic FX
  uses today's rate (Splitwise does not export the rate it used on the day), and
  settle-up rows arrive as balance-identical expenses rather than settlements.

## Shipped 2026-07-28/29 overnight (Konrad)

- **Hardening wave** — request-body caps on all JSON routes, serialized
  settlement decisions / joins / push caps, idempotent money retries,
  non-negative exact-share reconciliation, offline-queue lease + bounded
  retries, cross-tab offline recovery, stale-ledger mutation guards, payer
  tokens kept off the organizer's device, analytics contract made literally
  identifier-free, join gate given real dialog semantics.
- **Landing rework** — the room link as the landing-page story, approved copy
  pass, doodle-native design system, marker testimonial portraits, and the
  group-chat handoff shipped from `feat/pass-the-link-landing`.

## Shipped 2026-07-29, product-audit wave

- **CSV + JSON export** — download the room's ledger from room settings:
  members, expenses, exact shares, frozen FX, settlements (with the optional
  Peanut receipt link), balances and suggested transfers. The room slug is
  never embedded in the files; free-form fields are escaped so spreadsheets
  treat them as text, not formulas. A plain warning states that anyone with
  the room link can export. Round-trip tests prove exported balances match
  the room.
- **Audit fix wave** — atomic payer creation with pristine-identity cleanup,
  documentary receipt links on Peanut settlements, locale-safe amount parsing
  (rejects `0,123`-style pseudo-grouping), landing continuity (paste a room
  link to re-add it, manageable recent rooms), one reduced-motion policy,
  release-state vocabulary in `docs/release-states.md`.

## App icon — shipped 2026-07-30

**This work is finished. Nothing about the icon is open.**

Split used to run the Peanut mascot on brand yellow, inside a white disc, inside
a bordered rounded square. The mascot's own colour was 4.0% of the 512px file, so
it was a smudge at launcher size and gone at 16px — and it was Peanut's mark, on
a home screen that holds both apps.

Konrad drew the replacement: a thumbs-up on a black-ringed pink disc
(`primary-1` `#FF90E8`). Shipped with it:

- `apps/web/src/assets/logos/split-mark.svg` — the artwork. Two ids the
  generator depends on: `ground` (the disc) and `mark` (the thumb).
- `apps/web/scripts/generate-icons.mjs` (`pnpm icons`) draws it two ways.
  **disc** — the mark as designed, ring against the frame, transparent outside —
  for everything that takes a transparent PNG: launcher, install prompt, tab.
  **flat** — disc dropped, its pink filling the tile, thumb inside the safe zone
  — for everything a platform masks into a square: Android maskable, iOS
  apple-touch. A mask eats an edge ring first, which is why those two differ.
- `src/app/favicon.ico`, which never existed. Disc at 32 and 48, flat at 16,
  every size rendered rather than downscaled. The old `src/app/icon.png` was a
  byte copy of the 192 launcher icon; it now has its own art.

Two decisions, both Konrad's, both closed:

- **The theme colour stays Peanut yellow** (`#FFC900` in `manifest.ts` and in
  `layout.tsx`'s viewport). The icon carries the distinction from Peanut on its
  own. Do not follow the icon to pink.
- The five drawn peanut candidates that preceded the logo are deleted. They were
  exploration, and the logo answered the question.

Gate at `a6c5479`: typecheck clean, prettier clean, 1118/1118 tests pass.

## To light up (remaining gates)

Done 2026-07-28 and verified live: the `split-egress` squid pinhole (allowed
CONNECT-443 to push gateways and configured model providers, everything else
403), VAPID keys + proxy URL as runtime env, all build-arg ARG/ENV pairs, and
PostHog events confirmed arriving in project 234225 from a real prod session.
NEVER rotate the VAPID pair in place — every subscription dies silently; a
rotation needs a dual-key window.

Still gated:

- **Push exercise** [next session]: infra + UI are live; nobody has completed a
  real two-device subscribe→notify loop in prod yet. Run one before telling
  users about it.

## Deferred until demand or measured scale — decision 2026-08-04

Peanut Split is an experiment with almost no users. Do not build production
machinery for hypothetical scale. Reopen these items only at the stated trigger.

- **Deployment gates and deploy-failure automation:** `main` must deploy
  directly. Revisit gates or a release-control project at tens of thousands of
  users, or after repeated missed failures make the cost real.
- **Anonymous room management:** a separate capability could rotate a share
  link or delete a room without accounts. Keep this as an idea until roughly
  1,000 rooms, product-market fit, a real user request or a legal obligation.
  Review mockups before adding the flow. This deferred idea does not authorize
  schema fields, states, routes, guards or other scaffolding before the item is
  reopened and approved for implementation.
- **Scale machinery:** defer distributed quotas, data retention and
  partitioning, full CSP enforcement, a blocking browser matrix on every
  deployment, multi-replica realtime, database-wide integrity redesign and full
  telemetry. Reopen at roughly 1,000 rooms or when measurements show the
  specific bottleneck.
- **Firefox mobile test harness:** the 2026-08-04 focused run passed 20/23. The
  three remaining failures are not credible product regressions in the current
  setup: one times out resizing the viewport before navigation, one misses a
  synthetic slide gesture and one misses a synthetic long-press. The project
  currently spreads Playwright's iPhone 14 descriptor and then overrides only
  `browserName` to Firefox, even though Firefox does not support Playwright's
  mobile emulation. Before claiming Firefox mobile coverage, use the native
  Desktop Firefox descriptor with an explicit narrow viewport, separate
  synthetic gesture tests from the semantic journeys, and spot-check the two
  gestures on a real Firefox device. Reopen before browser CI becomes a gate or
  when Firefox-specific user demand makes the coverage valuable.

## Deferred product-direction decisions — merge audit 2026-08-04

The post-merge audit found five surfaces where separate releases now express
different product directions. Do not resolve these as incidental bug fixes.
Reopen each item in a product review with mobile mockups and the evidence named
below.

- **Yellow or pink as the primary brand field:** the design system assigns
  yellow to primary actions and fields. It reserves pink for Peanut attribution
  and rare delight. The default landing hero instead uses pink as its full
  primary field. Decide which rule owns marketing surfaces before the next
  landing redesign or wider brand review.
- **Operational import or acquisition journey:** `/app` is the accountless
  operational home, but its Import action opens the indexed `/import` marketing
  page. Decide whether app users need a focused import shell or whether one
  acquisition route should serve both contexts. Reopen before adding another
  app-home tool or when import funnel data shows a navigation cost.
- **Room scope or device scope in settings:** the room-coloured card groups
  shared room settings with device-local push and identity controls. A separate
  “This device” row sits below it. Decide whether the sheet groups by room
  context or by who can see each change. Reopen before adding another setting
  or after a user reports a scope surprise.
- **Discoverable reactions or a hidden gesture:** reactions are available by a
  long-press, while the explicit reaction control appears only on keyboard
  focus. Decide whether reactions are a visible social feature or an optional
  discovery. Reopen when reaction use can be measured or before adding another
  gesture-only action.
- **Return to the ledger or continue importing:** after an existing-room import,
  “Import another file” is primary and “Go to room” is secondary, despite the
  duplicate-history warning. Decide whether repeat batch import is the main
  workflow. Reopen after initial import usage or before the next importer pass.

## Shipped 2026-07-28, third wave

All five branches merged, deployed, and smoke-tested live the same day:

- **Receipt scan (code complete, v1 entry point held)** — photograph a bill →
  Gemini vision itemizes → editable review → tap-assign items → lands as a
  normal EXACT expense through the one tested money path. Images never
  persisted, receipt contents never logged, model output re-validated like any
  anonymous input. See the v1 hold above before re-enabling it.
- **Realtime + offline** — SSE poke-and-refetch (polling stretched to 45s while
  the stream is open, 8s floor otherwise, never removed); offline queue for
  expense creates only (queued settlements could double-pay).
- **Trip recap** — celebratory share card at all-settled; the shared artifact
  is the image, never a URL (a recap link would carry the room-slug credential).
- **Delight** — 8-theme catalog themed through to the OG unfurl; 6-emoji
  reactions requiring a token-proven member.
- **Splitwise import** — client-side CSV parse (the file never uploads),
  net→shares reconstruction with per-row zero-sum validation, one transaction,
  balances proven against the export's own Total balance row in tests.

A three-lens adversarial review (money/correctness · elegance/DRY ·
maintainability/security) ran the same day, and its consolidated 32-item fix
wave shipped hours later: the mid-drain queue-loss race, the queued-expense
false "all settled", the chunked-body cap bypass, missing `publish()` on
reactions/theme, one balance fold instead of two, one fake-expense
constructor, one rate limiter, the voseo→tuteo sweep, the i18n-audit
object-form blind spot, and a scroll-padding a11y fix. 690 tests.

**Wave 3.5 (PWA deepening) — shipped 2026-07-30**, except the real-device
passes below. The installed app is called Split rather than the repo's name;
the launcher carries shortcuts; the install row lives permanently in settings
with one owner for the `beforeinstallprompt` event and five honest states; and
an app badge is raised when a push arrives for a device that was away. A
receipt-photo OS share target is now advertised to installed Android PWAs. Its
provider-terms-screen bypass was explicitly accepted; physical OS share-sheet
and killed-app lifecycle verification remain open.

Still open, and NOT closed by any automated check:

- **Real-device install pass** [Konrad] — the install row, the shortcuts, the
  iOS Safari sheet and the splash have been driven headless only. Chromium's
  `beforeinstallprompt` and iOS's absence of it are exactly what a headless run
  cannot tell you apart.
- **Real-device share-target pass** [Konrad] — an OS share sheet handing a photo
  to an installed PWA cannot be exercised from Playwright at all.
- **Push exercise** — unchanged, see "To light up" above.
- An OS share is parked in this device's Cache Storage while Split chooses a
  room. Reads reject it after 10 minutes and the next app boot collects an
  expired entry, but Cache Storage cannot physically self-delete while the PWA
  never runs again. A killed-app retention check remains part of the Android
  lifecycle gate.

## Achievement and shareable moments (proposed 2026-07-29)

Goal: maximize organic sharing without turning money into a leaderboard or
leaking a room credential. There are two distinct loops:

- **Private acquisition:** a useful room-link share into the group chat recruits
  the people who belong in that room.
- **Public expression:** a redacted image card lets somebody celebrate the trip,
  their crew or their alter ego without granting access to the ledger.

The existing pieces are enough for a focused first pass: persisted member
personas, expense and settlement attribution, reactions, currencies and dates,
the all-settled celebration, the recap renderer, native file sharing and push.
Do not build a separate global profile while Split remains accountless.

Every achievement should have the same three surfaces:

1. **Immediate:** one short in-room celebration when the threshold is crossed.
2. **Persistent:** an achievement shelf in the existing ongoing/final recap.
3. **Shareable:** a branded image handed to the native share sheet. Public
   variants hide money and names by default.

Ranked moments, by expected share desire, recipient conversion, eligibility and
social safety:

1. **`WRAPPED` — end-of-trip story deck.** Expand the settled recap into three
   to five individually shareable cards: result, group stats, currency passport,
   alter-ego cast/personal award, and the clean landing. Reuses the strongest
   emotional moment and the existing recap pipeline. Recommended first.
2. **`CREW` — crew assembled.** Celebrate meaningful roster thresholds (3, 5,
   8, 12), show the persona lineup, and offer the existing invite link with copy
   such as “The crew is five strong. Who is missing?” This is the strongest
   direct acquisition loop; do not fire on every join.
3. **`ALTER-EGO` — positive personal awards.** One signature role per member
   and trip, starring their persona: Trip Starter, Ledger Legend, Currency
   Hopper, Hype Crew, The Closer, or First Mover. Award contribution and
   coordination, never debt, spending power or payment speed.
4. **`PASSPORT` — multi-currency milestones.** Celebrate 2, 3 and 5 currencies
   with a stamp-style card: “Three currencies. One clean split.” Strong for
   travel identity and safe to share without amounts.
5. **`RESCUED` — Splitwise import victory.** On successful import, celebrate
   the number of expenses and people brought over with balances intact. This is
   narrow but a sharp switching/acquisition story.
6. **`TAMED` — expense-count milestones.** Mark 10, 25, 50 and 100 saved
   expenses with “25 receipts tamed.” Celebrate bookkeeping completed rather
   than money spent; no push for routine thresholds.
7. **`CLEAN-LANDING` — settlement result.** Share a factual coordination card
   such as “Seven people. Three payments. All square.” Do not claim transfers
   were “saved” without a defensible counterfactual.
8. **`GROUP-LORE` — crowd-favourite expense.** A row that earns at least five
   reactions becomes group lore. Its description stays group-only unless the
   sharer explicitly includes it in the external card.
9. **`UP-TO-DATE` — prompt logging.** A quiet badge for several days of expenses
   entered close to their expense dates. Lower priority: useful habit, but the
   definition must not reward spending every day or punish a quiet trip.
10. **`FIRST-SPLIT` — first successful room handoff.** A creator-only onboarding
    moment once another person joins their first locally remembered room. Cheap,
    but less expressive than the group achievements above.

Recommended first package: **`WRAPPED + CREW + ALTER-EGO + PASSPORT`** —
**shipped 2026-07-30.** Six cards come off one frame behind a single metered
route (`/r/<slug>/card/<kind>`), achievements are read from what the room
already holds rather than from a new table, and every unlock has the three
surfaces above: the in-room moment, the recap shelf, and a PNG handed to the
share sheet. The remaining moments (`RESCUED`, `TAMED`, `CLEAN-LANDING`,
`GROUP-LORE`, `UP-TO-DATE`, `FIRST-SPLIT`) stay queued behind share telemetry,
as planned.

Known items carried out of that wave:

- On a settled recap the deck and the shelf both drew PASSPORT and ALTER EGO.
  Fixed 2026-07-30 by standing the shelf down on whatever the deck draws — a
  reversible product judgment, see `shelfKinds` in `achievements-contract.ts`.
- `/r/<slug>/opengraph-image` has a pre-existing 80-character name overflow.
  It predates this wave and is NOT a card-route bug, but the fix has a
  seven-consumer blast radius (every OG surface shares the name-fitting path),
  so it wants its own pass rather than a patch inside this one. [Konrad]
- `ART_BY_KIND` reads like a dispatch table and is not one — the route picks
  art by kind directly. It is live as a completeness check (a seventh kind
  fails the card test through it) and dead as a dispatcher. Leave it or rename
  it; do not "wire it up".

The current file-share path is privacy-safe but not clickable. A later
**`CAPSULE`** phase can mint an optional, revocable, immutable and read-only
public achievement URL under a second random token. It must have no route back
to the writable room, must never contain the room slug, and must default to no
names or amounts. Its only action is “Start your own split.” This is the
external conversion multiplier, but it is a separate privacy/product decision,
not something to smuggle into the card implementation.

Guardrails:

- No daily spending streaks, biggest-spender badges, debtor rankings, fastest-
  payer awards, or public descriptions by default.
- Never share `/r/<slug>` outside the intentional private invite flow; the slug
  remains the room credential.
- One share prompt per meaningful milestone/session. Unlocks may accumulate in
  the recap shelf without interrupting the ledger.
- Track `achievement_seen`, `achievement_share_opened` and
  `achievement_shared` with achievement type and delivery tier only. Preserve
  the existing analytics rule: no slug, member, description, amount or currency.

Remaining candidates, ordered by expected value per effort:

1. ~~**Trip recap share card**~~ — shipped 2026-07-28, see above. Numbering
   left alone so the other items keep the IDs they were discussed under.
2. **Bill photo → itemized split** — snap a receipt, a vision model itemizes,
   assign items to people. Splitwise paywalls OCR; this is the sharpest "all
   premium features free" attack. Needs: one more allowlisted egress host + an
   API key + capture/itemize/assign UI. A real build, not a button.
3. **Room themes** — user-picked palette/motif (today hash-derived), which also
   themes the OG unfurl. Small.
4. **Expense reactions** — tap-to-react emoji on expenses. Social warmth with
   no chat surface. Small.
5. ~~**CSV export**~~ — shipped 2026-07-29 (audit wave, below): CSV + JSON
   download from room settings.
6. **Verified settle receipts** — reopens the 2026-07-27 decision (Peanut emits
   no signed charge webhook; polling public `GET /charges/:id` is the cheapest
   route). Revisit only if day-30 shows conversion is what's broken.
7. ~~**Readable, strong room links**~~ — updated 2026-08-04: a room keeps the
   readable name stem and appends a 128-bit opaque base64url capability, for
   example `ski-trip-R7LxQ3TBJV_uQ2PMhzc8rw`. This replaces the 30-bit
   three-word tail for new rooms. Existing three-word and six-character links
   still resolve so issued links do not break.

   The hero, pass-the-link stage and proof rail read one `SLUG_TAIL_HINT`
   constant. They show a short opaque run so the readable stem remains visible
   at 375px. The share screen shows and copies the complete link.

   Left open, found in review: the settings link row now ellipsises. Its
   `truncate` is load-bearing — `SettingRow.tsx` documents the overflow it fixed
   — so the row hides the end of the tail, which is the part that identifies the
   room. Copy and share are unaffected, and `LinkMoment` still shows the whole
   link because it wraps with `break-all`. Truncating from the middle would show
   both ends; that is a design call, not a bug fix.

## Design roadmap (opened 2026-07-28)

Split shipped fast and looks it: emoji where drawings should be, a Knerd headline
nobody can read at three words, labels stacked above inputs, and a footer that is
four grey links in a row. This section is the visual pass, taken from Munin's
line — the same hand-drawn stroke set, run through the same seeded roughening
build — kept in Peanut's colours (`primary-1` yellow, `secondary-1` pink for the
Peanut mark only, cream `background`).

Order below is execution order, not importance. Status per item.

1. **Doodles as icons, emoji gone.** — _status: shipped 2026-07-28_
   Port `rough.py` and the `build.py` → generated-set pipeline from
   `munin/design/raven-doodles/`. Clean geometry in a 32-unit box, stroked at
   ~1.4–2px, `fill: none`, `stroke: currentColor`, so a drawing inherits the ink
   of whatever it sits in and needs no second asset per theme. Output is a
   TS map (`name → path`) plus a `<Doodle>` component. Everything currently
   carrying an emoji — the sixteen room emojis, the settle methods, the feature
   grid, the use-case cards — draws instead. Legibility floor learned in Munin:
   below about r2.5 a loop fills in and reads as a full stop.

2. **Footer and sitemap.** — _status: shipped 2026-07-28_
   Real Peanut logo rather than the word set in `font-display`, and the
   structure peanut.me uses: named columns, not a single row of grey links.
   Every static page Split has should be reachable from it.

3. **Language picker placement.** — _status: decided 2026-07-28 — stays in the footer, see below_
   RULED: it stays in the footer, but as a compact three-word row in the bottom
   bar rather than the full-width segmented control it was — which was the
   largest single control on the page, sitting at the very bottom of it.
   No mockup round, because the premise was wrong. Locale is already resolved
   server-side from the cookie and then `Accept-Language`, so a Portuguese
   speaker arrives in Portuguese without touching anything. The switcher is not
   the way in, it is the correction path for the minority the header guesses
   wrong — and a correction path is exactly what a footer is for. Putting it in
   the hero would cost fold space to a control most people never need, and Split
   has no header bar on the LP to put it in without inventing one.

4. **Room doodle auto-picked from the name.** — _status: shipped 2026-07-28_
   A keyword table maps what someone types ("ski", "esquí", "pizza", "airbnb")
   onto a drawing, so the room already looks like itself before anybody opens
   the picker. All three locales in one table, falling back to the peanut.
   Wired through `lib/room-emblem.ts`: the `emoji` column holds a doodle name for
   new rooms and an emoji character for every room made before the swap, and both
   are valid forever — no migration, because a rewrite could only guess which
   drawing somebody meant by 🎿. A value is a doodle if the generated set has that
   name, which is unambiguous since emoji are pictographic and doodle names are
   lowercase ASCII. `RoomEmblem` is the single render seam; `og/emblem.ts` builds
   the unfurl's image locally from the path data. Known legacy emoji resolve to
   their matching doodles and unknown stored values fall back to the peanut; text
   metadata and notifications omit the emblem entirely rather than reintroducing
   a device-specific glyph.

5. **Currency list, readable and friendlier.** — _status: shipped 2026-07-28_
   `🇧🇷 R$ BRL — Brazilian Real` is four encodings of the same fact in one line.
   Draw the symbol, show three or four currencies, and put the rest behind a
   "more" step.

6. **Knerd is for one or two words.** — _status: shipped 2026-07-28_
   The display face has no accented glyphs and no lowercase worth reading; at
   three words it stops being a headline and becomes a texture. Cap it, and set
   the rest in the body face.

7. **Inline the field labels.** — _status: shipped 2026-07-28_
   "What are you splitting" above an empty box becomes `Ski trip…` inside it.
   Halves the vertical space the form takes, which is what puts the button above
   the fold on a 390px screen.

8. **Cut superfluous copy.** — _status: first pass shipped 2026-07-28_
   Cold-read UX pass over every marketing surface; delete the lines that restate
   the line above them.

9. **A `(?)` affordance instead of a sentence.** — _status: shipped 2026-07-28_
   The slug preview and "How the link works" currently take two lines and a
   underlined link. One small drawn `(?)` beside the preview opens the same
   sheet.

10. **The form IS the first fold.** — _status: shipped 2026-07-28_
    Drop the yellow pitch band above the form and give the form the yellow
    instead, with enough weight that it reads as the subject of the page rather
    than a widget under a headline.

## Landing-page follow-up backlog (opened 2026-07-29)

Follow-up to the pass-the-link hero on `feat/pass-the-link-landing`. These are
queued improvements, not descriptions of the current shipped page. The bar is
the energy and responsiveness of peanut.me: Split should feel playful before
anyone has made a room, without turning the first fold into another product
mockup or pushing the real creation form out of reach.

1. **Remove the decorative utility header.** — _status: implemented on
   `feat/landing-follow-up` 2026-07-29_ — **[Konrad]**
   Delete the entire `PEANUT SPLIT` / `4 FRIENDS · 1 LINK` strip from the hero.
   Do not replace it with another slogan, navigation bar, or spacer. The
   headline, real room form, and group-chat handoff already identify and explain
   the product; the extra strip is visual housekeeping masquerading as content.

   **Done when:** the utility row and both strings are absent in every locale;
   its height is reclaimed rather than left blank; the headline, handoff, form,
   and primary CTA still fit the agreed desktop and mobile folds; the footer,
   document title, and metadata continue to identify Peanut Split.

2. **Run every UI icon through the doodle engine.** — _status: engine
   consolidated and source audit added on `feat/landing-follow-up` 2026-07-29;
   audit widened to all of `src` and every route swept in a browser at 375×667
   and 1280×800 on `feat/landing-sweep` 2026-07-30 — no stray icon found. Two
   items stay open: the 44px tap floor (below) and the real-device pass_ —
   **[Konrad]** Inventory the whole
   product, not only the landing page. Replace
   remaining library icons, emoji used as controls, hand-authored one-off SVGs,
   Unicode arrows/checks, and other interface glyphs with named drawings from
   the generated `design/doodles` → `doodles.ts` pipeline. The WhatsApp,
   Telegram, Messenger, and Messages marks around the new hero are included:
   move their geometry into the engine and render them through `<Doodle>`
   instead of keeping a parallel `MessagingDoodle` component. Third-party marks
   should remain recognisable but inherit Split's loose line and stroke weight.
   Photos, content illustrations, flags, and the Peanut wordmark are not UI
   icons and are outside this conversion.

   **Done when:** every route has been swept at mobile and desktop sizes; all UI
   icon call-sites share the typed doodle set and inherit `currentColor`; there
   is no second icon system left for new code to copy; tap targets remain at
   least 44px even when the drawing is visually smaller; a source-level audit
   catches new emoji, raw icon SVGs, or legacy icon-component imports.

   **Open — the 44px tap floor.** Six drawn controls measure under it: the
   reaction opener (28px) and each reaction option (32px) in `ReactionBar`, the
   copy-invite button (36px) in `ShareDrawer`, the character opener (20px tall)
   in `RoomHeader`, and the two full-width rows that are 20px and 33px tall. The
   obvious fix — an invisible hit area around each drawing — makes adjacent
   reaction targets overlap, so the wrong reaction gets picked. Growing the
   drawn control instead changes the density of the expense list. Konrad picks
   which trade the reaction row takes; the other four are safe to grow.

3. **Rewrite “How the link works” and make “free forever” consistent.** —
   _status: implemented with an enforceable copy audit on
   `feat/landing-follow-up` 2026-07-29_ — **[Konrad]** Rewrite `LinkExplainer` in plain, human
   language: short concrete sentences, one fact at a time, no synthetic
   reassurance, no repeated setup, and no copy shaped like an objection the
   page invented so it could answer it. Remove negative loss framing such as
   `If everyone loses it, the room is gone` (and the Spanish/Portuguese
   equivalents). Explain the positive behaviour instead: opened rooms stay
   listed on this device, the room travels through its group link, and Split
   records the maths without touching the money. Keep the access model honest:
   anyone with the room link can open the room.

   Audit every user-facing `free` / `gratis` / `grátis` claim across the product,
   landing copy, FAQ/read-more copy, metadata, alternatives, and editorial
   content. Whenever the claim is about Peanut Split, say **free forever** (with
   a natural equivalent in each locale), not merely “free.” Do not rewrite
   unrelated uses such as cash being fee-free or quoted competitor copy; record
   those deliberate exceptions so the audit stays enforceable.

   **Done when:** the explainer reads naturally aloud in en / es-419 / pt-BR;
   none of its headings lead with losing, failure, or a hypothetical worst case;
   the privacy and trust model remain factually exact; every Peanut Split price
   promise says “free forever”; catalog parity, content rendering, and the
   user-facing copy audit pass.

4. **Give the landing page the app's motion, sound, and haptic vocabulary.** —
   _status: first motion map and implementation complete on
   `feat/landing-follow-up` 2026-07-29; every automated contract re-verified
   against a production build on `feat/landing-sweep` 2026-07-30 — quiet on
   passive scroll, sound only after a gesture, complete final frame in both
   reduced-motion paths, no overflow, CTA in the fold at 375×667 and 390×844.
   peanut.me comparison and real-device feel
   pass pending_ — **[Konrad]** Start with a motion map rather than sprinkling
   unrelated loops over the page. Choreograph the room-link handoff, people
   joining, channel doodles arriving, proof scenes entering the viewport,
   doodles reacting, button press/squash states, FAQ opening, room examples, and
   the final CTA as one playful sequence. Benchmark the implemented feel
   directly against peanut.me on phone and desktop before sign-off.

   Reuse the product primitives: `useMotionAllowed()` and the
   `.reduce-animations` path for motion, and `useFeedback()` for sound/haptics.
   Haptics belong to real taps and successful actions, never passive scroll or
   autoplay; sound must begin only after a user gesture and continue to respect
   the existing sound setting. Prefer one-shot, causal motion with a readable
   final frame over perpetual background movement.

   **Done when:** the full page has intentional motion and feedback beats rather
   than only an animated hero; touch interactions feel alive on iOS and Android;
   hover/focus interactions have an equivalent desktop response; reduced-motion,
   animations-off, sound-off, and haptics-off modes all remain complete and
   quiet; there is no layout shift, horizontal overflow, blocked control, or
   below-fold primary CTA at the supported viewports; Playwright covers the
   reduced-motion and final-state contracts and a real-device pass covers feel
   and performance.

   Implementation map: `docs/landing-motion-map.md`.

## Share package cleanup (2026-07-29)

**Shipped:** the "Download room card" and "Download invite text" buttons are
gone from `LinkMoment`. Both were nonsense as shipped — a raw 1200×630 `.svg`
file and a two-line `.txt` file, verified with Playwright download interception.
No messenger accepts a pasted SVG, so the download dead-ended the handoff it
was supposed to help. The share drawer now has one primary action (native
share, which falls back to clipboard) plus the inline copy row and the
manual-copy textarea fallback. `SHARE_PACKAGE_METHODS` shrank to
`native | clipboard`; the five `room.link.download*` keys left all three
locales.

**Corrected 2026-08-06:** a private room invite is URL-first again. Its native
payload is localized text plus the exact room URL and never a PNG attachment;
some iOS share targets accept image + URL and silently deliver only the image.
The URL still has a room-specific 1200×630 visual through its
`opengraph-image.tsx` metadata route. That route's URL is Next's to mint, not
ours to spell — a route group earns it a build-scoped hash and a cache-buster —
so the room page's own `<head>` is the only place it is written down, and room
load warms whatever it finds there. Creation no longer warms anything: it runs
on `/new`, whose head describes `/new`. The warmed image's browser/shared-cache
lifetime is five minutes with a short stale-while-revalidate window. Preview
failure never blocks the room or the share. Public achievement/recap keepsakes
remain a separate, explicitly labelled image-only action with no URL, text or
title in the native payload. `share_completed` therefore stays
`native | clipboard`; there is no invite `card` method to add.

The empty-room hierarchy was corrected in the same pass: Share room remains the
sole primary share action until at least one real expense exists. A roster-only
CREW unlock waits rather than presenting a competing image-share moment before
the ledger has earned one.

**Queued — semi-done feature audit, remaining surfaces:** [Konrad]
A 2026-07-29 Playwright walk covered landing, room view, and the share drawer
before it was cut short. Not yet audited for label-vs-behavior gaps:
settle/balance drawers, member management, join flow on a fresh device, and
the PWA install surface. (`RecapShareButton` was checked in code and is
healthy — it shares a PNG; its download is a last-tier fallback, not a labeled
button.) Method that worked: drive each control headless, intercept
downloads/clipboard, and compare against the label's promise.

## Deliberately not building

Each of these either breaks "a room is a link" or adds a maintenance surface
the one-month kill condition can't justify. The bunq/Tricount post-mortem in
`mono/projects/peanut-split/competitive-2026-07-24.md` is the tie-breaker.

- **In-room messaging** — the room is born inside a group chat; WhatsApp is the
  messaging layer. Reactions (wave 3) are the allowed subset.
- **User image uploads** (group photos, backgrounds) — abuse/moderation problem
  on an unauthenticated link-credential surface with no support channel.
  Palette/emoji customization only, until post-PMF with real moderation.
- **Mandatory accounts, roles/permissions/admins** — impersonation-tolerant
  flat rooms are the design (see SPEC).
- **Budgets, analytics dashboards, AI chat agent** — a second product inside
  Split.
- **Native apps, monetization features** — out of scope for the bet. If native
  is ever reopened, the migration cost is already mapped in
  `docs/NATIVE-MIGRATION.md` (server schema unaffected; the room list in
  `ps:recent` is the only unrecoverable state; a transfer code is a copy, not
  a sync, and has no lost-device recovery). That document authorizes nothing.

## Known debt

- **Private feedback retention scheduler:** report writes and every production
  container start delete `FeedbackReport` rows older than 90 days. Add a daily
  Dokploy/Postgres job using the same cutoff so the policy remains time-bounded
  even if a container runs for more than 90 days without a deploy or a new
  report. **Owner: Konrad. Done when:** the daily job is visible in operations,
  one deliberately expired canary row is removed, and screenshots, diagnostics,
  messages, and room snapshots are all covered by the same row deletion.

- `formatMoney` can flash 2-decimal amounts for a fresh non-fallback currency
  before `/api/currencies` resolves (mitigated by the fallback catalog).
- `apps/api` (the Fastify settle-loop engine) still exists beside `apps/web`
  with its own database and schema style; collapsing them is the remaining
  merge (`CLAUDE.md` "one seam still open"). The debt ceiling and
  settle-intent flow live only there.
- Root `pnpm format` fails (`prettier-plugin-tailwindcss` resolves only inside
  `apps/web`); format per-app instead. Root prettier also wants to reformat
  `ci.yml` — leave it.
- `Settlement` has no currency column (implicitly room currency) — decide
  before any multi-currency settlement work.
- In-memory rate limiter shares one bucket for header-less clients ('unknown'
  key) — unreachable behind Traefik, but wrong if the proxy ever changes.
- SSE fan-out and the per-room scan quota are per-container (in-memory); a
  second replica halves poke delivery and doubles the quota. Single-replica by
  assumption; the fix is Postgres LISTEN/NOTIFY + a shared store, not caps.
- `useDeleteSettlement` (lib/queries.ts) is an exported mutation hook with no
  caller (pre-dates wave 3).
- **The content stylebook describes more gates than it has.** Found in review
  2026-07-30. `content.test.ts` really does enforce the counting rules and about
  six of §11.1's ~40 never-strings — a page saying `unlimited`, `fewest
transfers` or `live exchange rate` fails. The rest do not exist: a page can
  ship `truly seamless`, `world-class`, `split bills not friendships`, `any
currency`, `150+` or any es/pt-BR never-string and pass clean, and the
  em-dash cap, the co-presentation rule and the banned-concession-title rule
  are unenforced (the Tricount page carries 6 em-dashes against a cap of 3).
  Worse, `content.ts`'s `Frontmatter` has no `type`, `claims`, `cast` or
  `competitorClaims`, and `parseDoc` builds from an explicit key list — so
  those keys are silently DISCARDED and every §11.2/§11.3 rule keyed on them
  is unrunnable. Either implement them or mark those rows human-review-only; a
  rulebook that names a gate it does not have stops the next reviewer looking.
  The `_system/stylebook.md` header also links six files that do not exist in
  this repo, including the `intent-queries` list §6.15 says every FAQ question
  must come from — so no FAQ on the new pages is checked against anything.
- The competitor-claims register calls itself exhaustive but is missing a row
  for the `"tricount does the math for you"` quote the comparison page uses.
  The quote is verbatim (confirmed on tricount.com 2026-07-30); the register
  is what is incomplete.
- `equalSplitMinor` (lib/money.ts) and `equalShares` (server/split.ts) are
  documented as "the same rule" with nothing pinning them — a two-line
  agreement test would close it.
- The SSE route's sink late-binding could be a `TransformStream` (simpler, real
  backpressure) — worth doing next time the file is open, not before.
- No unused-export/unused-key lint (`knip` or similar) — dead exports
  accumulate silently; wiring one in is the cheap permanent fix.
- `all_settled` fires (analytics + would-be push) for a solo room whose only
  expense nets to zero — harmless (push skips with no targets) but the event
  is semantically noisy for single-member rooms.

### From the deep-QA sweep 2026-08-11

(Full repro for each is in the QA report; the money, concurrency, import, and
auth surfaces came back clean. Triaged 2026-08-12 — the medium is fixed and
shipped, the three lows are decisions and are documented below. The one
high-severity finding, the room `og:image` 404 in prod, is a tracked fix, not
debt.)

- ~~**[medium] IP rate limits are bypassable by rotating `X-Forwarded-For`.**~~
  **Fixed & shipped 2026-08-12 (`ea535a9`).** `clientIp` trusted the left-most,
  client-supplied XFF hop, so rotating the header handed out a fresh bucket per
  request and defeated every IP-keyed limit. It now walks the header from the
  right, steps over our own private/proxy hops, and keys on the address Traefik
  appended — the one value a caller cannot forge. Prod topology confirmed
  (Traefik, no CDN); single-hop XFF is unchanged, only a spoofed leading hop is
  now ignored. This was the sharp edge of the existing "in-memory rate limiter …
  wrong if the proxy ever changes" note above.
- **[low] Incremental member-add does not cap the roster — _working as intended,
  not debt._** The import paths reject a source _file_ over `MAX_MEMBERS` (20),
  but `POST /api/rooms/{slug}/members` deliberately does not: `product-truths.md`
  §room-size-20 documents joining via the room link as uncapped, and "up to
  twenty" as a copy promise rather than a guarantee. Enforcing a roster cap here
  would reject the 21st person clicking a room link — a product decision, not a
  bug fix. Left uncapped on purpose; do not "fix" it by adding a cap.
- **[low] Room-existence oracle on mutating routes — accepted defense-in-depth
  debt.** Mutating handlers call `loadRoom()` directly without `meterRoomLookup`
  (`route.ts:36`, `expenses/route.ts:37`, `settlements/route.ts:28`), so they
  never spend the lookup-miss budget the GET routes do (`rateLimit.ts:141-164`).
  Cryptographically infeasible to exploit — ~43-bit slug entropy, and the write
  limit is now per-real-IP after the XFF fix. The clean fix wraps `loadRoom` in
  `meterRoomLookup` across ~8 write routes (moving it into `loadRoom` would
  double-meter the already-metered routes and every in-transaction reload), so it
  is not worth the blast radius today.
- **[medium] Nothing audits `ops/` for banned claims.** `content.test.ts` iterates
  `src/content` and `scripts/marketing-copy-audit.mjs` walks five named paths, so
  copy written anywhere else ships unchecked. `ops/listings/listing-kit.json` —
  the text submitted to app directories alongside the social card — carried
  "free forever", "works offline" and "any currency" until 24 Aug, and one
  submission went out with them. Fixed by hand and the rules are now written at
  the top of that file, but the next file outside `src/content` will repeat it.
  A gate that walked `ops/` and `design/` would close the class.
- **[medium] A 404 page advertises an `og:image` that 404s.** Any URL under a
  missing route segment still renders inherited layout metadata, so it emits
  `og:image` pointing at a hashed route Next never generated — verified on
  `/alternatives/splitwise-alternative`, which returns 200 for the meta tag and
  404 for the image. Shared dead links therefore unfurl broken. Only affects
  shares of URLs that do not exist, which is why it has not mattered.
- **[medium] The locale content OG routes have no coverage of any kind.** Six
  files under `es-419/` and `pt-br/` (blog hub, blog `[slug]`, `[page]`) plus the
  two localized guide routes. Nothing unit-tests or requests one. This matters
  more than the count suggests: `content-og.tsx`'s `drawable()` sanitizer exists
  precisely because Satori drops unmapped codepoints, and accented Spanish and
  Portuguese titles are the only inputs that exercise it. The English routes are
  equally untested but far less likely to hit the sanitizer.
- **[low] `ARTICLE_IMAGE_URL` has no cache-buster.** Generated `opengraph-image`
  routes carry a content hash in the query, so swapping a card changes its URL
  and the social platforms refetch. `/og-default.png` is a stable path with no
  version, so a consumer that caches by URL — Google, for structured data — can
  keep showing the previous card indefinitely after a swap, with every test
  green. Versioning the filename on change would fix it; nobody has been bitten
  yet because the card rarely changes.
- **[low] `roomPreview.test.ts` needs a database and fails without one.** It is an
  integration test living in the unit suite, so a clean checkout with no local
  Postgres reports a failure that reads like a code defect. Either move it behind
  the integration boundary or make the requirement explicit in its failure.
- **[low] `en.json` ships a rail label the stylebook bans.** `marketing` carries
  "ANY CURRENCY", which `stylebook.md:479` and `product-truths.md:33` both
  prohibit — the approved phrasing is "162 room currencies", since 156 convert.
  `marketing-copy-audit.mjs` does walk `src/i18n/messages`, but its
  `prohibitedClaims` list only covers lifetime-price and Peanut-attribution, so
  it does not catch this one.
- **[low] The locale roots have no landing card.** `/es-419` and `/pt-br`
  308-redirect to their blog indexes, so there is no localized equivalent of the
  `/` unfurl. Their metadata is correct for a blog index; it just means a Spanish
  or Portuguese share of the site root gets the English card.
- **[medium] `content-og.tsx`'s sanitizer disagrees with `roomCard.ts`'s, and has
  none of its guards.** `drawable()` (`content-og.tsx:38`) maps an undrawable
  character to a space; `sanitizeForFont` (`roomCard.ts:154`) deletes it — so
  `Split東京Now` is `Split Now` on a content card and `SplitNow` on a room card.
  It also lacks all three of `sanitizeForFont`'s protections: no NFC normalize,
  so decomposed accents are destroyed mid-word (`niño` typed NFD renders
  `nin o`, while the precomposed spelling is fine, and es-419/pt-br is exactly
  where NFD arrives from); no meaningful-character floor, so a mostly-undrawable
  title ships as a fragment rather than falling back; and no length cap at all,
  where every sibling truncates — a 400-character tagline pushes the sheet off
  the canvas and takes the wordmark with it, measured on real rasters.
  No live title is decomposed and the longest is 53 characters, so nothing is
  broken today; all four are pinned as `(bug)` tests in `content-og.test.tsx`.
  The fix is one call — route `drawable()` through `sanitizeForFont` with
  `BODY_CHARS`, an NFC normalize and a cap — at which point those tests become
  the spec to invert.
- **[low] `content-og.tsx`'s docstring names the wrong font and the wrong
  coverage.** Lines 19-22 say Sniglet covers Latin-1 and everything past it is
  dropped. The tagline face is Roboto (`fonts.ts:28`) and `BODY_CHARS` carries
  Latin Extended-A and the Cyrillic block, so Ukrainian and Polish titles render
  verbatim. The behaviour is right and is now pinned by a test; only the comment
  is wrong, and it is the kind of wrong that invites someone to "fix" working
  code and blank a Ukrainian title.