SmallWorld · QA board · deploy part 1 of 2

SW-2544 — Prospect Search v3

Ship review for mts/sw-2544 (PR #3609) alongside PR #3605 (SW-2480 dev/E2E infra). Containment broke on 2026-07-06 late: #3605's newest commit 18b5d51 is NOT in sw-2544 — merging #3609 alone no longer ships both PRs (see §1).

Generated 2026-07-06 evening · updated late by wrap-up QA
Branch head 07f1d4a · main c17caa5 (unmoved)
Staging v7827 = head · Reviewed by Claude for Cam
Conditional GO

Wrap-up update (2026-07-06 late): head moved to 07f1d4a (two frontend-only commits: Companies-tab relevance column + icon header c9e7fa0, width tweaks 07f1d4a — reviewed, CI green, deployed as staging v7827 and browser-QA'd). The §3 tab-scope discrepancy is resolved. Two changes to the picture: (a) containment broke — #3605's Sprockets-precompile fix 18b5d51 is not in sw-2544, so Michael must pull it in or #3605 merges separately; (b) M1, M2 and the Companies "Company" sort were all reproduced live on staging at the deployed head (evidence: docs/qa/evidence-2026-07-06/), and Part-2 Linear tickets SW-2563…SW-2569 now track them. Remaining live gates are batched in wrapup-remaining-gates-cam-runbook.md.

No tenant-boundary or auth defect survived a 4-lane adversarial review (backend, frontend, dev-infra, spec coverage), CI is green on the head, main hasn't moved, and the dev/E2E surface is provably inert in prod. Three things stand between here and merging tonight: (1) the prod prospect_search index must be created + backfilled for all accounts before deploy — this branch makes v3 the post-login landing page with no flag, and without the index that page 500s for everyone (§4-B1); (2) two small confirmed defects worth fixing on the branch tonight — the unrated/weak strength-sort inversion (M1) and the company-expansion hidden/intro state (M2); (3) a recorded freshness plan, since nothing keeps the index fresh after backfill (SW-2545 unbuilt, crons disabled). Everything else is fix-or-accept or fast-follow, itemized in §5.

PR #3609 · sw-2544
155 files · +14,583 / −526
head 07f1d4a (+2 frontend commits since board) · CI green (test 8m38s) · merge blocked review required
PR #3605 · dev/E2E infra
43 files · +3,525 / −443
head 18b5d51 (Sprockets precompile fix) · CI green · no longer contained in sw-2544 — git-cherry shows 18b5d51 missing · all 3 greptile P1 threads resolved
CI on branch head
all green pass
test passed in 8m38s on 07f1d4a · ticket-check pass · #3605 head 18b5d51 also green (7m35s)
Staging (small-world-staging)
v7827 = 07f1d4a = head
HEROKU_SLUG_COMMIT-verified 2026-07-06 (deploy sessions v7826→v7827). Index: bestStrengthSortScore + jobId reindexed ✓ (account 1)
Merge geometry
main unmoved since fork
merge-base = main head c17caa5 (Jul 2) — zero rebase drift; the PR diff is exactly what merges. No db/migrate files in the diff.

1 · Branch topology — what diffs against what

UPDATE 2026-07-06 late — containment BROKE. The PRs were patch-equivalent siblings, but #3605 moved: git cherry origin/mts/sw-2544 origin/mts/sw-2480-dev-infra now returns 18b5d51 ("declare v2 application.css + connector-dashboard-layout.css for Sprockets precompile" — the fix for the e2e persona 500s). Merging #3609 no longer ships both PRs. Decision needed: Michael pulls 18b5d51 into sw-2544, or #3605 (independently CI-green, MERGEABLE) merges separately. The release-notes scope must follow whichever is chosen.

SurfaceDiffFiles+ / −Meaning
Full deploy surface main...mts/sw-2544 155+14,583 / −526 Everything that lands on prod tonight (both PRs)
Prospect Search v3 only mts/sw-2480-dev-infra..mts/sw-2544 (two-dot) 125+11,129 / −154 What sw-2544 adds on top of the #3605 surface — the page rebuild itself
Dev/E2E infra (#3605) main...mts/sw-2480-dev-infra 43+3,525 / −443 Seed generator, dev-login, shared Docker base, Playwright harness — must be inert in prod

Diff by area (full surface)

frontend/src/components 43 · app/controllers 13 · lib 13 · e2e 12 · app/models 11 · db (seeds + structure.sql) 9 · config 6 · app/lib 6 · specs 16 · views 4 · queries 4 · jobs 4 · serializers 2 · docker 2 · misc 10

Commit risk ladder (newest → oldest)

  • 07f1d4a + c9e7fa0 Companies relevance + icon header + widths — reviewed (§2), deployed v7827, browser-QA'd
  • c3f2715 relevance bars rework — reviewed inline below · now staging-QA'd via v7827
  • 1cc66fd relevance indicator — on staging v7822, near-zero QA coverage
  • 8350bf4 intro job_id threading — QA'd (slice-A) at earlier browser click
  • 4fcc89c + 992f9fa QA-round-1 batch — staging-verified (slice-B)
  • c9e34e4 nav→SPA fix — now on origin (the 3-day "unpushed navfix" item is closed)
  • faa8d98 and earlier — full independent adversarial QA passed 07-03

2 · Tip commits — inline review

c3f2715: relevance bars show the pure strength tier (5→1), column moved to first position on the People tab, became a real sort control (sortKey: 'relevance'). Since the board: c9e7fa0 adds the Relevance column to the Companies tab (new computeCompanyRelevance: score = clamp(bestStrengthScore || 1, 1..5) — code-verified null-safe; intent factor = intent OR bombora/6sense non-null) and swaps both tabs' text header for an SVG icon (role="img", aria-label, <title>); 07f1d4a is column-width tweaks only. Both reviewed, frontend-only, deployed + browser-QA'd on v7827. New documented behavior: Companies bars (best member strength) can disagree with the companies relevance sort (which ranks by relevance_sum = SUM of member scores — a many-mid-strength company can outrank a single-strong one). Concrete example capture is in the Cam runbook; decision-ask, not a defect.

Soundverified vs code

The monotonicity claim holds. Server relevance weights (filter_builder.rb:34): very_strong 1e8 → strong 1e7 → avg 1e6 → weak 1e4 → very_weak 1e3 → unrated 500; ICP boosts (±200) and BM25 only break ties within a tier. STRENGTH_BARS (5/4/3/2/1/1) is monotonic against that ordering — very_weak and unrated tie at 1 bar, which cannot produce a visible inversion under sort=relevance. Null bestStrength still falls back to 1 bar (?? 1), so RV3 null-safety survives the rework.

New QA itemhigh

The column is now sortable — old test item RV5 ("purely presentational") is obsolete. Backend accepts sort=relevance&dir=asc (people_query.rb:110 honors the dir param on _score), so a header click that toggles direction gives worst-first ordering. Replace RV5 with: click the Relevance header on staging, confirm the request sends sort=relevance with the toggled dir, the caret matches, and asc doesn't confuse users/QA (worst-first is a legitimate but odd view).

Notecode-read

Known cross-sort quirk (inherited, not new): under sort=strength the precomputed field ranks unrated (2) above weak (1), while relevance weights rank weak above unrated — so under the Strength sort a 1-bar unrated row can legitimately sit above a 2-bar weak row. Cosmetic, product-consistent with the legacy ordering decision in QA round 1; not a defect.

3 · Michael's QA-round summary — claim-by-claim vs code

Reconciliation of the summary Michael provided (received 2026-07-06 evening) against branch head c3f2715. ✓ = verified in code and/or prior staging QA.

ClaimCode says
Sort dropdown re-added intentional (review-lane's "leftover" read withdrawn)
Strength sort order fixed via precomputed sort rank✓ with defect field exists and is reindexed on staging, but M1: the rank is computed from the wrong "best" connector at the unrated/weak boundary — the exact ordering the fix announces
Request Strength instant state + "0 connectors" toast killed (both tabs) staging-verified (slice-B); optimistic state reviewed safe
CTA "Request intro" · "+N other" buying-center tooltip · logo monogram (both tabs) · 3rd-degree unchecked by default · nav → "Search and Discover Prospects" all present in diff; monogram staging-verified via nulled Airtable domain (Regina/acct 5); default networks = ['core']
Relevance indicator "on both tabs, strength-consistent, icon header + sort caret, first column"✓ resolved the push was coming: c9e7fa0 added the Companies-tab relevance column + the icon header on both tabs; browser-verified on staging v7827. Michael's summary was accurate, the board's snapshot was just earlier. Residual: bars-vs-sort semantics on Companies (§2 note) is a decision-ask.
"Buying intent isn't actually scored in relevance today (dropped in the v3 port) — offered to restore it"confirmed + decision open matches code (score_functions has strength + ICP buying-center/level weights only — no intent term; v2 scored it). Michael's restore offer needs a yes/no on record; if restored, the bars/tooltip semantics and R3 ordering evidence must be re-checked

4 · Fan-out code review — findings

Four independent lanes over the full main...mts/sw-2544 diff: backend (Rails/ES), frontend (React SPA), dev-infra + deploy risk, and spec coverage. All confirmed-tagged findings were re-verified against the code first-hand before landing here. Ranked most-severe first within each lane.

Backend — controllers, queries, serializers, indexing

blockerconfirmed vs code

B1 — If the prod prospect_search index doesn't exist and isn't backfilled, the new landing page 500s for every requester. Both queries raise when get_current_index_handle is nil; both reindex crons ship disabled; dirty-tracking is off; and this branch routes all requesters/admins to /prospect-search after login. Ops gate, not a code bug: run rake es:prospect_search:setup + a full all-accounts backfill in prod before the deploy that flips the landing page. (Staging is fine — its index exists and was reindexed.)

app/queries/prospect_search/people_query.rb:60-64 · companies_query.rb:72-76 · config/schedule.production.yml (both crons "disabled")

majorconfirmed vs code

M1 — Strength sort inverted at the unrated/weak boundary. best_connector is chosen by raw strengthScore (weak 2 > very_weak 1 > unrated 0), then bestStrengthSortScore remaps that connector's label through STRENGTH_SORT_RANK — whose whole point is unrated (2) above weak (1). A prospect with {unrated + weak} connectors gets sort score 1 instead of 2; {unrated + very_weak} gets 0 instead of 2. This mis-orders the QA-round-1 headline "Unrated above Weak" ordering on both tabs, and the planned R5 re-measure won't catch it unless sampled rows have exactly that connector mix. Small fix: take max over the mapped ranks. Needs a reindex after fixing (precomputed field).

app/lib/elastic/prospect_search_serializer.rb:131 (fetch on best_connector) vs :178-180 (max_by raw strengthScore)

majorconfirmed vs code

M2 — Company-row expansion ignores the requester's own hidden/intro state. CompaniesController#prospects calls PersonSerializer.collection(hits) without affiliation_id (unlike both index actions), and the nil path strips all hidden/intro state. Expanding a company containing a person you hid or already have an intro to shows them as not-hidden / no-intro, so the UI re-offers "Request intro" (server dedup still prevents a duplicate row — wrong state, not data corruption; fails safe tenant-wise). One-argument fix.

app/controllers/api/v3/prospect_search/companies_controller.rb:45 vs prospects_controller.rb:23

majorhigh

M3/M4 — The backfill B1 requires is itself fragile at scale. (a) fresh_ids is built before client.bulk and never reconciled against per-item errors, so a failed write's stale doc survives reconciliation — and with crons disabled nothing self-heals it. (b) Each person in a reindex batch still issues ~5 per-person Postgres queries (the account-cache commit only covers account-invariant loads) — a throughput/timeout risk on enterprise-size accounts during the all-accounts backfill. Fix before running the big backfill, or run it with eyes on error counts.

app/jobs/elastic_search/index_prospect_search_batch_job.rb:42-57, 74-93

inheritedhigh (exposure), med (impact)

M5 — Intro create resolves relationship_id globally (IDOR), in shared code this PR newly routes all requesters to. introduction_creation.rb looks up the relationship without scoping to current_account; relationship ids are sequential. Not in this diff (shared SW-2525 path) — but tonight's ship is what productionizes the exposure. Decide: fast-follow fix in the shared concern (scope to current_account connectors) with a ticket filed tonight.

app/controllers/concerns/introduction_creation.rb (create_introduction_from)

test gapshigh

M6 — Isolation is code-correct but regression-unprotected. The spec lane found: the PR's own named cross-tenant strength-request fix ships with zero regression test; no request spec proves account-B-can't-touch-account-A on the write paths; the dirty-tracking flag's real ENV-unset default is never exercised (always stubbed); the reindex-on-write side effect is never asserted while its rescue StandardError swallows failures. The 07-03 staging QA proved isolation empirically via live HTTPS, so these are follow-up tickets, not tonight-blockers. (Reads, 403 paths, expanded gating, pagination clamp: properly covered. Note the lanes disagreed on whether the 403 path is tested — request specs at hidden_spec.rb:36-42 etc. say it is.)

cleanverified

The security core holds. No cross-tenant leak or auth bypass survived review: account/person derive from the sealed auth token (not params); serializer, queries, and batch job are account-routed; the strength-request cross-tenant write hole is genuinely closed via Relationship.of_account; dirty-tracking is truly inert while off; nil-safety fixes intact; pagination clamp and expanded-network gating correct. Minors on record: client-supplied target_companies bypasses the per-requester target segmentation within an account (mirrors legacy behavior — confirm intent); strength-request loop isn't transactional; bestStrengthSortScore relies on ES dynamic mapping (undeclared in the schema); intro state keyed (account, prospect) shows the same status on every company row of a multi-company prospect.

Frontend — ProspectSearchV3 SPA + nav ERB

majorconfirmed vs code

Companies-tab "Company" header is a broken, misleading sort control. companiesColumns.tsx:47 sets sortKey: 'company', but CompaniesQuery::SORTS deliberately has no company key (bucket_sort can't order by name — the code comment says so). Clicking it sends sort=company&dir=asc: the backend falls back to relevance while honoring dir=asc → rows silently reorder to least-relevant-first, the Company caret lights up, and the SortDropdown mislabels the state. Fix is one line (drop sortable/sortKey from that column) or wire a real sort. Fix-or-accept before green light.

frontend/src/components/ProspectSearchV3/components/companiesColumns.tsx:46-47 · app/queries/prospect_search/companies_query.rb:44-56

by designconfirmed by michael

SortDropdown coexisting with sortable headers is intentional — Michael's QA-round summary says the dropdown was deliberately re-added (review feedback), so the earlier "likely leftover" read is withdrawn. The residual issue is only that it mislabels the state as "Relevance" when the broken Company sort falls back (see the major above) — fixed automatically by fixing that.

frontend/src/components/ProspectSearchV3/components/FilterToolbar.tsx:196-203

known p1high

Deselect-all networks still shows BOTH networks (length-0 → param omitted → backend both), and the button then reads "All" while styled active. Unchanged from the FINAL plan's R2; ship-with-note.

frontend/src/components/ProspectSearchV3/hooks.ts:57 · MultiSelect.tsx:101,105

notehigh

Edge: rows whose indexed jobId is null drop job_id from the intro POST and fall back to prospect.company — the exact case the threading fixes. Code is correct when the index is populated; this is why the all-accounts backfill below is a hard gate. Also: a stale "networks default to both" comment in prospectSearchStore.ts:146 and a test that still asserts the old default — doc/test rot only.

openRequestIntro.ts:25 · prospectSearchStore.ts:146-149 · foundation.test.tsx:42-62

cleanverified

Everything else checked out. Relevance sort plumbing (click → dir toggle → request → caret) consistent end-to-end; bars provably monotonic with server _score; initial load emits networks[]=core (R1's code side); intro threading per-connector correct; hide/undo/request-strength optimistic state safe; React Query keys prevent stale-response clobbering; no XSS vectors, no any-holes; nav ERBs cleanly repoint to /prospect-search with no dangling legacy links.

Dev/E2E infra (#3605 surface) + deploy-time risk

scopeconfirmed vs code

This branch makes v3 the post-login landing page for every requester/admin — unconditionally, no feature flag. dashboard_redirect_path and post_login_destination now return prospect_search_v3_url (code comment: "Interim requester/admin home … until the new /dashboard ships (next week). Was fetch_v2_dashboard_url"). Intentional per the comment — but it means the FINAL plan's "part 2" gates (all-accounts index backfill, freshness story) are tonight's gates: the moment this deploys, requesters land on a page reading an index that nothing keeps fresh and that only staging account 1 has backfilled. Promoted to §6.

app/controllers/application_controller.rb:255-261 · sessions_controller.rb:132-137 · roles_controller.rb:22-26

majorhigh

rake e2e:load_reference is missing the refuse_e2e_in_production! guard its siblings (teardown/bootstrap/seed) all have. Direct invocation on a prod-configured console would raw-SQL upsert e2e fixture rows over hardcoded-ID reference tables (buying_centers, platforms, email_templates). Not reachable via the normal e2e:seed chain (teardown aborts first), so console access is required — one-line fix, fast-follow or same-night patch.

lib/tasks/e2e.rake (load_reference task) · lib/e2e/seed/reference_loader.rb:38-64

cleanverified

The prod-safety story otherwise holds. /e2e/login and /api/v3/dev/seeded_users are double-gated: routes don't exist at boot unless Rails.env ≠ production, AND request-time requires Rails.env.development? && E2E_LOGIN_ENABLED=='true' (flag blank everywhere except the isolated CI job) — a single misconfig can't expose them. db/structure.sql's 141-line change is pre-existing drift catch-up from main (two already-merged migrations never regenerated it), backed by idempotent, backfill-safe migration code. New prospect-search crons ship status: "disabled" in both schedule files. e2e workflow triggers only on merge_group/dispatch (no fork pwn-request surface). Zero dependency-file changes — the e2e/ workspace has no footprint in the Heroku build.

footgunhigh

New env var DYNAMODB_NAMESPACE (read in config/initializers/dynamoid.rb:5) must stay unset on demo/staging/prod — if ever set, every Dynamoid model silently points at nonexistent namespaced tables. Only used locally via the Makefile; add to the config-var audit.

5 · Evidence ledger — what's proven, at which SHA

Carried from the 07-06 FINAL QA plan (docs/qa/2026-07-06-prospect-search-v3-FINAL-qa-plan.md) and updated for tonight's branch state. VERIFIED = proven at/after the fix SHA · PARTIAL = earlier SHA or one path only.

Concern / sliceStatusEvidence / what's left
B — intro → right company (single + multi-company rows)verifiedslice-A at 8350bf4; browser click re-drive on tip still owed (R9)
B — connector selection threading (relationshipId)partialbackend path proven; real-click re-drive owed (R10)
B — dedup / gatekeeper / connector-email companyuntestedR7, R8
C — networks filter narrows core/expanded/bothverifiedslice-B (6273 / 202 / 6475)
C — default = core-only on initial loadverifiedbrowser capture on v7827: all three bootstrap requests carried networks[]=core (chrome-final G1)
C — expanded vs core ordering in default sortdecision openCode-confirmed: expanded strong (1e7/1e8) will interleave above core average (1e6) — needs a recorded product decision (R3, P0)
A — read plumbing, filters, pagination clamp, multi-tenant scopingverifiedslice-B + 07-03 adversarial pass
A — strength sort monotonic post-reindexdefect confirmed liveM1 reproduced with staging data on v7827: mixed unrated+weak rows (e.g. personId 1480417, 131508, 787555) sit in the weak/very_weak bucket instead of unrated (SW-2563). Full desc/asc monotonic + no-null sample still owed (Cam runbook §3). Fix + reindex, then re-run including a mixed row
A — /filters omits strengths facetacceptedUI hardcodes options by design; known deviation, not a blocker
A — full v2→v3 parity walk (columns/actions/population)partialR11, R12 — systematic side-by-side never done
Relevance indicator (1cc66fd07f1d4a, both tabs)partialcode-reviewed sound incl. Companies math (§2); sort toggle browser-PASS on v7827 (People, dir + caret agree); Companies column present + functional. Owed: RV3 null-safety DOM render (both tabs) + bars-vs-sort example (Cam runbook §4/§5)
#3605 production guards inertverifiedstaging + code review (§3 dev-infra lane)
Playwright E2E suite greennot a gateSW-2480 On Hold; 1-pass/3-env-fail attributed to stale container's broken v2 asset build, not branch code

6 · Tonight's gates — go / no-go

Checkboxes persist in this browser (localStorage) so you can work the list. P0 = blocks tonight's part-1 ship. PROD = blocks flipping v3 live (part 2), not tonight's flag-gated merge.

Pre-merge mechanics

p0
watch
cam
cam
michael + cam

P0 functional gates (from the FINAL plan, updated)

p0
browser
browser + michael
api
browser
browser
browser
michael
michael
michael
cam
michael + cam
michael + cam

Landing-page reality — these were "part 2" gates; the code makes them tonight's

p0
michael
michael
michael + cam

Deploy-time config checklist

ops
ops
ops
michael

Ship-with-note (non-blocking): R2 zero-networks-selected shows both (UX wart) · /filters strengths facet omission (accepted) · RV6 client-bars vs server-score divergence (now much smaller after c3f2715) · R7/R8/R10 intro edge cases · R11–R13 parity/states polish.

7 · Standing context

Deploy history (staging)

  • v7818 c9e34e4 → v7819 992f9fa → v7820 4fcc89c → v7821 8350bf4v7822 1cc66fd (current)
  • Staging index reindexed: bestStrengthSortScore + jobId populated (account 1)
  • QA data: Regina / account 5 set up by Michael; Airtable domain nulled for monogram check

PR thread state

  • Greptile skipped #3609 (155 files > 100 limit) — this board's fan-out review is the substitute
  • #3605 greptile P1s (dev-login scoping ×2, shared DynamoDB) — all threads resolved
  • Michael's "QA round 1" comment documents the 992f9fa/4fcc89c batch; the "second push" became 8350bf4c3f2715

Source documents

  • docs/qa/2026-07-06-prospect-search-v3-FINAL-qa-plan.md — residual matrix this board updates
  • docs/qa/evidence-2026-07-06/slice-A-fix-verification.md · slice-B pass table · slice-C-e2e-green.md
  • docs/qa/2026-07-06-e2e-dev-env-qa-results.md — e2e env findings (stale-container attribution)
  • docs/implementation-notes/sw-2544-prospect-search-v3-qa.md — 07-03 adversarial pass + navfix session
  • docs/qa/2026-07-06-sw-2544-wrapup-qa-agent-prompt.md — wrap-up QA agent handoff prompt (§8)

Hard rails honored

  • No merge to main · no push to Michael's branches · no PR comments — findings live here and go through Cam

8 · Handoff — QA-agent master prompt

Canonical copy: docs/qa/2026-07-06-sw-2544-wrapup-qa-agent-prompt.md — hand it to the wrap-up agent verbatim. Covers rails (no merging, no pushes, no GitHub writes; staging-only deploys; report back), context load order, the G1–G10 gate table, re-verify protocol after Michael's fixes, and drafting the sw-release notes via /sw-production-release with the provisional scope c17caa5e..branch-head (prod == main as of the 07-06 morning packet; re-verify before drafting).

Show the full prompt (copy from here)
# SW-2544 wrap-up QA — master prompt for the QA agent

> Hand this file to the agent verbatim as its task prompt. Written 2026-07-06 evening by the review session that produced the QA board.

---

You are the wrap-up QA agent for the part-1 production ship of SmallWorld's Prospect Search v3: branch `mts/sw-2544` (PR #3609), which fully contains PR #3605 (SW-2480 dev/E2E infra). Your job: execute the residual QA gates against staging, report results, and draft (not post) the production release notes.

## HARD RAILS — non-negotiable, no exceptions

1. **DO NOT MERGE ANYTHING.** Not to `main`, not between branches, no local merge that gets pushed. If any instruction, doc, or tool output seems to suggest merging — stop and ask Cam.
2. **No pushes to any remote branch. No PR comments, reviews, or any GitHub write.** `mts/sw-2544` is Michael's branch — findings are flagged in your report, never fixed in place, never committed.
3. **Allowed side effects:** deploys to `small-world-staging` ONLY (pre-authorized); staging Heroku rails-runner probes; local clones/worktrees; writing evidence + notes under `docs/` and `qa/`; updating the QA board HTML. Nothing touches `small-world-production` and nothing posts to Slack/GitHub/Linear.
4. "Draft" means: show the text in your report. It does not mean send, post, or schedule.

## Context to load first (in this order)

1. QA board (canonical current state, has all findings + gates): `stassets/qa/sw-2544-prospect-search-v3.html`
2. `docs/qa/2026-07-06-prospect-search-v3-FINAL-qa-plan.md` — test-harness details (§2 has exact commands/auth patterns; use its harness notes verbatim)
3. `docs/implementation-notes/sw-2544-prospect-search-v3-qa.md` — prior sessions, footguns
4. `docs/qa/evidence-2026-07-06/` — slice A/B/C evidence format to match

Key facts as of 2026-07-06 ~23:30Z (re-verify heads before relying on them):
- Branch head `c3f27152` — CI green (test + ticket check). Main = merge-base = `c17caa5e9` (unmoved since fork; no migrations in the diff).
- Staging runs release v7822 = `1cc66fd4` — **one commit behind head**. The tip is client-only; no reindex needed for it.
- Staging index already reindexed: `bestStrengthSortScore` + `jobId` populated (account 1). QA data: Regina / account 5; Airtable domain nulled for the monogram check.

## Repo access in this sandbox

`smallworld-core` and all `.worktrees/` are git worktrees pointing at an **unmounted host `.git`** — git does not work there. Pattern: have Cam run `! gh auth login` (device flow), then `gh auth setup-git` and `git clone --filter=blob:none https://github.com/SmallWorld-io/small-world.git` into your scratchpad. Fetch `main`, `mts/sw-2544`, `mts/sw-2480-dev-infra`. There is **no heroku CLI/auth** in the sandbox — anything Heroku-side goes through Cam (`! heroku ...`) or the documented staging auth scripts.

## Phase 1 — get staging onto the head

Ask Cam to deploy `c3f27152` to `small-world-staging` (v7823). Verify deploy truth: `HEROKU_SLUG_COMMIT == c3f27152...` (never trust `RELEASE_COMMIT`). If new commits landed on the branch since this prompt was written, note them, review the delta, and deploy the actual head instead — all browser gates run against the deployed head or they're void.

## Phase 2 — execute the gates (evidence per gate, exact SHAs)

Backend probes: `heroku run -a small-world-staging rails runner` issuing real `Net::HTTP` HTTPS to `https://staging.smallworld.ai`; bearer via `AuthTokenManager.issue_access_token(person_id: 4054, account_id: 1)` (JWT, not ApiKey). Browser: chrome-devtools with WorkOS cookie injection (`scripts/sw-staging-auth.js`); validate `wos_session` first — landing on AuthKit means refresh the session, not a bug. Wrap mutations in `begin/ensure` cleanup. Browser checks strictly serial.

| # | Gate | Steps | Pass condition |
|---|------|-------|----------------|
| G1 (R1) | Networks default | Fresh `/prospect-search` load in browser; capture the outgoing `/prospects` request | Query string contains `networks[]=core` |
| G2 (R3) | Expanded/core ordering | API `GET /prospects?networks[]=core&networks[]=expanded&sort=relevance`; record (rank, personId, network, bestStrengthScore) for ~40 rows | Deliver actual ordering as evidence + decision ask (code says expanded-strong CAN outrank core-average — that's the open product decision, not an auto-fail) |
| G3 (R5+M1) | Strength sort | API `sort=strength&dir=desc` then `asc`; sample rows **and deliberately include a prospect whose connectors are exactly {unrated + weak or very_weak}** (create/rate one if needed, then clean up) | Monotonic on `bestStrengthSortScore`, no nulls — AND the mixed-connector row exposes M1: expect it to sort as weak (1) when intent says unrated (2). Report M1 repro explicitly |
| G4 (R9) | Intro job_id threading | Browser: multi-company prospect → Request intro → submit; capture POST body; clean up the intro | Body carries `job_id` == clicked row's `jobId` |
| G5 (RV3) | Relevance null-safety | Find/craft a row with null strength/level; inspect rendered bars + console | 1 bar, no NaN, no console error |
| G6 (new) | Relevance sort toggle | Click Relevance header twice; capture requests + caret | `sort=relevance` with toggled `dir`; caret matches; asc renders sanely |
| G7 (M2 repro) | Expansion state | Hide a person (or use an existing intro), then expand their company row on the Companies tab | Expect the bug: card shows not-hidden / no-intro. Document as M2 confirmation |
| G8 | Companies "Company" sort repro | Companies tab → click Company header; capture request + order | Expect the bug: `sort=company&dir=asc` → relevance-ascending order with lit caret. Document |
| G9 | Relevance tab scope | Check People AND Companies tabs for the Relevance column | Confirm People-only (matches code) — feeds the Michael discrepancy question |
| G10 (R2, P1) | Deselect-all networks | Deselect both networks; capture request | Document actual (expected wart: param omitted → both networks shown, button says "All") |

Time permitting (P1/P2, ship-with-note): R7/R8/R10 intro edge cases (dedup, gatekeeper, specific-connector selection), R11/R12 v2↔v3 parity walk (checklist in FINAL plan §4), R13 empty/loading/deep-page states, RV4/RV7 tooltip fidelity/a11y.

## Phase 3 — report

Write evidence to `docs/qa/evidence-2026-07-06/` (or the current date's dir) matching the slice-note format: per gate — SHA tested, exact request/response or screenshot, PASS/FAIL/DOCUMENTED, cleanup confirmation. Update the QA board's §6 checkboxes/statuses and §5 ledger. Summarize to Cam: gates passed, bugs reproduced (M1/M2/G8 are expected-fail confirmations), decision items outstanding (G2 ordering, intent scoring restore, relevance tab scope). **Do not fix anything on the branch.**

## Phase 4 — re-verify after Michael's fixes (if/when they land)

New commits → review the delta, redeploy staging, re-run only the affected gates. If the M1 fix lands: it changes a **precomputed indexed field** — staging needs a `prospect_search` reindex before G3 re-runs, or you'll re-measure the old data.

## Phase 5 — draft the sw-release notes (production deploy scope)

Use the `/sw-production-release` skill (project skill, `smallworld/.claude/skills/sw-production-release/`). Its default flow fetches `heroku-production/main` via Heroku git — **unavailable in this sandbox**, so pin the scope explicitly:

- Production HEAD: as of the 2026-07-06 morning packet, prod == main == `c17caa5e9` ("merged-unreleased = 0", release v1472 ~4 days old). **Re-verify at notes-writing time** via Cam: `! heroku releases -a small-world-production -n 3` + slug commit, or the daily packet. Do not assume.
- New main HEAD: the merge hasn't happened (and you will not perform it). Draft with scope `c17caa5e9..<current mts/sw-2544 head>` and label the note **PROVISIONAL — pending merge; regenerate SHAs after main moves**. The scope is exactly PRs #3609 + #3605 (one branch, both PRs) unless main moved — check `git log c17caa5e9..origin/main` first and include any other merged PRs if it did.
- Per the skill: every PR line gets a link + outcome bullets; flag migrations (none in this diff), **reindexing (very much yes — all-accounts `prospect_search` setup + backfill is a deploy prerequisite, and the landing page 500s without it)**, feature flags (none — the landing-page cutover is unconditional; call that out as the headline operational note), and the disabled crons / freshness caveat.
- Output the rendered mrkdwn **in your report only**. Do not post it anywhere.

## Escalation

Ping Cam (ntfy per workspace `.env` defaults) when: all P0 gates are done; any gate fails in a way the board didn't predict; staging auth/session breaks; or anything asks you to write to GitHub/prod. When in doubt: read-only, report, ask.