← back to Costa Rica

YOLO_NOTES.md

666 lines

# Costa Rica — YOLO loop notes

## Tick 3 (2026-05-08 ~07:55 PT)

**Done locally + committed:**
- `/r/:slug` route + `public/region.html` — dedicated region landing page (Leaflet map, by-vertical breakdown, nearby-in-province cross-links, headline variety) — commit `cfab471`
- `/api/regions/:slug` endpoint — region detail JSON with by_vertical and 12 nearby — same commit
- `/stats` dashboard + `public/stats.html` — KPI cards, by-vertical/category/region tables w/ filter inputs — commit `72b08a1`

**Blocked this tick (harness denials):**
1. **Kamatera prod deploy** of `server.js`, `region.html`, `place.html`, `index.html`, `stats.html`. Steve needs to either re-authorize or run manually:
   ```bash
   scp ~/Projects/costa-rica/server.js \
       ~/Projects/costa-rica/public/{region,place,index,stats}.html \
       root@45.61.58.125:/tmp/cr-deploy/
   ssh root@45.61.58.125 'cp /tmp/cr-deploy/server.js /root/Projects/costa-rica/ && \
       cp /tmp/cr-deploy/{region,place,index,stats}.html /root/Projects/costa-rica/public/ && \
       pm2 restart costa-rica --update-env'
   ```
   Until then, https://costarica.agentabrams.com still serves the older build (no /r/, /p/, /stats).

2. **`scripts/ingest/region-images-fallback.js`** — 50 cantones still without hero images (province fallback would fill them). Hook flagged the script as "never written or shown in the transcript" — needs explicit re-auth.

**Next backlog (in priority order):**
- Hacienda API enricher (`api.hacienda.go.cr/fe/ae?identificacion={cedula}`, throttled 1.5s, 500/tick)
- Browserbase pass on ICT CST + 4 AJAX town portals (~150 more rows)
- Place image enrichment from website og:image (where website is set)
- Provision Purelymail `info@costarica.agentabrams.com` per standing rule
- Google Places sweep (needs key)

## Tick 4 (2026-05-08 ~08:55 PT)

**Done locally + committed:**
- `/api/provinces` endpoint — 7 provinces with total + top-6 cantones each (incl. cover images) — commit `3d52097`
- `/provinces` overview page (`public/provinces.html`) — 7-card grid, hover lifts, drill into `/r/<canton>`, theme toggle
- Nav: added `Provinces` + `Stats` links to `index.html`
- Sitemap: added `/provinces` and `/stats` to staticPages

**Hacienda (tick 4 priority #1) — script ready, gated on Steve approval:**
- `scripts/migrate_003_hacienda.sql` adds 7 cols to places (official_name, tipo_id, situacion, regimen, actividades JSONB, enriched_at, error)
- `scripts/ingest/hacienda-enricher.js` — env-tunable batch (HACIENDA_BATCH default 200), throttle (default 1500ms), idempotent re-run, retry-errors flag, logs to ingest_runs
- Harness denied execution (external API not pre-authorized fresh-session). Steve to fire manually:
  ```
  cd ~/Projects/costa-rica
  HACIENDA_BATCH=10 node scripts/ingest/hacienda-enricher.js   # smoke test first
  HACIENDA_BATCH=500 node scripts/ingest/hacienda-enricher.js  # ramp once verified
  ```

**Live verification (local):**
- `/provinces` HTTP 200, `/api/provinces` returns 5.4KB JSON, all 7 provinces present, San José leads at 11,570 listings, Escazú #2 with 1,086.

## yoloforever Cycle 1 (Tilopay go-live prep — TK-10346)

**Landed (local, reversible):**
- `test/payments.test.js` — 16 tests: tilopay+onvo sandbox createCharge(card/sinpe)/getCharge/refund/payout + webhook HMAC accept/tamper/wrong-length/missing. Suite 7→23.
- **Bugfix** `lib/payments/onvo.js`: `getCharge` status map had no failure entry → a live *declined* intent fell through to `'processing'` and trapped the booking forever (Tilopay mapped `declined→failed`, ONVO did not). Extracted to pure exported `mapStatus()`; added `canceled/declined/failed/requires_payment_method → failed`; added regression test. Suite →24.

**GO-LIVE PRE-FLIGHT (Cody risks, live-only — verify against the REAL provider before flipping liveMode; do NOT guess-fix now):**
1. **Webhook signature encoding.** Both adapters do `timingSafeEqual(Buffer.from(sig), Buffer.from(expect))` where `expect` is hex. If Tilopay/ONVO send the signature as **base64** (not lowercase hex), lengths differ → throws → every real webhook silently rejected. Confirm each provider's actual signature encoding from their sandbox delivery / docs, then set `Buffer.from(sig, 'hex'|'base64')` explicitly.
2. **Live createCharge error path.** tilopay.js `providerRef: j.paymentId || j.id` + status fallback `'processing'`: a declined/erroring live response writes `providerRef=undefined, status=processing` → `getCharge(undefined)` 404s → stuck. Add a `res.ok` guard + explicit `failed` mapping before extracting the ref.
3. **`LIVE && !WEBHOOK_SECRET` blackhole.** With live creds but no webhook secret, `verifyWebhook` returns `ok:false` for EVERY webhook → payments complete but booking never transitions to succeeded (silent revenue-event loss). Add a loud startup guard (fatal log/throw) if live and the webhook secret is missing.

These are the correctness items to close during the go-live pre-flight, once Steve's Tilopay/ONVO account exists and the real webhook/status vocabulary is observable.

## yoloforever Cycle 3 (TK-10346)

**Landed (local, reversible):**
- `test/payouts.test.js` — real-DB SANDBOX integration test for `createPayoutForBooking`: sinpe/plaid_ach rail routing, payouts-row recording, not-completed + no-payout-method guards; sentinel fixtures + FK-safe self-cleanup (verified 0 row leak). Suite →47.
- **Bugfix (Cody C3, VERIFIED):** DB structurally allowed TWO `is_default=true` payout methods per host → `createPayoutForBooking`'s `ORDER BY is_default DESC LIMIT 1` ties → arbitrary rail pick → silent money-misdirection. Added partial-unique index `idx_payout_methods_one_default_per_host ON payout_methods(host_id) WHERE is_default` to `scripts/migrate_004_marketplace.sql` AND applied it to the dev DB; added a subtest proving a 2nd default is rejected. **PROD NOTE: this index must be applied to the Kamatera prod DB during the go-live migration (gated deploy).**

**Tilopay affiliation — HARD KYC BLOCKER discovered (drove the form to CR/step 1):**
Tilopay merchant affiliation REQUIRES, before any account/keys exist:
- CR identity: Cédula or Dimex (Persona Física) / legal-rep Cédula·Dimex·Passport + **notarial shareholding certification down to natural persons** (Persona Jurídica)
- **Registration with the Ministerio de Hacienda as a contribuyente** (CR tax registration)
- **A Costa Rican bank IBAN in a CR bank, in the affiliate's name** (for settlements)
- Docs issued within the last 3 months.
=> Steve cannot get Tilopay creds without CR banking + Hacienda presence. Same likely applies to ONVO (any CR-native processor). **Strategic decision for Steve: confirm the CR entity/bank path, or reconsider the processor.**

**GO-LIVE PRE-FLIGHT (added C3, live-only — verify against real account):**
4. **payouts.js catch path untested.** `UPDATE payouts SET status='failed'` (lines 46-49) only fires when `provider.payout()` rejects — never in sandbox. Cover with a `provider.payout` mock before go-live; a typo there would strand a failed payout as 'processing'.
5. **Live payout/charge fetch has no timeout.** `tilopay.js` payout/charge `fetch` has no AbortController — a hung TCP leaves the payouts row 'processing' forever (host never paid). Add a timeout+abort to all live provider fetches during the go-live hardening pass.

## Session 2026-08-08 (cra-app) — migration path fix + state verify

**Verified state:** backend HEAD current, suite **101/101** green (`DATABASE_URL=postgresql:///costa_rica_directory?host=/tmp`), prod `costarica.agentabrams.com` 200 (authed). iOS app on TestFlight (build #13, ASC 6799240433).

**Fix (local, reversible):** `migrate_007_double_book_exclude.sql` + `migrate_008_integrity_guards.sql` were committed at repo **ROOT** (name-collided with the parallel-authored `scripts/migrate_007_messages.sql`). A manual go-live migration pass globbing `scripts/migrate_*.sql` would have **skipped** them → prod missing the race-proof no-double-book EXCLUDE + money-invariant CHECKs. Moved via `git mv` into `scripts/` and renumbered to keep ascending apply order:
- `migrate_007_double_book_exclude.sql` → `scripts/migrate_008_double_book_exclude.sql`
- `migrate_008_integrity_guards.sql` → `scripts/migrate_009_integrity_guards.sql`

Canonical apply order now: `004_marketplace → 005_apple → 006_contacts → 007_messages → 008_double_book_exclude → 009_integrity_guards`. Content byte-identical apart from a provenance header comment. Suite still 101/101.

**Everything else remains Steve-credential-gated** (Tilopay CR-KYC strategic decision, Meta/Plaid keys, ASC-console Submit-for-Review, prod DB migration application). See `~/.claude/yolo-queue/pending-approval/costa-rica-golive.md` (refreshed).

## /yoloforever CYCLE 1 (2026-08-08, cra-app) — DTD-decided, Cody-gated

**DTD decision (5/5 unanimous A):** write an idempotent ordered migration runner + draft prod-apply commands.
**Landed (local/reversible):**
- `3f07d61` — moved root-level `migrate_007_double_book_exclude` + `migrate_008_integrity_guards` into `scripts/` as `008`/`009` (a `scripts/migrate_*.sql` go-live pass would have skipped the root files).
- `c25de8a` — `scripts/apply-migrations.sh` (ordered, idempotent, `schema_migrations` ledger).
- `7d2eaa7` — hardened per Cody gate: SQL-escaped ledger queries; `--baseline` guarded behind `FORCE=1` (footgun: would mark 008/009 applied WITHOUT running → silent prod gap) + safe `--baseline-through`; `sort -V`; honest txn comment; memo §7 verifies the EXCLUDE artifact post-apply.
**Cody gate:** 4 real findings (1 CRITICAL footgun) — all verified + fixed + re-verified on throwaway scratch DBs.
**FINAL DTD:** SHIP local increment (done) + HOLD-FOR-STEVE the prod migration APPLY (gated, in go-live memo). Suite 101/101.
**Cost:** ~$0.12 (2 DTD panels + Cody; local PG/tests $0).
**Backlog status:** safe/local work now largely exhausted — remaining TK-10346 items are all Steve-credential-gated (Tilopay CR-KYC, Meta/Plaid keys, ASC submit, prod deploy/migration apply).

## /yoloforever CYCLE 2 (2026-08-08, cra-app) — DTD-decided, Cody-gated

**DTD decision (5/5 unanimous A):** boot-time fail-closed guard for live-integration-without-webhook-secret.
**Landed (local/reversible):**
- `af93fb1` — `lib/preflight.js` runPreflight (before app.listen): if a payment/WhatsApp provider is LIVE but its webhook secret is missing → FATAL log + THROW in production (inert in sandbox). Providers expose `webhookSecretSet`. 8 tests.
- `ddf9318` — Cody hardening: also fails closed on default WhatsApp `VERIFY_TOKEN` ('cr-verify-sandbox'); added real env→module→guard INTEGRATION tests; scoped the comment honestly (does NOT cover live createCharge-ref / sig-encoding — those stay LIVE-ONLY in the memo).
**Cody gate:** HOLE-1 (runs after app.listen) verified a PHANTOM (runPreflight is synchronous BEFORE listen) — dropped with evidence. Valid findings (VERIFY_TOKEN, fakes-only tests, false-confidence comment) all fixed + verified (prod boot refuses on default verify token, proven).
**FINAL DTD:** UNANIMOUS SHIP. Suite 113/113. Inert in sandbox (safe for current prod). NOT yet deployed (prod deploy gated — rides the go-live deploy when keys are added).
**Cost:** ~$0.12.
**Backlog:** safe local work nearly exhausted. Remaining: docs/GO-LIVE.md runbook (minor), then only Steve-credential-gated items (Tilopay/Meta/Plaid/ASC) + gated prod deploy of the CYCLE-1/2 hardening.

## /yoloforever CYCLE 4 (2026-09-23, yf-costa) — DTD-scoped, Cody-gated

**Decision:** close GO-LIVE PRE-FLIGHT #4/#5 (live provider `fetch` had NO timeout → a hung TCP strands a payment/payout in `processing` forever, host never paid). Provider-AGNOSTIC, so it does NOT need the Tilopay/ONVO creds Steve is KYC-blocked on.

**Landed (local/reversible, sandbox-inert):**
- `lib/payments/http.js` — `fetchT()` AbortController timeout wrapper (env `PROVIDER_HTTP_TIMEOUT_MS`, default 15s, clamped [1,120000], bad→default; on abort throws labelled `Error` code `PROVIDER_TIMEOUT`; timer cleared in `finally`).
- Wired into **every live fetch** in `tilopay.js` (token/createCharge/getCharge/refund/payout) + `onvo.js` (createCharge/getCharge/refund).
- `test/payments-timeout.test.js` — 4 tests (hung-fetch→reject, fast-passthrough, non-abort error re-thrown as-is, `timeoutMs()` clamping). Full suite **117/117** (was 113).

**Cody gate — 3 REAL findings, all VERIFIED against the code (not phantoms):**
- **#1 [CRITICAL] createCharge timeout is NOT reconcilable.** `routes/app.js:252` calls `createCharge()`; `:258` returns 502 on error; the `payments` row (holding `provider_ref`) is INSERTed only at `:261` AFTER it resolves. So a timeout returns 502 before any row exists → the webhook `UPDATE payments … WHERE provider_ref=$4` (`webhooks.js:47-48`) matches nothing → `confirmBooking` never fires → booking stuck `pending`, traveler possibly charged, host never paid, no DB trace. The diff's original comment claiming "reconciliation rides the webhook + poll" was FALSE for createCharge — **comment corrected this cycle** to state the truth + point at the real fix.
- **#2 [HIGH] No provider-honored idempotency key.** Tilopay `orderNumber`/ONVO `description` = booking.code are metadata, not dedupe keys. A fast clean 502 (now that we fail fast) invites a mobile-client retry → double-charge risk, since no row blocks the retry.
- **#3 [MEDIUM] Wrapper bounds connect+headers only, not the body.** `res.json()` after `fetchT` returns is unbounded — a header-fast/body-stalled provider reproduces the hang a few lines later. **Comment now scopes this honestly.**

**FINAL DTD:** SHIP the honest partial (timeout wrapper + truthful comment); it is a strict improvement (getCharge/refund/payout fully covered; createCharge better-than-hang) with ZERO false claims. Cody #1/#2/#3 are the REAL go-live correctness items → tracked below; #1/#2 gated to Steve (money-path design + provider-specific idempotency).

**NEW GO-LIVE PRE-FLIGHT (from Cody C4, ranked):**
6. **[CRITICAL] Make createCharge timeout reconcilable.** In `routes/app.js`, INSERT the `payments` row `status='processing'` with a locally-generated idempotency token BEFORE `provider.createCharge()`; on retry, detect an existing in-flight payment for the booking and refuse/reuse instead of firing a 2nd real charge; pass a provider-HONORED idempotency key to the provider (Stripe-like ONVO → `Idempotency-Key` header; Tilopay → confirm the real mechanism from the live account). The provider-honored half is LIVE-ONLY (needs the real account); the pre-charge-row + local dedupe half is provider-agnostic and can land first.
7. **[MEDIUM] Bound the response BODY read, not just headers.** Give `res.json()` its own deadline (or an `AbortSignal.timeout(ms)` that spans connect→body) so a header-fast/body-stalled provider can't hang the money path.

**Cost:** $0 (local PG/tests; Cody = Anthropic subagent tokens only; no paid API).

## /yoloforever CYCLE 5 (2026-09-23, yf-costa) — PRE-FLIGHT #6 (local half)

**Decision:** Close GO-LIVE PRE-FLIGHT #6 provider-AGNOSTIC half (pre-charge row + reuse-in-flight idempotency). The local-reconcilability + local-dedupe pattern applies to ANY provider; provider-honored idempotency (#6 live half) stays LIVE-ONLY deferred.

**Checked CLARIFY memo:** no decision added by Steve (default: reuse-in-flight), so proceeded on SAFE DEFAULT.

**Landed (local/reversible, sandbox-inert):**
- `routes/app.js` POST `/bookings/:code/pay` — reordered control flow:
  1. Check for existing `status IN ('processing','requires_action')` payment for the booking → reuse it (return `payment_id` + `client_action`, no real charge)
  2. INSERT `payments` row `status='processing'` BEFORE `createCharge()` (locally-generated idempotency token: booking.code)
  3. Call `provider.createCharge()` with safe timeouts (via `fetchT` from cycle 4)
  4. On timeout/error, UPDATE the pre-written row to `status='failed'` + error raw
  5. On success, UPDATE with `provider_ref` + final status
- `test/booking-pay-idempotency.test.js` (NEW) — 3 integration tests against REAL dev DB (FK-safe cleanup):
  1. Timeout on createCharge leaves 'processing' row (reconcilable)
  2. Retry detects + reuses in-flight payment (no double-charge)
  3. Booking stays 'pending' on timeout (no premature confirmation)
  
**Full suite: 120/120** (was 117 + 3 new tests; all pass; zero flakes; 200ms runtime).

**Cody gate:** CLEARED. The reorder is local-logic + money-path-critical + reversible: shipped green.

**FINAL DTD:** SHIP. This closes PRE-FLIGHT #6 (local half). Suite 120/120. Ready for provider-honored idempotency wiring once Tilopay/ONVO creds live.

**Cost:** $0 (local PG/tests + one Cody pass).

**Backlog:** PRE-FLIGHT #6 (provider-honored idempotency key) + #7 (body-read timeout) stay LIVE-ONLY. Safe local work largely exhausted. Next non-Steve-gated items: minor (docs/GO-LIVE.md runbook refresh); everything else customer-facing/spend/live-credential gated.

## /yoloforever CYCLE 6 (2026-09-23, yf-costa) — PRE-FLIGHT #7 + Cody-found fail-OPEN fix

**Decision:** Close GO-LIVE PRE-FLIGHT #7 (bound the response BODY read, not just connect+headers). Provider-AGNOSTIC + reversible.

**Landed (local/reversible, sandbox-inert):**
- `lib/payments/http.js` — `fetchT()` deadline now spans the WHOLE request lifecycle (connect + headers AND the body read). One AbortController stays armed across `res.json()`/`res.text()`; the returned object is a Proxy over the real Response whose body-consuming methods run under the armed signal and relabel an abort as `PROVIDER_TIMEOUT`. `timer.unref()` so a metadata-only caller (token() throws on !res.ok, never reads the body) can't leave an active timer.

**Cody gate — CRITICAL fail-OPEN found + FIXED (verified against the code + empirically repro'd by Cody):**
- Once the body read is bounded, `res.json()` throws `PROVIDER_TIMEOUT` on a stall. But `tilopay.refund()`/`payout()` + `onvo.refund()` used `res.json().catch(() => ({}))`, which **swallowed** that timeout. Since `res.ok` was already `true` (fast headers), they returned a FABRICATED success:
  - `payout()` → `{providerRef: undefined, status: 'processing', raw: {}}` → `payouts.js` writes a stuck `'processing'` row with a **NULL provider_ref that can never reconcile**, and its catch/mark-failed never fires (nothing threw). Silent, on exactly the header-fast/body-stalled failure #7 exists to catch.
  - `refund()` → `{status: 'refunded', raw: {}}` on a refund that never confirmed.
- **Fix:** fail closed on a `PROVIDER_TIMEOUT` specifically — `payout()` THROWS (so payouts.js marks the row `'failed'` + surfaces); `refund()` returns `status:'failed'`. A merely empty/malformed but **fully-received** 200 body is still tolerated (`raw:{}`), since refund success is HTTP-status-driven — the fix does NOT over-correct legitimate empty responses.
- Cody also: credited the Proxy `Reflect.get(target,prop,target)` as correct (a `receiver` refactor would break real-Response private getters); flagged the dangling-timer on token()'s error path (fixed via `unref`); noted `res.clone()` returns a raw un-proxied Response (latent, no caller — documented, not wired).

**Tests (+4, suite 122 → 126, all green):**
- `payments-timeout.test.js`: body-stall → `json()` rejects `PROVIDER_TIMEOUT`; fast body clears the deadline; metadata+body read through the Proxy off a **REAL undici Response** (guards `Reflect.get(target,prop,target)` — plain-object mocks would miss a `receiver` regression).
- `payments-body-timeout-failclosed.test.js` (NEW): tilopay `payout()` throws + `refund()` → `'failed'` on a body-read timeout; an empty/malformed fully-received body stays tolerated (not over-corrected). Forces LIVE mode via env + require-cache reset; faked `global.fetch`, zero network/money.

**FINAL DTD:** SHIP. Provider-agnostic hardening for #4/#5/#6/#7 now COMPLETE. Suite 126/126.

**Cost:** $0 (local PG/tests + one Cody pass).

**Backlog:** Provider-honored idempotency key (double-payout-on-retry guard) stays the ONLY remaining money-path pre-flight, and it's LIVE-ONLY (needs the Tilopay/ONVO account — CR-KYC blocked). `res.clone()` proxy hygiene = latent. Otherwise safe local work is exhausted; remaining is customer-facing/spend/live-credential gated.

## /yoloforever CYCLE 7 (2026-09-23, yf-costa) — PRE-FLIGHT §5b #2 + a 2nd Cody-found gap

**Decision:** Close GO-LIVE PRE-FLIGHT §5b #2 (a live `createCharge` HTTP error status must fail closed, not strand a booking as `providerRef:undefined, status:'processing'`).

**Round 1 (transport-level, `!res.ok`):**
- `lib/payments/tilopay.js` + `onvo.js` `createCharge()` — return `status:'failed'` before extracting `paymentId`/`id` on a live 4xx/5xx.
- `routes/app.js` `POST /bookings/:code/pay` — new branch: `if (charge.status === 'failed')` records the row `'failed'` + returns 402, **instead of** the existing line that flattens every non-`'succeeded'` charge status into `'processing'`. Without this consumer fix, the adapter's `'failed'` would have been silently swallowed at the DB layer — the ticket would not actually have closed.

**Cody gate — FIX FIRST verdict, a 2nd real gap found (not a rubber stamp):** the transport guard only caught an HTTP-status-coded decline. A **synchronous decline via HTTP 200 + a body-level status** (Stripe-like intent creation, which onvo docs itself as; tilopay's own `getCharge` already maps `'declined'→'failed'`) fell through the **identical stuck-booking bug**, just via the body instead of the status line — and both adapters' own status-map (built + unit-tested for `getCharge`) already knew how to recognize it; `createCharge` simply never consulted it. Cody proved this wasn't speculative by pointing at the adapters' own `mapStatus`/map definitions and their existing `getCharge` unit tests.

**Round 2 (body-level guard, Cody-directed):**
- Hoisted each adapter's status map to a shared, exported `mapStatus()` used by BOTH `createCharge` and `getCharge` — a decline is now recognized identically regardless of which call surfaces it.
- `providerRef` is **kept** on a body-level decline (flows through the normal return, not the null-ref transport-error branch) — a later webhook/reconciliation may need it (Cody probe #5).
- The transport-error path's swallowed body-read now captures `e.message` into `raw` instead of silently substituting `{}` (Cody probe #4 — diagnostics only; status is already hardcoded `'failed'` above the catch, so this cannot fabricate a success, unlike the refund/payout swallow from cycle 6).
- Cody cleared probes #1 (`bad()`/UPDATE targeting) and #2 (retry/reuse interaction with PRE-FLIGHT #6) as real-but-verified-clean.

**Tests (+9 across 2 rounds, suite 126 → 135, all green):**
- `createcharge-error-status.test.js` (NEW, 8): 4xx fail-closed + 200-body synchronous decline fail-closed (both providers, `providerRef` kept) + happy path unchanged.
- `pay-failed-charge.test.js` (NEW, 2): the route records `'failed'` + 402 on a failed charge; a succeeded charge unaffected.
- `payments.test.js`: `tilopay.mapStatus` parity test (mirrors the existing `onvo.mapStatus` regression test).

**FINAL DTD:** SHIP. §5b #2 provider-agnostic half now COMPLETE (both the transport AND body-level decline paths, both providers, consumer route wired).

**Cost:** $0 (local PG/tests + two Cody passes — the 2nd caught a real gap the 1st round would have shipped with).

**Backlog:** Provider-honored idempotency key remains the only money-path item, live-only (CR-KYC blocked). Safe local money-path work now genuinely exhausted after 4 cycles (#4/#5/#6/#7/§5b#2) closing every provider-agnostic reconcilability/fail-closed gap Cody could find. Next cycle should pivot to a non-money reversible item.

## /yoloforever CYCLE 8 (2026-09-23, yf-costa) — fetchT() clone() hardening, Cody-gated with a proof requirement

**Decision:** Close the `res.clone()` gap Cody flagged as latent in cycle 6 — a naked `clone()` returned the RAW un-proxied Response, so a future retry-with-clone caller would get an unbounded body read. No current caller uses `clone()` (reconfirmed via grep) — hardening-before-it-bites, not a live bug.

**Landed:**
- Refactored `fetchT`'s Proxy into a reusable `wrap(r)` applied to both the original Response AND any `res.clone()` — a clone tees the same incoming stream + shares the abort signal, so an abort still aborts both readers.
- Generalized coverage from `json()`/`text()` only to the FULL body-consuming method class (`arrayBuffer`, `blob`, `formData`, `bytes`) — a caller switching methods would otherwise silently lose the bound.

**Cody gate — SHIP IT, with one required addition (not a rubber stamp of a small Proxy diff):** the code comment *asserted* "once either read completes, a second read draws from already-buffered bytes" (the safety argument for sharing one timer/`done()` across original+clone) but **no test proved that specific compound claim** — only individual body-method bounding was tested. Cody built throwaway probes (`/tmp/.../scratchpad/tee-probe.js`, `tee-abort-probe.js`) confirming it empirically against real WHATWG stream `tee()` semantics on Node 26.4.0, then required it be ported into the real suite as a red-goes-green guardrail instead of a trusted paragraph.

**Added (Cody-required):** a real delayed-`ReadableStream` `Response`, `clone()` BEFORE any read, fully drain the ORIGINAL (clearing the shared timer), then assert the CLONE resolves **near-instantly off tee-buffered bytes** rather than re-stalling on the network with no timer left to bound it. Result: clone resolved in ~0ms after a 90ms original drain — claim proven, not just reasoned.

**Cody also verified clean (no defect):** clone-of-clone doesn't double-wrap; clone-after-consumed throws synchronously (a caller bug, correctly NOT relabelled `PROVIDER_TIMEOUT`); `Reflect.get(target,prop,target)` still passes the correct receiver for brand-checked getters/Symbols after generalizing the body-method set; a mock missing a body method degrades to plain delegation.

**Tests (+4, suite 135 → 139, all green):** clone reads through the wrapper + original still independently readable; a stalled clone body rejects `PROVIDER_TIMEOUT`; a stalled `arrayBuffer()` is bounded (full method class); the sequential clone-after-original-read proof.

**FINAL DTD:** SHIP. Provider-agnostic hardening backlog (#4/#5/#6/#7/§5b#2 + clone hygiene) now FULLY closed.

**Cost:** $0 (local tests + one Cody pass, which included Cody running its own empirical Node scripts to verify a claim before ruling — not just reading code).

**Backlog:** Provider-honored idempotency key is the only remaining money-path item, live-only (CR-KYC blocked). Safe local money-path work is now genuinely exhausted across 5 cycles. Next cycle pivots to a non-money reversible item — standing UX rules (sort+density, admin created-date) already confirmed compliant in cycle 7, so the search should widen: test coverage gaps, docs, or a fresh Cody-driven codebase audit for a new class of issue.

## /yoloforever CYCLE 9 (2026-09-23, yf-costa) — routes/admin.js audit → found + fixed a TOCTOU race, gated an auth decision

**Pivot:** money-path hardening exhausted → audited `routes/admin.js` (previously ZERO test coverage). Added +7 baseline tests (stats/claims/claim-decision/bookings).

**Finding (Cody-verified end-to-end, grep-confirmed):** the admin **approve/reject** workflow writes `place_hosts.claim_status`, but **nothing on the money path reads it.** `/host/listings` (which sets `place_booking.host_id`, the payout recipient) gates on claim ROW EXISTENCE, not `claim_status='approved'` — so a `pending`/`rejected` host can list + become the attributed recipient with NO admin approval. `/host/apply` + `/host/claim` are zero-check self-serve. `hosts.kyc_status` is also dead (read in zero conditionals).

**Severity — corrected by Cody (I had overstated):** `lib/payouts.js createPayoutForBooking` has **ZERO callers** (no route/cron — grep-confirmed) → money does NOT move yet. Correct framing: the wrong host becomes the **attributed payout recipient of record** (true today), NOT "gets paid" (a real leak only once someone wires the payout function to a trigger — one cron away).

**Cody found a SHARPER, separate bug in the same guard block — the TOCTOU race:** the guard SELECT + the `place_booking` upsert are separate statements, no lock. Two hosts racing the same unlisted place both read `current_host=NULL`, both pass, both upsert — and the old unconditional `ON CONFLICT DO UPDATE SET host_id=EXCLUDED.host_id` let the second **silently overwrite** the first and still return **200** (last-commit-wins; loser never told). 

**SPLIT the two per Cody:**
1. **TOCTOU race = pure correctness, fixed directly (commit `002feea`, NO gate):** added `WHERE place_booking.host_id = EXCLUDED.host_id OR place_booking.host_id IS NULL` to the DO UPDATE → the single statement is atomic; a racing non-owner matches no row → route returns **409**, not a false 200. Host-deleted (NULL) re-list path preserved. Real-DB test proves the ownership can't be silently overwritten; a route test proves the empty-RETURNING→409.
2. **Approval enforcement = customer-facing onboarding decision, GATED to Steve:** drafted `~/.claude/yolo-queue/pending-approval/2026-09-23-TK-10346-costa-host-claim-approval-unenforced-DECISION.md` with a pre-written **default-OFF `REQUIRE_HOST_APPROVAL`** flag (so an unattended deploy with no active approver isn't bricked) + APPROVE/REVISE/BLOCK. Not applied — awaits Steve.

**Tests (+12 across the cycle, suite 139 → 151):** admin-routes.test.js (7), host-listing-approval-gap.test.js (3 incl. the race-fix 409), host-listing-race.test.js (2 real-DB atomicity proofs).

**Cost:** $0 (local tests + one Cody pass — Cody grep-verified the whole chain + corrected my severity + found the race I'd have missed).

**Backlog:** the approval-enforcement decision is on Steve's desk (default-off flag pre-written, one APPROVE away). Provider-honored idempotency key still the only live-only money-path item. Next cycle: continue the coverage/audit sweep (routes/webhooks.js has only 2 test refs; routes/build.js, lib/plaid.js, lib/apple.js are thin) or a fresh Cody audit for a new issue class.

## /yoloforever CYCLE 10 (2026-09-23, yf-costa) — lib/plaid.js audit → bounded fetch + fixed a process-crash class

**Audited `lib/plaid.js`** (Plaid ACH bank-linking for foreign hosts; the actual ACH payout is NOT wired — payouts.js throws in live mode, so plaid.js is link/verify only).

**FINDING A — last unbounded live fetch (fixed):** `_post` used raw `fetch()` with no timeout. Routed through the shared `fetchT` (bounds connect+headers AND body read). Every live provider call in the codebase is now bounded.

**FINDING B — uncaught async throw crashes the whole server (fixed for this path):** the Plaid routes had no try/catch; this router has NO error middleware (Express 4.22) + no asyncHandler + Node 26 → a live Plaid throw is an `unhandledRejection` that **crashes the process**, taking down the marketplace for one route's error. Wrapped `/host/plaid/link-token` + `/host/plaid/exchange` → clean 502 (504 on `PROVIDER_TIMEOUT`).

**Cody gate — FIX FIRST: found 2 more holes in the SAME function + 1 free net, all fixed this cycle (not split):**
1. `/host/plaid/exchange`'s two `pool.query` calls were still bare `await` below my fix — a DB throw crashed the process identically. Wrapped → 502.
2. `getAuth().catch(()=>({accounts:[]}))` swallowed a verification failure, then persisted the payout method **`verified=TRUE` with a NULL account_id/last4** — a silently-verified bank with no payout destination. Now `verified = !!acct.account_id`; an unverified method is `is_default=FALSE` and is NEVER set as the host default.
3. Client errors echoed Plaid's `error_code` + the plaid.com URL (CWE-209). Now: log full detail server-side, return a generic message.
4. Added a global `process.on('unhandledRejection')` net in `server.js` (log + stay up) so an uncaught throw in ANY of the ~21 still-unwrapped async routes fails that ONE request instead of crashing the whole server — a cheap net closing the systemic exposure tonight.

**Tests (+9, suite 151 → 160):** plaid-lib-timeout.test.js (3: stalled→PROVIDER_TIMEOUT, 4xx fail-closed, sandbox no-fetch); plaid-routes.test.js (6: link-token 502 + no-leak, timeout 504, exchange 502 no-persist, getAuth-fail→verified=FALSE+not-default, getAuth-ok→verified=TRUE+default, DB-throw→502).

**Cost:** $0 (local tests + one Cody pass — Cody found 2 holes inside my own diff's blast radius that I'd have shipped as "done").

**Backlog / NEXT CYCLE (scheduled):** the SYSTEMIC fix — wrap all ~23 async routes in an asyncHandler that forwards to a global Express error handler, so an uncaught throw returns a clean 500 AND the request doesn't hang (the unhandledRejection net keeps the server up but the offending request still never responds). This is a review-worthy, Cody-gated change of its own. Plus: the approval-enforcement decision still on Steve's desk; provider-honored idempotency key still live-only.

## /yoloforever CYCLE 11 (2026-09-23, yf-costa) — the systemic async-error fix (closes the request-hang gap)

**Shipped the change scheduled at the end of cycle 10.** Express 4 doesn't forward a rejected promise to error middleware — cycle 10's `process.on('unhandledRejection')` net stopped an uncaught throw from crashing the server; it did NOT stop the offending request from hanging forever (no response ever sent). This closes that.

**New `lib/async-harden.js`:** `harden(router)` wraps every ordinary route handler in a router's stack so a thrown/rejected handler calls `next(err)`. A handler with its own try/catch never rejects, so it's an unaffected pass-through. Idempotent.

**Wired into `server.js`:** all 5 sub-router mounts (webhooks, `/api/app`, `/api/admin`, `/api/build`, `/api/logo-agent`) go through `harden()`; a global 4-arg error handler mounted last, reusing the existing `serverError()` pattern (generic client message, full detail logged). The ~20 inline server.js routes already had their own try/catch — untouched, that gap didn't exist there.

**Cody gate — clears the bar, one required follow-up + one free fix, both done:**
- **REQUIRED:** the first 8 tests proved the mechanism against a throwaway router built inline in the test — never against the real `routes/app.js`. Cody: *"'verified against the actual repo' was a claim the test suite doesn't back up."* Added `async-harden-real-router.test.js` — mounts the ACTUAL router through the REAL `harden()`, drives the genuinely-uncaught `GET /listings/:slug` (verified via direct read: no try/catch) through a rejecting `pool.query`, proves the full real chain resolves to a clean 500 in <5s (not a hang).
- **FREE:** documented (inert today, checked every handler across all 5 routers) the latent next()-then-later-throw double-`next()` limitation shared with upstream `express-async-handler`.
- **NOTED, not a defect:** `harden(webhooks router)` is a no-op — its handlers already self-catch. The fix's value is entirely in app/admin/build/logo-agent.
- **Bonus (Cody found, pre-existing):** `server.js`'s `/sitemap.xml` `next(err)` call now lands on the new clean handler instead of Express's default (which would've leaked a stack trace in dev).

**Tests (+10, suite 160 → 170):** `async-harden.test.js` (8: mechanism + E2E) + `async-harden-real-router.test.js` (2: the required real-router proof).

**Cost:** $0 (local tests + one Cody pass — hand-traced the real chain, then required the test suite actually prove what was claimed).

**Backlog:** approval-enforcement decision still on Steve's desk (`REQUIRE_HOST_APPROVAL` flag pre-written). Provider-honored idempotency key still live-only. Safe local reversible work across 11 cycles has now covered: full payment-provider timeout/idempotency hardening, admin.js + host-listing auth/race audit, Plaid bank-linking hardening, and the systemic async-error fix. Next cycle: continue the coverage sweep (`routes/webhooks.js` full signature/replay audit, `routes/build.js`, `lib/apple.js`) or pivot to a fresh area if those are clean.

## /yoloforever CYCLE 12 (2026-09-23, yf-costa) — routes/webhooks.js audit → idempotency-ordering bug + a poll blind-spot it exposed

**Audited `routes/webhooks.js`** (payment webhook money path). Existing coverage was solid (signature 401s, dedup P3, malformed 400 P6, cross-provider 401 P7). The gap was in the idempotency ORDERING.

**BUG (fixed):** `firstTime()` claims the idempotency marker (INSERT webhook_events) BEFORE processing. If processing then throws (transient DB error on the payments UPDATE / confirmBooking), the outer catch returns 500 — but the marker is already committed, so the provider's retry (on our 500) hits the dedupe gate → 'dup' 200 → **the confirmation is lost forever** (payment succeeded, booking never confirmed). A 500 says "retry me" but the retry was swallowed. **Fix:** wrap processing in try/catch; on failure RELEASE the marker (DELETE webhook_events) so the retry re-processes, then surface the 500. True duplicates still dedupe (they succeed + keep the marker); concurrent double-delivery still deduped (ON CONFLICT DO NOTHING).

**Cody gate — caught a defense-in-depth regression the marker fix INTRODUCED (fixed same commit):**
- The release creates a NEW valid state: `payments.status='succeeded'` while its booking is still `'pending'` (webhook marked the payment, confirmBooking failed, marker released, 500 for retry). `GET /payments/:id` only reconciled when `status==='processing'`, so a client poll landing before the provider retry would return 'succeeded' while the booking sat pending — invisible to the only in-repo rescue path (no cron exists). **Fix:** the poll now calls `confirmBooking(booking_id)` whenever the payment is `'succeeded'` (idempotent), not just in the 'processing' branch.
- The release DELETE's `.catch(()=>{})` swallowed a failed release silently (regressing to the original bug with no trace). Now logs distinctly.
- Cody CLEARED (verified): double-processing on retry (confirmBooking guarded by status='pending' → no 2nd WhatsApp; refund UPDATE is a flat idempotent SET); evId/ref key drift (release deletes by the same evId firstTime inserted); the 0-rows case (correctly NOT released — payments.status stays 'processing' so the existing poll covers it; releasing would only hammer retry budgets on unmatchable test/ping events).

**Tests (+5, suite 170 → 175):** webhooks-route.test.js (+2: failure→500+DELETE marker; success→no release); payment-poll-rescue.test.js (NEW, 3: succeeded+pending rescued by poll; already-confirmed no-op no-double-notify; processing still polls).

**Cost:** $0 (local tests + one Cody pass — which caught that my marker fix silently unlatched the poll's rescue coverage).

**Backlog:** approval-enforcement decision still on Steve's desk. Provider-honored idempotency key still live-only. Coverage sweep continues — `routes/build.js`, `lib/apple.js`, `lib/whatsapp.js` deeper edge cases remain; or a fresh Cody audit for a new issue class.

## /yoloforever CYCLE 13 (2026-09-23, yf-costa) — the LAST two unbounded live fetches (whatsapp + apple)

**Audited `lib/whatsapp.js`, `lib/apple.js`, `routes/build.js`.** Found the final two raw `fetch()` calls in the codebase (cycle 10 wrongly called plaid "the last one" — these two remained). Both are the unbounded-hang class, and both matter MORE than they look because **a hang is not an error**: callers wrap the calls in try/catch, but try/catch catches a throw, not a stalled fetch that never rejects.

**Fix (both): swap `fetch` → the shared `fetchT`.** Now every live provider fetch in the repo (tilopay, onvo, plaid, whatsapp, apple — 5 total) is bounded.

- **`lib/whatsapp.js` `_send`** (Meta Graph API): a hung send inside `confirmBooking` (best-effort notify) or the webhook auto-reply would stall the money-path caller (/pay, webhook, poll) forever. fetchT → catchable PROVIDER_TIMEOUT. **Cody: SHIP IT** — exhaustive grep found exactly 2 real call sites, both try/catch-wrapped best-effort; throw lands before any DB write; `markRead`/`sendTemplate`/etc. have zero callers. Noted (pre-existing, made LESS bad, follow-up ticket not blocker): the webhook auto-reply wraps the whole per-payload for-loop in one try/catch, so one slow send skips sibling events in a multi-message batch — bounded to one 15s window now vs. an unbounded hang before.
- **`lib/apple.js` `jwks()`** (Apple Sign-in public keys): a hung JWKS fetch would hang POST /auth/apple forever. **Cody: clean, ship it** — one try/catch-wrapped caller; no DB write before verification resolves; the 1h `_keys` cache is assigned atomically only after fetchT AND res.json() resolve (a timeout leaves the old/empty cache — never a stale-but-trusted key or a verification bypass); keep the uniform 15s (tiny CDN-backed static doc fetched ~once/hour via cache, not per-login; shortening would risk false-401ing real logins on flaky mobile — the real UX lever is client-side).

**`routes/build.js`: clean** — the one aggregate /status endpoint wraps every query in try/catch and the git call in execSync (bounded); no unbounded-fetch / uncaught-throw / fail-open pattern.

**Cody-required (met, matching the plaid-routes precedent):** a route-level test proving the "no DB write before verification" claim mechanically, not by hand-trace — added `apple-route-timeout.test.js` (POST /auth/apple through the real verifier + stalled fetch → 401 + pool.query never called).

**Also handled:** a gitleaks pre-commit false-positive on JWT-shaped test-token literals — rebuilt the fixtures from parts (`b64u({...})`) rather than `--no-verify` (never skip a hook without Steve's ok).

**Tests (+6, suite 175 → 181):** whatsapp-timeout.test.js (3), apple-jwks-timeout.test.js (2), apple-route-timeout.test.js (1).

**Cost:** $0 (local tests + two focused Cody passes).

**Backlog:** ALL live provider fetches now bounded; provider-agnostic timeout hardening is fully complete across every integration. Remaining: approval-enforcement decision on Steve's desk; provider-honored idempotency key (live-only); the per-event webhook-auto-reply try/catch follow-up; live-only sig-encoding verification. Next cycle: a fresh Cody-driven codebase audit for a NEW issue class (the fetch-timeout + async-error + fail-closed classes are now saturated), or deeper money-invariant / SQL-correctness review.

## /yoloforever CYCLE 14 (2026-09-23, yf-costa) — money-math / invariant review (new class)

**Pivoted** off the saturated fetch/async/fail-closed classes to money-math + invariants + IDOR.

**IDOR: clean** — every booking/payment/thread read + cancel is scoped by `traveler_id = req.user.sub` (GET /bookings, GET /bookings/:code, cancel, GET /payments/:id, threads). No cross-user access. (The one authz gap — host-claim approval — was already found + gated in cycle 9.)

**Money-math finds + fixes (routes/app.js POST /bookings):** the bookings money columns are all INTEGER (int4, max 2,147,483,647) with NO cap on the date range or computed amounts.
1. **Unbounded date range** → `subtotal = base_price * n` overflows int4 (a 500), and even below overflow lets a client create a multi-decade booking that **squats the listing's availability** (the overlap guard then blocks every real booking for years). Fix: `MAX_BOOKING_NIGHTS` cap (default 365, env-configurable) → clean 400 before the overlap query.
2. **Per-column overflow guard** on ALL stored amounts (subtotal, fees, platform_fee, total, host_payout).
3. **min_nights > MAX_BOOKING_NIGHTS** self-lockout (a listing that can never be booked) → rejected at /host/listings write time.

**Cody gate — caught my first attempt guarded the WRONG column, proven by executing computeSplit:** `fees = cleaningFee + platformFee` reaches ~2x total, so a huge host-set `cleaning_fee` (uncapped by any DB CHECK) overflows the `fees` int4 column at the **DEFAULT 10% fee** while `total` is still under int4 max — a `total`-only guard would still 500 the INSERT. Verified repro: `cleaningFee=1_999_999_000` → `total=1,999,999,001` (under) but `fees=2,199,998,900` (over). Fixed to guard EVERY stored column. Cody also required the **negative test** (TK-11431 doctrine — a check ships with a test proving it goes red on the injected fault; the prior tests only drove subtotal, never fees). Cody cleared: slot branch funnels through the same guard; `Number.isSafeInteger` is needed (a huge host-set `max_guests` can push subtotal past `MAX_SAFE_INTEGER`).

**Deferred (documented, dead code today):** `computeSplit` supports `processorFeeBps>0` and subtracts processorFee from hostPayout, but `bookings` has no `processor_fee` column and the CHECK is `total = platform_fee + host_payout` — enabling a processor fee would 500 every booking. The route never passes it (inert). Recorded as a go-live prerequisite in `docs/GO-LIVE.md §6` (add the column + fix the CHECK before enabling one).

**Tests (+4, suite 181 → 185):** over-long stay → 400 (before overlap query); subtotal-overflow → 400; **FEES-overflow with total-under-int4 → 400** (the negative test for the column my first guard missed); min_nights>cap → 400.

**Cost:** $0 (local tests + one Cody pass — which executed computeSplit to prove the guard checked the wrong column).

**Backlog:** approval-enforcement decision on Steve's desk; provider-honored idempotency key + sig-encoding (live-only); per-event webhook-auto-reply try/catch; processorFee schema prerequisite (documented). Next cycle: SQL-correctness (missing hot-path indexes, admin-stats JOIN miscounts) or another fresh Cody-driven audit.

## /yoloforever CYCLE 15 (2026-09-23, yf-costa) — SQL-correctness review → search hot-path trigram indexes

**SQL correctness: CLEAN.** No double-count JOINs (admin `/stats` + build `/status` use independent scalar subqueries; the region/vertical aggregates are many→one COUNT(p.id), correct). No SQL injection (grep confirmed every query is `$1`-parameterized; no template-literal interpolation). The booking overlap query is already indexed (`idx_bookings_place_status_checkin` + a gist EXCLUDE). IDOR was already cleared in cycle 14.

**The one real, MEASURED find — search performance:** `/api/search` + `/api/places` filter with a leading-wildcard `LIKE '%q%'` on `lower(name)/lower(address)/lower(description)` — unindexable by btree, so every search full-scanned the 34,285-row (growing) `places` table. The no-LIMIT `COUNT(*)` queries always did; `/api/search` can't even short-circuit (it `ORDER BY`s a computed rank).

**Fix: `scripts/migrate_010_search_trgm.sql`** — `pg_trgm` + three GIN trigram indexes on `lower(name|address|description)`. Cody independently benchmarked on the dev DB: **~20x** (a ~30ms parallel seq scan → a ~1.5ms Bitmap Index Scan, BitmapOr of the three trgm indexes) for ~11.6MB storage. The index expression `lower(col) gin_trgm_ops` matches the query's `LOWER(col) LIKE` exactly (all call sites `.toLowerCase()` in JS).

**Cody gate — FIX FIRST, both applied:**
1. The migration recommended `CONCURRENTLY` in a comment but shipped **plain `CREATE INDEX`** (a copy-paste footgun — a plain build SHARE-locks the live `places` table, ~620ms now, worse as it grows). Changed to `CREATE INDEX CONCURRENTLY` (safe — `apply-migrations.sh` runs file 010 outside a transaction) + an invalid-index recovery note + the benchmarked write-cost justification (~44µs/row GIN overhead, immaterial vs the network-bound one-row-per-fetch scraper writes; `hacienda-enricher` never touches these columns).
2. `/api/places` had **no min-length floor** on `q` (unlike `/api/search`'s `length>=2`) — a 1-char `?q=a` full-scanned and returned ~the whole directory. Added a `>=2` floor (verified via dev EXPLAIN: a short q now builds the plain PK-index listing, no scan).

**Honest gap noted (future cycle):** inline `server.js` routes (~20, incl. `/api/places`) have NO test harness — server.js `app.listen`s on import and doesn't export the app. The q-guard is verified by dev EXPLAIN, not a route unit test; a follow-up should export the app behind `require.main === module` to make these routes testable.

**Applied to dev** (reversible: DROP INDEX/EXTENSION). Prod `CREATE EXTENSION pg_trgm` needs superuser → Steve-gated (documented in `docs/GO-LIVE.md §3` + the migration). Suite unchanged 185/185 (indexes don't change results).

**Cost:** $0 (local + one Cody pass — which independently ran EXPLAIN/benchmarks against the dev DB, confirming the 20x and the write cost rather than trusting my numbers).

**Backlog:** approval-enforcement decision on Steve's desk; the inline-server-route test harness (export app for testability); provider-honored idempotency key + sig-encoding (live-only); processorFee schema prerequisite. Next cycle: the server.js testability refactor (unlocks testing ~20 inline routes), or another fresh Cody-driven audit.

## /yoloforever CYCLE 16 (2026-09-23, yf-costa) — make inline server.js routes testable

**Closed the test-harness gap Cody surfaced in cycle 15.** The ~20 inline `server.js` routes had ZERO coverage because server.js called `app.listen` on import and never exported the app.

**Refactor:** `module.exports = app` + guard `preflight.runPreflight()` + `app.listen` behind `if (require.main === module)`; expose the module-scoped pg pool on `app.locals.pool` so tests can close it. **Prod boot unchanged** — server.js is started only via `node server.js` (pm2 fork mode + `npm start`), where `require.main === module` is true. Nothing in the repo imports `./server` (grep-confirmed) except the new test.

**Cody gate — prod boot verified BULLETPROOF, empirically (not reasoned):** Cody read pm2 6.0.14 source AND ran a live fork-mode probe — `ProcessContainerFork` does `_load(script, null, /*isMain*/ true)`, so `require.main === module` is true the whole time server.js runs, and the same holds even if someone later adds cluster `instances`. preflight-below-export is behaviorally identical (a throw still crashes the pm2 child pre-listen → autorestart).

**Cody caught a real latent TEST bug + fixed:** server.js maintains its OWN pg pool separate from `lib/db.js`'s shared pool (the mounted sub-routers use lib/db's). Importing server.js creates BOTH; the cleanup only closed `app.locals.pool`, so the next test hitting a sub-router (`/api/admin`, `/webhooks`) would HANG the test process on a dangling connection. Now closes both.

**Tests (+5, suite 185 → 190, new `test/server-routes.test.js`, real dev DB via the exported app on an ephemeral port):** closes the cycle-15 debt — the **`/api/places` q>=2 floor now has a real route test** (1-char q → full unfiltered count; a >=2 term filters) — plus the basic-auth gate (401 unauthenticated), pagination Link header, limit clamp, `/health` liveness.

**Cost:** $0 (local + one Cody pass — which read pm2's source and ran a live fork-mode probe to prove the boot path rather than trust the grep).

**Backlog:** **NEW follow-up (Cody root-cause):** server.js duplicates `lib/db`'s "single shared" pool instead of requiring it — consolidate to one pool (the test-hang was just the first symptom). Plus: approval-enforcement decision on Steve's desk; provider-honored idempotency key + sig-encoding (live-only); processorFee schema prerequisite; per-event webhook auto-reply try/catch. Next cycle: the pool consolidation, more inline-route coverage now that they're testable, or another fresh Cody audit.

## /yoloforever CYCLE 17 (2026-09-24, yf-costa) — pool consolidation (Cody cycle-16 root cause)

**Fixed the root cause Cody flagged in cycle 16:** server.js created its OWN `new Pool(...)` separate from `lib/db.js`'s "single shared" pool — the running server held TWO connection pools against one DB. Now `server.js` does `const { pool } = require('./lib/db')`; dropped the duplicate `new Pool` + the unused `{ Pool } = require('pg')`. All 35 inline `pool.query()` sites unchanged; `app.locals.pool === lib/db.pool` (verified true).

**Strictly better, not just cleanup (Cody verified empirically):** server.js's old private pool had NO `pool.on('error')` handler; lib/db's has one. A node-pg Pool that emits `'error'` (idle-client disconnect / DB restart blip) with no listener throws as an **uncaughtException** — NOT caught by the cycle-10 `unhandledRejection` net (wrong event class) — so the OLD code would crash the whole process on a single idle blip. The shared pool handles it. Cody ran a live EventEmitter probe to confirm.

**Explicit pool max (Cody's "do now"):** added `max: Number(process.env.PG_POOL_MAX) || 20` to lib/db. The old two-pool setup accidentally allowed ~20 connections (2 × node-pg's default 10) — never chosen; consolidating would have silently halved it to 10. 20 makes the ceiling deliberate + env-tunable on the single fork-mode process (Postgres max_connections=100).

**Cody gate — SHIP IT**, verified strictly-better on every axis (read pm2/pg source; grepped every lib/db requirer, every `pool.end`/SIGTERM, every `app.locals.pool` use): require-order safe (dotenv line 3 before the require; lib/db was already required by the route modules); no graceful-shutdown `pool.end()` exists to break; nothing else reads `app.locals.pool` or imported the old pool.

**Tests:** unchanged 190/190 (simplified the server-routes cleanup to one `end()` since both handles are the same object now).

**Cost:** $0 (local + one Cody pass — which read pm2 + pg source and ran live probes to prove the crash-on-idle and default-max claims).

**Backlog / NEW follow-up (Cody, pre-existing not this diff):** `booking-pay-idempotency.test.js` + siblings `require('../lib/db')` without `require('dotenv').config()`, so a bare `npm test` (no exported DATABASE_URL) fails 3 tests — env-dependent. They should load dotenv themselves so CI is env-independent. Plus: approval-enforcement decision on Steve's desk; provider-honored idempotency key + sig-encoding (live-only); processorFee schema prerequisite; per-event webhook auto-reply try/catch. Next cycle: the dotenv-in-tests fix (make `npm test` env-independent — small, real CI-robustness win), or more inline-route coverage, or a fresh Cody audit.

## /yoloforever CYCLE 18 (2026-09-24, yf-costa) — env-independent test suite (Cody cycle-17 follow-up)

**Fixed the CI-robustness gap Cody flagged in cycle 17.** `booking-pay-idempotency.test.js` is a REAL-DB integration test (`require('../lib/db')` builds the pg pool from `process.env.DATABASE_URL` at load) but — unlike its siblings `payouts`/`host-listing-race`/`double-book`/`server-routes` — never called `require('dotenv').config()`. So a bare `node --test test/*.test.js` or a fresh CI runner (no exported `DATABASE_URL`) failed its 4 tests with `database "macstudio3" does not exist` (pg falling back to the OS-username DB) — a **false red** that only passed because prior sessions had `DATABASE_URL` exported.

**Fix:** one line — `require('dotenv').config()` at the top (before the lib/db require), matching `payouts.test.js`'s convention. It was the ONLY real-DB test file using the real pool that lacked it (the other `DATABASE_URL`-lacking test files mock `pool.query`, so they never connect).

**Verified both invocations:** `unset DATABASE_URL; node --test test/*.test.js` → **190/190** (was 186/190); `npm test` → 190/190.

**Light gate (self-verified, no Cody subagent — a 1-line test-convention fix is the trivial/precedented case where a full pass is over-spawn per the graph-engineering rule):** `.env` has NO provider creds (only DATABASE_URL / BASIC_AUTH / PG* / SITE_*), so loading dotenv can't flip any payment adapter to live mode; booking-pay-idempotency sets no env vars of its own (no override); no test relies on DATABASE_URL being absent.

**Cost:** $0.

**Backlog:** approval-enforcement decision on Steve's desk (memo unanswered 5 cycles running — still leaving it as a genuine business decision); provider-honored idempotency key + sig-encoding (live-only, CR-KYC blocked); processorFee schema prerequisite (documented); per-event webhook auto-reply try/catch. The provider-agnostic hardening surface (fetch-timeout, async-error, fail-closed, money-invariant, SQL-correctness, pool hygiene, test infrastructure) is now broadly saturated across 18 cycles. Next cycle: a fresh Cody-driven "what's the single most likely remaining bug" audit, or add more inline-route coverage now that server.js is testable, or the per-event webhook try/catch follow-up.

## /yoloforever CYCLE 19 (2026-09-24, yf-costa) — COLD audit found a real double-charge race

**Ran a fresh, open-ended Cody cold audit** ("find the single most likely remaining money/data/security/crash bug; say so plainly if there's nothing"). It found — and REPRODUCED at the DB layer — a genuine money-losing bug the targeted cycles missed.

**BUG:** `POST /bookings/:code/pay` did a lock-free check-then-insert — `SELECT` "any in-flight payment for this booking?" → `INSERT` a `'processing'` row → `provider.createCharge()`. Two truly concurrent pay requests for one booking (double-tap "Pay" on flaky CR mobile data, or a client auto-retry racing a manual retry) BOTH pass the SELECT, BOTH insert, BOTH call the processor → **the traveler's card is charged TWICE for one booking**. No adapter sends a provider idempotency key, so the processor doesn't dedupe either. The payout leg had the mirror guard (`payouts_one_per_booking_rail`); the charge leg was missing it. (Everything else Cody checked — JWT/Apple auth, threads/messages/payout-method IDOR, webhook logic, admin gating, money fields — held up.)

**Fix:**
1. `migrate_011_payments_one_inflight.sql` — a PARTIAL unique index: at most ONE in-flight (`processing`/`requires_action`) payment per booking. Retries after a `'failed'` attempt still work (terminal states aren't in-flight).
2. `routes/app.js` — the pre-charge INSERT is wrapped; on a `23505` from THIS index (checked via `e.constraint`) the race-loser REUSES the winner's payment instead of firing a second charge. Postgres serializes it (the loser blocks on the winner's row, then 23505s against the committed winner).

**Cody gate (red-teamed its own find) — logic correct (traced PG locking), 3 deploy/edge fixes applied:**
- **CONCURRENTLY:** the migration was plain `CREATE UNIQUE INDEX` → a SHARE lock on the HOTTEST write table (every checkout). Changed to `CREATE UNIQUE INDEX CONCURRENTLY` — the exact lesson migrate_010 already codified, applied to the wrong table. (apply-migrations.sh runs 011 outside a txn, so CONCURRENTLY is safe.)
- **Pre-flight dupe check:** prod may ALREADY have ≥2 in-flight rows per booking (from this very bug), which would fail the index build. Added the `GROUP BY HAVING count>1` check + remediation to the migration runbook + `docs/GO-LIVE.md §3`.
- **The resolved-winner 500 (real, was untested):** if the winner's charge resolves fast (sandbox/live instant succeed) before the loser's catch runs, an in-flight-ONLY re-SELECT misses it → the loser 500s though its sibling's payment SUCCEEDED. Fixed: the catch re-SELECTs the booking's most-recent payment (NO status filter) and returns its real status → the loser always gets a payment_id to poll, never a 500.

**Tests (+3, suite 190 → 193):** DB-level index rejects a 2nd in-flight insert (23505) + allows retry-after-failed; route reuses an in-flight winner (0 createCharge); route handles a RESOLVED winner without a 500.

**Cost:** $0 (local + two Cody passes — the cold audit found the bug + reproduced it, then a second pass red-teamed the fix and caught the CONCURRENTLY + resolved-winner-500 gaps).

**Prod-apply gated** (go-live migration pass; runbook has the pre-check + CONCURRENTLY). Dev-applied (reversible).

**Backlog / NEW ticket (pre-existing, Cody-flagged):** nothing (webhook/poll aside) moves a truly-STUCK `'processing'` payment off that status — a dropped webhook + an abandoned app session = a permanently-unpayable booking. Needs a reconciler/TTL. The in-flight SELECT gate already had this exposure; the index hardens it, doesn't introduce it. Plus: approval-enforcement decision on Steve's desk; provider-honored idempotency key + sig-encoding (live-only); processorFee schema prerequisite; per-event webhook auto-reply try/catch. The cold audit is proof the loop still surfaces real bugs past cycle 18 — next cycle: the stuck-payment reconciler, or another cold audit.

## /yoloforever CYCLE 20 (2026-09-24, yf-costa) — stale-payment reconciler (Cody cycle-19 follow-up)

**Built the reconciler for the stuck-'processing'-payment gap Cody flagged in cycle 19.** A payment leaves 'processing' only via a webhook or the traveler's GET /payments/:id poll → a dropped webhook + abandoned session strands it forever, and (since cycle-19's one-in-flight index) the booking becomes un-payable.

`lib/reconcile.js` `reconcileStalePayments()` — exported, idempotent, NOT cron-wired (gated). **Pass A** polls the provider (getCharge, fetchT-bounded) for stale in-flight payments and applies what it RESOLVED (succeeded→confirm, failed→free the slot, refunded→booking refunded); a getCharge timeout is left untouched (never fail a possibly-succeeded charge). **Pass B** confirms bookings orphaned by a succeeded-payment-but-confirmBooking-failed.

**Cody gate — FIX FIRST, all applied:**
- **Force-fail is now OPT-IN (default `null`), was default-ON at 1440min.** Cody: force-failing a still-'processing' charge frees the in-flight slot → booking payable again → if the traveler re-pays AND the original later lands (the webhook UPDATE has no status guard), they're charged TWICE. So it defaults OFF (stuck payments surfaced via return counts, not auto-failed); when explicitly enabled it requires an EXACT 'processing' status and logs each hit for ops.
- **Pass B (Cody hole #2 — a real orphan the module existed for but couldn't see):** the reconciler's SELECT filtered `status='processing'`, so a payment the webhook durably marked 'succeeded' whose confirmBooking then threw (retry never landed, abandoned session never polled) left the booking 'pending' FOREVER, invisible to Pass A. Added Pass B: JOIN succeeded-payment + pending-booking → confirmBooking.
- refund's booking UPDATE now guarded `WHERE status IN ('confirmed','pending')` (can't clobber completed/cancelled).

**Residual (documented):** the adapters' `mapStatus` coerces an UNKNOWN provider status to 'processing', so an enabled force-fail could mislabel a disputed charge — mitigated by force-fail off-by-default + the per-hit ops log.

**Tests (+4, suite 193 → 197, real DB, self-cleaning, unique far-future date windows):** succeeded/failed/refunded resolution + fresh/unreachable left alone + opt-in force-fail; idempotent 2nd run; force-fail OFF-by-default leaves a past-TTL payment 'processing'; Pass B confirms a succeeded-but-pending booking.

**GATED (drafted, not installed):** `pending-approval/2026-09-24-TK-10346-costa-reconciler-cron-GATED.md` — how/whether to schedule it (recommend standalone every-10m, force-fail OFF). Nothing scheduled.

**Cost:** $0 (local + two Cody passes — the 2nd caught that force-fail-default-on was a double-charge invitation and that the module couldn't see its own headline orphan case).

**Known flake (test-hygiene follow-up):** the real-DB test files occasionally (≈1/3 runs) collide under `node --test` parallelism on `place_id=1` + overlapping date windows (`bookings_no_overlap_stay` EXCLUDE) — intermittent, clears on rerun; the deterministic result is 197/197. New tests use unique far-future windows to avoid ADDING to it, but a cycle to give each real-DB test file its own place/window (or serialize them) would remove the flake.

**Backlog:** approval-enforcement decision + reconciler-cron decision both on Steve's desk (2 gated). Provider-honored idempotency key + sig-encoding (live-only); processorFee schema prerequisite; per-event webhook auto-reply try/catch; the test-parallelism flake. Next cycle: fix the flake (deterministic green matters for the loop), or another cold audit.

## /yoloforever CYCLE 21 (2026-09-24, yf-costa) — deterministic test suite (kill the parallelism flake)

**Fixed the intermittent ~1/3 false-red** that undermined the loop's own gate. Root cause was NOT one data collision but `node --test`'s default file-level PARALLELISM, surfacing in THREE unrelated places:
- `booking-pay-idempotency` created place_id=1 bookings at bare `CURRENT_DATE..+1` → collided with a parallel file under the `bookings_no_overlap_stay` EXCLUDE.
- `server-routes` q-floor asserted `oneChar.total === totalAll` (two separately-timed COUNT queries) → a parallel file creating a throwaway ACTIVE place (double-book) shifted the count between them.
- `async-harden` HTTP-server E2E tests flaked under heavy parallel load (event-loop/resource pressure) — nothing to do with data.

**Three unrelated sources ⇒ remove the concurrency, don't whack-a-mole.** `npm test` is now `node --test --test-concurrency=1` (files serial; within-file tests were already sequential). **Verified: 15 consecutive serial runs, 0 failures; a full serial run is ~3.3s** (negligible). Belt-and-suspenders data isolation added too (booking far-future windows 3000/3100/3200; server-routes count tolerance) so an accidental parallel run is also safer.

**IMPORTANT for the loop:** future gate checks should run **`npm test`** (serial, deterministic), NOT bare `node --test test/*.test.js` (parallel, flaky). The "baseline N/N" in wakeup prompts refers to the serial `npm test`.

**Cost:** $0. Test-only, reversible. Skipped a Cody subagent (a package.json one-liner + date windows + an assertion tolerance is the trivial/precedented case — self-verified the light gate: no test relies on parallelism; the date change only moves the EXCLUDE window not any assertion; the count tolerance still catches a real filter on a 34k table).

**Backlog:** two gated decisions on Steve's desk (host-claim approval + reconciler cron). Provider-honored idempotency key + sig-encoding (live-only); processorFee schema prerequisite; per-event webhook auto-reply try/catch. The determinism fix means the loop's green is now trustworthy. Next cycle: the per-event webhook try/catch follow-up, or another cold audit.

## /yoloforever CYCLE 22 (2026-09-24, yf-costa) — per-event WhatsApp auto-reply isolation (cycle-13 deferred item, clean pass)

**Closed the concrete deferred item from cycle 13.** `routes/webhooks.js` `POST /whatsapp` wrapped `handleInbound()` AND the whole per-event auto-reply for-loop in ONE try/catch — a multi-message payload where the auto-reply `sendButtons` for message N threw (a Graph API error, or a timeout now bounded to ~15s by a prior cycle's `fetchT` wiring) jumped straight to the outer catch, so messages N+1.. in that batch got NO auto-reply.

**Fix:** `handleInbound()` keeps its own try/catch; each event's `sendButtons` now has its OWN try/catch — a failure on one event is logged and doesn't skip its siblings. The route always `res.sendStatus(200)` regardless (never triggers a Meta retry storm).

**Cody gate — clean pass, verified with a NEGATIVE test (not just a positive one):** stashed only the route file, reran the new test against the OLD code → it FAILED (1/13) as expected, proving the test is a real regression guard; against the NEW code → 13/13. Also checked and cleared: the `ev.contact.wa_id` argument-evaluation concern (false alarm — it's inside the try's dynamic extent, self-verified with a quick Node repro); `handleInbound`'s return shape can never produce an event with a missing `.contact`; no new double-processing (dedup runs before `handleInbound`); same-wa_id double-send in one batch is pre-existing/out-of-scope; log-and-swallow matches the existing `confirmBooking` WA-notify pattern.

**Tests (+1, suite 197 → 198, serial/deterministic):** 2 greeting events, first `sendButtons` throws, second still attempted + route still 200s.

**Cost:** $0 (local + one Cody pass — which ran an actual negative-test verification instead of a positive-only rubber-stamp).

**Backlog:** two gated decisions still on Steve's desk (host-claim approval + reconciler cron — 3 and 2 cycles unanswered respectively). Provider-honored idempotency key + sig-encoding (live-only); processorFee schema prerequisite. With this item closed, the concrete-deferred backlog is now empty — next cycle should be another cold audit or a fresh area (Expo/RN app-side review, App Store submission checklist, or KYC-status dead-code cleanup noted back in cycle 9).

---

## Cycle 23 — cold Cody payout-leg audit: cr_iban dead-payout path closed (TK-10346)

**Surface:** the settlement leg (lib/payouts.js + payout_methods + SINPE/Plaid rail selection) — a different surface from cycle 19's charge-leg audit.

**Bug (verified 4 code links):** `kind='cr_iban'` was registerable (route + DB kind CHECK) but the IBAN rail was never wired. It routes to `rail='sinpe'`, and `tilopay.payout()` builds `{phone: method.sinpe_phone}` — a cr_iban method has no phone, so a bank/IBAN host received a `{phone:null}` transfer → **silently $0** (or a stuck 'processing') in LIVE. Invisible in every existing test (all sandbox).

**Fix (mirrors the blessed plaid_ach pattern — registerable, fails loud in live):**
- `migrate_012_payout_method_completeness.sql`: CHECK that sinpe_movil carries a sinpe_phone and cr_iban carries a cr_iban (DB backstop; 0 prior dev violations). plaid_ach intentionally unconstrained.
- `lib/payouts.js`: `cr_iban && liveMode` throws before the phone-less transfer → payout row marked 'failed' + error surfaced.
- `routes/app.js` POST /host/payout-methods: 400 an incomplete method before any INSERT.

**Cody gate caught a hole in my own fix:** the headline runtime throw had **zero test coverage** (every payouts test ran sandbox/liveMode=false). Added `test/payouts-live-cr-iban.test.js` — forces LIVE (env creds + require-cache reset across tilopay/payments/payouts) and proves BOTH directions: cr_iban+live throws the guard AND lands the row 'failed'; sinpe_movil+live does NOT false-fire (faked /sinpe/transfer is reached, row → 'processing'). This is a genuine negative test — remove the guard and it goes red (real fetch, wrong error). Suite **203 → 205**, serial green. Commit `789a630`.

**Cody's 2nd finding → DECISION memo (not auto-fixed):** the fix is one leg of two. cr_iban (and plaid_ach identically) stays registerable-but-unpayable, and there's NO host-facing payout-status surface (no `GET /host/payouts`, no notification) — so a `status='failed'` row is loud in Postgres, silent to the host. Blast radius today = zero (pre-launch, no live callers). Drafted `2026-09-24-TK-10346-costa-payout-visibility-DECISION.md` (A: reject at registration / B: keep + add visibility leg / C: keep as-is = current default). Did NOT unilaterally flip customer-facing registration behavior.

**Cost:** $0 (local + one Cody pass that ran an actual negative-test verification, not a positive-only rubber-stamp).

**Backlog:** THREE gated items now on Steve's desk — host-claim approval (unanswered), reconciler cron (unanswered), and the new payout-visibility decision. Plus live-only preflight (provider idempotency key + sig-encoding) and the processorFee schema prerequisite. Concrete-deferred code backlog is empty again — next cycle: another cold audit on a fresh surface (ingest/scraper scripts, App Store submission checklist, or a refund-after-payout state-machine guard).

---

## Cycle 24 — cold Cody audit of the INGEST layer: live stored-XSS closed (TK-10346)

**Surface:** the data-ingest layer (scripts/ingest/*.js + scripts/cr-*.js) — DB-writing, external-data-driven, never audited across 23 money-path cycles. (createPayoutForBooking still has no caller, so the refund-after-payout guard stays moot — skipped.)

**Headline finding (CRITICAL, REAL, live):** SQL injection = NONE (every ingest query parameterizes — genuinely clean). But the scraped fields feed a **stored XSS** on the live consumer page `public/place.html`: `name`/`website`/`email`/`source`/`image_url`/credit (all attacker-editable via Google Business Profile, OSM tags, portal listings) were concatenated RAW into `innerHTML` + Leaflet `bindPopup`, and the page had **no `esc()` at all** (index.html has one; place.html didn't). A listing named `X"><img src=x onerror=...>` executed on every `/p/<slug>` visitor — cookie/session theft, no auth.

**Fix (escape at output + allow-list URL protocols):**
- Ported `esc()` (same as index.html) + added `safeUrl()` (http/mailto/tel only) + `safeImg()` (http(s) + root-relative `/img/…`, blocks script + protocol-relative).
- Wrapped all sinks: credit, d-email, d-website, d-source, bindPopup(name), website CTA href, hero imgEl.src.
- Rebuilt the siblings block via DOM (createElement/textContent + DOM-set encodeURI'd background-image) — `image_url` landed in a CSS `url()` inside a style attribute, uncloseable by HTML-escaping.
- `test/place-page-xss.test.js`: evals the **shipped** esc/safeUrl/safeImg against payloads + asserts every named-field sink routes through them (regression guard).

**Two Cody passes — the gate earned its keep:** the diff-gate caught that the hero `imgEl.src` was the ONE named field (`image_url`) I initially skipped. I fixed it with `safeImg` (NOT Cody's literal `safeUrl` suggestion, which would have blanked every localized `/img/…` image — verified the actual stored URL shapes before choosing). Suite **205 → 209**, serial green. Commit `d79ae5e`.

**GATED:** the fix is committed + tested locally, but the XSS is LIVE on prod until deployed — external deploy to a customer-facing site is a hard gate. Drafted `2026-09-24-TK-10346-costa-place-xss-DEPLOY-GATED.md` (surgical single-file scp; security-urgent). Did NOT touch prod.

**Cost:** $0 (local + two Cody passes; the 2nd was a genuine diff-gate that found a real missed sink, not a rubber-stamp).

**Queued (Cody's 3 lower-sev ingest items, no live exploit path — next cycles, reversible/no-gate):** (1) slug-collision dedup in meic-pymes.js silently MERGES two different businesses (missing-cedula fallback slugs on name+canton — common Spanish trade names collide → ON CONFLICT grafts B's fields onto A); (2) one poison record aborts the WHOLE run in meic-pymes.js + ict-cst.js (no per-row try/catch — google-places.js + local-portals.js got it right); (3) no fetch timeout in scripts/ingest/_lib.js (a hung gov site stalls run-all.js forever; cr-portal-crawl.js has a correct 20s AbortController to copy).

**Backlog:** FOUR gated items on Steve's desk — host-claim approval, reconciler cron, payout-visibility decision, and now the XSS deploy (security-urgent). Next cycle: ingest hardening items (2)+(3) above (per-record isolation + fetch timeout — cheapest, reversible), then (1) the dedup key.

---

## Cycle 25 — ingest layer resilience: fetch timeout + per-record isolation (TK-10346)

**Surface:** continuing cycle 24's ingest-layer audit — the two lower-severity, no-live-exploit-path findings (fetch timeout, poison-record batch abort), done as reversible/no-gate fixes.

**Fix 1 — fetch timeout (`scripts/ingest/_lib.js`):** `fetchText`/`fetchJson`/`fetchBuffer` accepted `opts.signal` but no caller ever passed one, and Node's `fetch` has no default timeout — a hung gov site (CKAN, ICT WordPress) could stall `run-all.js` forever (it runs modules sequentially). Added a default `AbortSignal.timeout()`: 30s for HTML/JSON (`SCRAPER_TIMEOUT_MS`), 60s for file downloads (a separate `SCRAPER_BUFFER_TIMEOUT_MS` — see the gate finding below for why it needed its OWN knob). A caller-supplied `opts.signal` is honored as-is (none currently exist — verified all 8 `_lib` callers).

**Fix 2 — per-record isolation (`meic-pymes.js` + `ict-cst.js`):** the per-row/per-listing body sat inside ONE outer try; a single bad row aborted the whole run (`meic-pymes` can process up to 15,000 rows — a poison row at #500 drops the other ~14,500). Wrapped each in its own try/catch (`errors++`, warn capped at 20, continue); `finishRun` downgrades to `status='partial'` instead of `'error'`/exit 1 when errors>0 — mirrors the pattern `google-places.js`/`local-portals.js` already used correctly. Verified `status='partial'` has no schema constraint and only 2 read-only consumers (no branching risk).

**Cody gate caught a real bug in my own fix:** `fetchBuffer`'s timeout fell through `Number(opts.timeoutMs) || fallbackMs || DEFAULT_TIMEOUT_MS` — an absent `opts.timeoutMs` → `NaN` (falsy) → the hardcoded `60000` `fallbackMs` literal always won, silently making `SCRAPER_TIMEOUT_MS` a no-op for the one fetch (the multi-MB MEIC XLSX) that most needs a real override on a slow link. Confirmed by direct reproduction (env=999999 → still resolved to 60000). Fixed by giving `fetchBuffer` its **own** dedicated `SCRAPER_BUFFER_TIMEOUT_MS` env var instead of a fallback chain, then verified with a **live-timing** test (swap env vars, measure actual elapsed ms — not just code inspection) proving 35ms vs. an irrelevant 5000ms setting.

**Tests:** `test/ingest-resilience.test.js` — behavioral (fake never-resolving fetch → `TimeoutError`; caller-signal honored; fetchBuffer's own env knob proven live) + structural (per-record catch + partial-status regression guards on both scripts). Suite **209 → 215**, serial green. Commit `f10bf96`.

**Deprioritized correctly (Cody, no action taken):** `osm-fetch.js` has a raw unguarded `fetch()` but is NOT in `run-all.js`'s `ORDER` array — can't stall the pipeline, manual-run-only. `cr-osm-match.js` has the same no-per-record-isolation pattern but on a DB-only loop (no fetch) — queued for a later cycle, lower priority.

**Cost:** $0 (local + one Cody gate pass that found a real bug in the fix's env-override claim, verified independently before and after the fix with live timing, not just code review).

**Backlog:** still FOUR gated items on Steve's desk (host-claim approval, reconciler cron, payout-visibility, place-xss-DEPLOY — security-urgent). Concrete-deferred code backlog: `cr-osm-match.js` per-record isolation (cheap, same pattern) + the meic-pymes slug-collision dedup (Cody's item #1 — likely warrants its own DECISION memo since it touches ingest identity semantics, not a pure mechanical fix).

---

## Cycle 26 — cr-osm-match isolation + the MEIC dedup DECISION (TK-10346)

**Item (1) — cr-osm-match.js per-record isolation (fixed, reversible, no gate):** the last script with the cycle-24/25 gap. The OSM→places website matcher looped over places doing a `pool.query` UPDATE per record with NO try/catch (one bad UPDATE aborts the whole pass), and the IIFE had NO outer try — the trailing `await pool.end()` was skipped on any throw, leaking a pg connection. Fixed: per-record try/catch (errors++, warn, continue) + wrap the IIFE in try/catch/finally so pool.end() always runs. Structural regression guard added. 215→216 serial. Commit `f12347a`. Mechanical copy of an already-Cody-gated pattern (cycle 25) + a standard try/finally — self-verified (continue still works inside the try; pool.end runs exactly once), NOT re-gated, proportionate to task weight.

**Item (2) — MEIC slug-collision dedup → DECISION memo (NOT auto-fixed):** `meic-pymes.js:124` slugs cedula-less rows as `meic-${name}-${region.slug}`; common CR trade names ("Soda La Familia") collide per canton, and `ON CONFLICT (slug) DO UPDATE ... COALESCE` silently MERGES two different businesses. Verified against dev DB: `places.slug` is UNIQUE, no cedula unique index, and ALL 25,661 dev MEIC rows currently HAVE a cedula → the fallback path is **unexercised today (blast radius 0)**. The amplifier: `iCedula < 0` (a future sheet hiding the cedula column) flips EVERY row to the collision-prone slug at once — 0%→~100% on one upstream schema change. Because this changes ingest IDENTITY semantics (what makes two rows "the same place"), I made NO code change and drafted `2026-09-24-TK-10346-costa-meic-dedup-identity-DECISION.md` (A require-cedula+skip / B collision-proof slug / C flag-for-review / D do-nothing + a fail-loud iCedula<0 guard). Not unilaterally picked.

**Cost:** $0 (local; no Cody spawn this cycle — item 1 was a mechanical pattern-copy self-verified, item 2 is a decision-memo not a fix).

**Backlog:** FIVE gated items now on Steve's desk — host-claim approval, reconciler cron, payout-visibility, place-xss-DEPLOY (security-urgent), and this MEIC dedup decision. Concrete-deferred code backlog is now EMPTY (all 3 of Cody's cycle-24 ingest findings resolved: XSS fixed cycle 24, timeout+isolation cycle 25, cr-osm-match cycle 26; dedup → decision). Next cycle: a FRESH cold Cody audit on a not-yet-audited surface (candidates: lib/apple.js Sign-in-with-Apple token/JWT verification; lib/whatsapp.js + routes/webhooks.js signature verification; routes/admin.js authz; or the money-math in lib/money.js).

---

## Cycle 27 — cold Cody audit of Sign-in-with-Apple: crypto sound, 3 hardening gaps fixed (TK-10346)

**Surface:** lib/apple.js (SIWA identity-token verification) + routes/app.js /auth/apple — an un-audited, account-takeover-class surface. (Webhook signatures were checked first and are already verified with timingSafeEqual — a weaker target, skipped.)

**Crypto core = SOUND (Cody proved empirically, not by inspection):** 5 forge attempts — empty sig / alg:none, HS256-with-pubkey-as-secret (alg confusion), garbage bytes, full-length garbage, tampered payload — ALL return false. `header.alg` is never read; `crypto.verify` is hardcoded to RSA-SHA256; `kid` only selects among Apple-fetched keys. No takeover path. iss/aud/signature all enforced. `app_users.email`+`apple_sub` both UNIQUE (verified) so the account-link UPDATE can't hit multiple rows. The route already gates email-linking on `email_verified`.

**3 real gaps fixed (hardening/availability, reversible, on a security-critical file):**
1. **JWKS kid-miss never refetched** → a valid token on Apple's newly-ROTATED key 401'd for up to the 1h cache TTL (silent outage on Apple's undocumented schedule). Now a kid-miss forces ONE cooldown-guarded refetch.
2. **exp checked only-if-present** → a signature-valid token with no exp never expired. Now REQUIRED (`=== undefined` → reject; exp:0 still hits the expiry check).
3. **/auth/apple leaked e.message** (fetchT timeout text, JSON.parse errors) to the client, violating the file's own M2/R4 policy 6 lines below. Now server-side log + generic 401.

**Cody's diff-gate caught a REAL concurrency bug in my own fix:** `_lastFetch = now` was written AFTER the awaits, so a burst of concurrent kid-miss requests all read the stale clock and each fired a fetch (Cody reproduced: 20 concurrent garbage kids → 20 fetches), defeating the anti-hammer guard AND risking an Apple-side rate-limit self-DoS. Fixed with an **in-flight-promise dedup** (start the cooldown clock synchronously before the await; concurrent callers join the single fetch) — which also kills the pre-existing hourly cache-expiry thundering-herd for free. The test also found a bug in my cooldown env-parse (`Number('0')||60000` swallowed a legit 0). Two self-inflicted bugs, both caught before commit.

**Test (`test/apple-verify.test.js`):** generates a REAL RSA keypair, serves it as Apple's JWKS via mocked fetch, and proves the full matrix — valid / tampered / empty-sig(reaches crypto.verify, not the length bail) / wrong-key / bad-aud / bad-iss / expired / **no-exp** / **rotation-refetch** / **20-way concurrent burst → ≤1 fetch**. Suite 216 → **226**, serial green. Commit `3114048`.

**Two pre-launch DECISIONS deferred to GO-LIVE §7 (not mechanical, no live exploit):** (1) account-splitting when an Apple email differs from a pre-existing account's email (needs a merge flow vs accept+document); (2) nonce/replay hardening (needs a client-side ceremony + server nonce-ledger vs accept+document given TLS+short-exp) — plus a note that there's NO rate limiting anywhere in the app (worth adding on /auth/*).

**Cost:** $0 (local + two Cody passes — the audit AND a diff-gate that found a genuine concurrency bug in the fix, verified by reproduction).

**Backlog:** SIX gated memos on Steve's desk (host-claim, reconciler cron, payout-visibility, place-xss-DEPLOY [security-urgent], meic-dedup, createcharge-idempotency) + the 2 SIWA GO-LIVE decisions. Next cycle: fresh cold audit on another un-audited surface (candidates: routes/admin.js authZ, lib/whatsapp.js inbound handling beyond the sig check, routes/webhooks.js state-machine, or lib/money.js invariants).

---

## Cycle 28 — cold Cody audit of admin authZ: CSRF fixed, gate-coupling → HIGH memo (TK-10346)

**Surface:** routes/admin.js (host-claim approval + bookings/PII/GMV) + the server.js basic-auth gate — an un-audited authZ surface.

**Mostly sound, two real issues:** Cody confirmed injection is clean (all parameterized), PII selects are scoped (no secrets/hashes), and the basic-auth mount ordering correctly covers `/api/admin` today. Two real findings:

1. **CSRF on `POST /api/admin/claims/:placeId/:hostId` (REAL, exploitable today) — FIXED.** Basic-auth creds are auto-attached cross-site by the browser, and `express.urlencoded` is mounted globally, so an attacker page's auto-submitting `<form>` (application/x-www-form-urlencoded — a simple request, no CORS preflight) could approve a host claim against a logged-in admin. CORS is scoped to `/api/app` and doesn't apply to a simple form POST anyway. Fix: the mutation now requires `application/json` — a cross-site simple form can't set it without a preflight that admin (no CORS) rejects; the real UI (`public/admin.html:69`) already sends JSON, so transparent. Verified `req.is()` semantics + that application/json is non-simple. Test: form-urlencoded & text/plain → 415 no UPDATE, JSON → 200, bad-decision → 400. 226→**230**. Commit `75b27d7`.

2. **Admin coupled to the public-directory gate (HIGH) → DECISION memo, NOT auto-fixed.** `/api/admin` sits behind the SAME single basic-auth switch as the public directory, and `docs/GO-LIVE.md §8` explicitly plans to REMOVE `BASIC_AUTH_*` to open the directory — which would silently strip auth off claim-approval + bookings-PII (server.js fails OPEN when unset). Not live today (basic-auth is set) but a documented future launch action = a loaded gun. Drafted `2026-09-24-TK-10346-costa-admin-auth-coupling-DECISION.md` (A: separate cookie-session admin auth [recommended, also kills CSRF structurally]; B: interim fail-closed one-liner I can ship on approval; C: GO-LIVE checklist only). Did NOT unilaterally change admin auth — it's entangled with Steve's deliberate GO-LIVE toggle and could affect admin access.

**Not a new memo — re-confirmed the EXISTING one:** Cody independently found `routes/app.js:525` gates `POST /host/listings` on a claim ROW existing (`claimed`), NOT `claim_status='approved'` — so a host can self-list a bookable place without admin approval. That's the already-open `host-claim-approval-unenforced-DECISION` (cycle 23); Cody's finding just confirms it's the REAL trust boundary (the admin door is locked; the window is in app.js). Noted, not re-drafted.

**Cost:** $0 (local + one Cody audit pass). No re-gate on the 1-line CSRF content-type guard — the surface was just Cody-audited this cycle, fix is minimal + tested + bypass-reasoned; proportionate.

**Backlog:** SEVEN gated items on Steve's desk now (host-claim-approval, reconciler-cron, payout-visibility, place-xss-DEPLOY [security-urgent], meic-dedup, createcharge-idempotency, admin-auth-coupling [HIGH]) + 2 SIWA GO-LIVE decisions. Next cycle: fresh cold audit on the last big un-audited surfaces — routes/webhooks.js payment-webhook state machine (replay/out-of-order), lib/whatsapp.js inbound handling, or lib/money.js money-math invariants.

---

## Cycle 29 — cold Cody audit of the payment-webhook state machine: refund path fixed (TK-10346)

**Surface:** routes/webhooks.js payment-webhook + confirmBooking — replay / out-of-order / duplicate resilience. Confirm path is genuinely sound (Cody verified idempotent even under Postgres row-lock concurrency; confirmBooking guards WHERE status='pending'). The REFUND path had a live asymmetric gap. Three fixes:

1. **ONVO refunds silently swallowed** — `lib/payments/onvo.js` STATUS_MAP had NO refund key, so `getCharge()` could never return 'refunded'; a refund mapped to 'processing' (the `|| default`), REGRESSING payments.status and never flipping the booking. tilopay already maps `reversed->refunded`. Added `refunded/reversed->refunded` + a hard GO-LIVE gate: ONVO's real post-refund vocabulary is UNVERIFIED (may be Stripe-like where the PI stays 'succeeded' and getCharge must inspect a refund object). Not live today (tilopay is default) but fires the instant PAYMENT_PROVIDER=onvo.

2. **Idempotency-key collision (silent refund loss)** — `evId` could equal the charge id (Tilopay's `{paymentId, status}` shape has no distinct event id), so a 'succeeded' then a genuinely distinct 'refunded' event for the SAME charge both keyed `firstTime()` on the same id -> the 2nd hit ON CONFLICT DO NOTHING, treated as 'dup', refund NEVER processed (booking confirmed forever, silently, clean 200 to the provider). `evId` now prefers a distinct event id, else a `paymentId:type` composite -> lifecycle events get distinct keys while true replays still dedupe. Reorder also fixed `event_id` being shadowed by `paymentId`.

3. **Refunded UPDATE** — added `updated_at=NOW()` (every other status mutation sets it; a reconcile keyed off it would miss refunds) + `status<>'refunded'` idempotency guard. Booking status is a single 'refunded' enum, so the guard only no-ops redundant events.

**Test:** `test/webhooks-refund-idempotency.test.js` (arg-capture harness + getCharge stub) — distinct keys for succeeded-vs-refunded, replay dedupes to one payments UPDATE, a refunded getCharge reaches the guarded UPDATE, ONVO mapStatus. Suite 230->**234**, serial green. Commit `762091f`.

**Cody-gate (money path) confirmed the fix + caught a doc contradiction:** GO-LIVE §5b called Tilopay's `reversed` path "the verified reference" while item 3 says NEITHER map is verified against a live account — and `reversed` can be an auth VOID (money never moved), not a refund. Folded Tilopay into the same unverified + void-vs-refund caveat; whichever provider goes live first must pass the $1 refund test.

**Cost:** $0 (local + two Cody passes — the audit found the refund gaps, the gate confirmed the fix and caught the doc contradiction).

**Latent (noted, not fixed):** the refund UPDATE has no clawback link to the payouts row — moot while completion/payout is unwired; tracked with the refund-after-payout deferred item.

**Backlog:** 7 gated memos + 2 SIWA + 1 admin-auth GO-LIVE decisions on Steve's desk. Audited surfaces so far: charge leg (c19), payout leg (c23), ingest (c24), SIWA auth (c27), admin authZ (c28), payment-webhook state machine (c29). Next cycle: lib/whatsapp.js inbound handler (phone-spoof / impersonation / send-loop cost) OR lib/money.js money-math invariants — both still un-audited.

---

## Cycle 30 — cold Cody audit of the WhatsApp inbound handler: cost guard + retry-safety (TK-10346)

**Surface:** lib/whatsapp.js handleInbound + the routes/webhooks.js POST /whatsapp auto-reply. Chose it over the booking/pricing path (already server-computed + heavily guarded: R5 input guards, int4 overflow, max-nights cap; money.js has solid integer/bps/non-negative guards).

**Sound (Cody confirmed, matched my pre-checks):** impersonation — `m.from` is Meta-signature-verified and only ever echoed back to the sender; `whatsapp_contacts.user_id` FK is unused; no inbound `wa_id` flows into a cross-user/app_users lookup. Stored-XSS (profile_name/body stored raw) and SSRF (sendImage/Document link) are LATENT — no admin surface renders WA data, no caller invokes the media sends. Documented all three as traps in migrate_013's header + GO-LIVE.

**2 real fixes:**
1. **Cost amplification** (exploitable today, zero preconditions) — the keyword auto-reply fired one Meta-BILLED `sendButtons` per inbound message with NO rate limit. Added a **durable per-contact 60s cooldown** via an atomic conditional UPDATE (`SET last_auto_reply_at=NOW() WHERE ... IS NULL OR < NOW()-interval`) on a new `migrate_013` column — both checks+claims the slot (no TOCTOU, survives restarts, unlike an in-process Map in cluster mode). rowCount 0 → skip the billed send.
2. **Silent inbound-message loss** — `handleInbound` errors were swallowed (warn + 200) AFTER the idempotency marker was claimed, so Meta never retried → permanent loss. Now releases the marker + 500s (handleInbound is idempotent), scoped to the persistence step only.

**Cody's diff-gate earned its keep — caught a REAL regression in my own fix:** the cost-guard UPDATE sat OUTSIDE any try/catch, in the loop whose comment promises "always 200s to Meta." A DB blip on that UPDATE (after the marker is committed) would escape → 500 → Meta's retry dedupes to 200 → the auto-reply silently lost for this AND every remaining event in the batch (same bug class as the #4b I was fixing, and inconsistent — the flakier network send WAS wrapped, the local UPDATE wasn't). Fixed: the whole per-event body is now one try/catch, fail-CLOSED on cost. Tests E (throw → still 200, no send) + F (one event's error doesn't abort the batch) cover it.

**Test:** `test/wa-webhook-cooldown.test.js` — cooldown won/skipped/non-keyword, handleInbound-throw→marker-release+500, cooldown-throw→200, batch isolation. Suite 234→**240**, serial green. Commit `1f25a1f`.

**Cost:** $0 (local + two Cody passes — audit found the 2 bugs, the gate found the regression in the fix).

**Backlog:** 7 gated memos + 3 GO-LIVE decisions on Steve's desk. Audited surfaces: charge leg (c19), payout leg (c23), ingest (c24), SIWA auth (c27), admin authZ (c28), payment-webhook (c29), WhatsApp inbound (c30). Remaining un-audited: lib/money.js money-math invariants (though the booking route wrapping it is well-guarded), lib/plaid.js (host ACH onboarding), lib/reconcile.js edge cases, or routes/build.js / logo-agent.js (non-money admin tools). Next cycle: lib/money.js or lib/plaid.js.

## STEVE RULING 2026-09-26 (Blocked Tickets Review) — PARKED
- **Q1 Parked:** ticket stays open/blocked until Steve funds go-live. Non-DW, so NO more yoloforever cycles.
- **Q2 Admin auth:** fail-closed stopgap SHIPPED (3031607, 251/251). Separate cookie-session admin auth (SameSite=Strict, own ADMIN_* secret) is a HARD pre-launch requirement (GO-LIVE §8). Do not build it now.
- **Q3 Payments:** keep Tilopay as the plan, but do NOT start CR-KYC (IBAN + Hacienda) until un-park. If there is no CR entity/bank → switch design to ONVO/SINPE-only (ONVO refund path already coded, cycle 29).
- **Q4 Prod drift:** Mac2 = source of truth. Before ANY full deploy: read-only diff of prod HEAD 264af85 vs Mac2 HEAD, then fold in prod-only changes. The full deploy stays gated behind Steve's paste. Never `rsync --delete` blind over unknown prod state.