# Compass codebase review

> Original audit preserved for traceability. See [6 September independent verification](../verification.md) for current status and corrections, including the currency-alternative semantics in #21.

Review date: 5 September 2026. Full current checkout; report only.

**35 actionable findings: 13 high priority, 20 moderate, 2 low. Twelve separate product and architecture opportunities.**

Fix import integrity first. The app can import deselected rows, turn a JSON false into supported=true, and retain partial writes after reporting failure. Authentication/privacy, matching semantics and wizard-state defects follow.

The existing 2,228-test suite passes. The uncovered cases are gaps in the assertions and user-flow coverage; the passing baseline does not disprove the reproduced defects.

## Triage groups

| Order | Group | Findings | Resolution |
|---|---|---|---|
| Start here | Import integrity | #3, #6, #7, #15, #26, #27, #30, #32 | Make preview selection and typed values authoritative, then prove rollback through the controller. Follow with recoverable jobs and bounded file/URL handling. |
| Start here | Authentication and privacy | #2, #4, #11, #12, #31 | Close log-retention and OTP-guessing gaps. Repair email changes and 2FA removal; keep extraction history out of the CLI session store. |
| Next | Matching and input contracts | #5, #8, #13, #16, #17, #18, #19, #20 | Decide how a group of client countries should aggregate, then normalize browser/API values at the shared model boundary. Correct partner and prospect semantics together. |
| Next | Usability and accessibility | #1, #9, #10, #14, #23, #25, #28, #29, #34, #35 | Preserve wizard state; reveal and focus actionable errors; provide keyboard-operable disclosures and named controls. Contain mobile layout and cancel stale polling. |
| Then | Analytics and scale | #21, #22, #24, #33 | Represent the actual blockers, keep team drill-downs accessible, and reuse loaded prospect evidence before adding coverage caches. |

## Findings

P1 = high-impact defect to prioritize. P2 = meaningful edge case, reliability, performance or usability problem. P3 = smaller correction. No P0 finding was established. Historical issues remain in scope because this is a whole-codebase review.

### #1 P1 - Back navigation erases account requirements

**Location:** [app/assets/javascripts/stimulus/pages/admins/compass/account_requirements_controller.js:25](/home/stevan/dev/compass/app/assets/javascripts/stimulus/pages/admins/compass/account_requirements_controller.js:25)

A user who adds or edits account requirement rows, goes Back to step 1, then returns to step 2 loses every edit. `setCurrencies()` regenerates defaults after the one-time initial payload has been consumed. Preserve existing rows when the currency set has not changed, and reconcile rows only when step-1 currency choices actually change.

**Fix:** Track the last currency selection and skip `generateDefaults()` on repeated step entry; when currencies change, reconcile existing rows by retaining valid custom rows and adding/removing only affected defaults.

**Acceptance check:** Change GBP naming to Named Required, go Back then Next, and assert both the row and submitted JSON retain it.

**Evidence:** Browser reproduced: Named Required changed to Any after Back/Next. Confidence anchor 100/100.

### #2 P1 - Admin email edits update an unused account column

**Location:** [app/controllers/admins/admins_controller.rb:45](/home/stevan/dev/compass/app/controllers/admins/admins_controller.rb:45)

Saving an edited admin email displays Admin updated successfully, but the email shown on reload and accepted at sign-in remains the old credentials email. The assignment writes the legacy accounts.email column instead of AccountCredentials, bypassing the intended reconfirmation email shown in the form.

**Fix:** Split account fields from email in update as create already does, and update the credentials record transactionally so Devise reconfirmation runs. Preserve entered values and show credential validation errors. Test the before/after login identity, unconfirmed_email state and confirmation email.

**Acceptance check:** Edit an admin email, inspect unconfirmed_email and delivery, confirm it, then test the intended old/new login behavior.

**Evidence:** Source and call-chain review. Independently rechecked. Confidence anchor 100/100.

### #3 P1 - Deselected upload rows are still imported

**Location:** [app/controllers/admins/compass/uploads_controller.rb:161](/home/stevan/dev/compass/app/controllers/admins/compass/uploads_controller.rb:161)

An admin unchecks an unwanted capability row or clears Select all, then confirms. The controller discards the submitted row_include map, while every valid row retains an import/replace action. The unwanted rows are written anyway, including capability restrictions the reviewer explicitly chose not to accept.

**Fix:** Make submitted row selection authoritative in confirm_params and ApplyUpload, translating unchecked rows to skip. Include an explicit form selection marker/empty value so Select all unchecked cannot fall back to import-all. Update the displayed import count from actual selection and verify none/one/all selections through the full request flow.

**Acceptance check:** Confirm the same preview with zero, one and all rows selected. Assert precisely those records change, including conflicts.

**Evidence:** Source and call-chain review. Independently rechecked. Confidence anchor 100/100.

### #4 P1 - Turning off 2FA calls a missing mailer

**Location:** [app/controllers/two_factor_auths_controller.rb:80](/home/stevan/dev/compass/app/controllers/two_factor_auths_controller.rb:80)

A fully authenticated admin clicks Turn off on the security or recovery-code page. The action saves a reset token, then raises NameError before sending the email, so the advertised 2FA removal flow cannot complete.

**Fix:** Implement the reset email on the existing DeviseMailer (or add the explicitly referenced mailer and template), using the generated reset URL and a safe delivery path. Add a request test from the Turn off action through email delivery and token consumption, including expired/blank-token behavior.

**Acceptance check:** Submit Turn off 2FA, assert the mail is queued, and exercise valid, expired, absent and consumed reset tokens.

**Evidence:** Source and call-chain review. Independently rechecked. Confidence anchor 100/100.

### #5 P1 - Wildcard capability form options cannot be saved

**Location:** [app/models/compass/account_capability.rb:24](/home/stevan/dev/compass/app/models/compass/account_capability.rb:24)

The advertised All currencies / Any country options submit empty strings, but capability validators allow nil only and there is no normalization. Creating or editing an account capability with either wildcard selected fails validation; the entity restriction All countries option fails for the same reason. Current request specs omit these keys instead of submitting browser-generated empty strings, so they pass while the default form path fails.

**Fix:** Normalize optional wildcard fields to presence at the model boundary: AccountCapability currency and iban_country, and EntityRestriction client_country. Preserve nil as the wildcard so matching and existing partial unique indexes remain consistent; test full browser-style form payloads for create and update.

**Acceptance check:** Submit real form payloads containing empty strings for all wildcard options on create and update.

**Evidence:** Browser reproduced: both wildcard defaults return field validation errors. Confidence anchor 100/100.

### #6 P1 - Document boolean false becomes supported true

**Location:** [app/services/compass/apply_upload.rb:178](/home/stevan/dev/compass/app/services/compass/apply_upload.rb:178)

A PDF/image/web extraction returning the valid JSON boolean false is shown as Yes and imported as supported=true. This can turn an unsupported currency or excluded client jurisdiction into a positive live match. Only the string false is handled correctly.

**Fix:** Normalize and validate document rows into the same canonical schema as spreadsheet rows before preview. Explicitly accept boolean true/false and recognized string forms, reject ambiguous values, and have preview and ApplyUpload consume one boolean representation. Add false cases for both corridor and jurisdiction document imports.

**Acceptance check:** Run document-to-preview-to-import cases for native true/false and accepted string forms; reject invalid values.

**Evidence:** Runtime probe plus source trace. Independently rechecked. Confidence anchor 100/100.

### #7 P1 - Failed imports commit earlier writes and deletions

**Location:** [app/services/compass/apply_upload.rb:22](/home/stevan/dev/compass/app/services/compass/apply_upload.rb:22)

Import failure can leave a partially changed live capability dataset. The service transaction joins the controller with_lock transaction; an invalid enum in a later extracted/edited row raises ArgumentError after earlier rows were written, but the service rescues before the outer transaction exits. The controller reports Import failed while the outer transaction commits earlier writes and any replace-all deletions.

**Fix:** Use transaction(requires_new: true) for the atomic application block, matching ApplyTranscriptIngestion:34, or let failures propagate beyond the outer transaction and mark failure after rollback. Verify a later-row failure restores both earlier updates and replace-all deletions through UploadsController#confirm.

**Acceptance check:** Fail the second row with an invalid enum through UploadsController#confirm, in merge and replace-all modes; assert all prior data is unchanged.

**Evidence:** Runtime probe plus source trace. Independently rechecked. Confidence anchor 100/100.

### #8 P1 - Either forwards accepts providers offering neither forward type

**Location:** [app/services/compass/match_providers/forward_matching.rb:48](/home/stevan/dev/compass/app/services/compass/match_providers/forward_matching.rb:48)

A lookup requiring a forward of Either type receives a green forward result when the matching capability explicitly has both deliverable=false and ndf_available=false. Either should accept at least one offered type, but the early return disables the check entirely; the prospect matcher likewise records qualifying forward evidence for this state. Sales can be told an unavailable forward is supported.

**Fix:** For Either, require deliverable? || ndf_available? and return a red/negative result when both are false. Apply the same predicate in prospect forward evidence; correct the existing partner test that currently codifies green for neither type, and add the three valid type combinations.

**Acceptance check:** Test deliverable only, NDF only, both and neither for Either in both matchers; change the test that currently blesses neither.

**Evidence:** Runtime probe plus source trace. Independently rechecked. Confidence anchor 100/100.

### #9 P1 - Lookup details are mouse-only

**Location:** [app/views/admins/compass/lookups/_result_row.html.haml:6](/home/stevan/dev/compass/app/views/admins/compass/lookups/_result_row.html.haml:6)

Keyboard and screen-reader users cannot open a provider's detailed match explanation because the click handler is placed on a non-focusable table row. The row also exposes neither its expanded state nor the controlled detail row. A real disclosure button in the first cell supplies keyboard activation and a stable accessibility contract.

**Fix:** Put a labelled button in the first cell, give the detail container a unique id, connect them with `aria-controls`, update `aria-expanded`, and let the Stimulus action run from the button. Keep row-click expansion only as an optional convenience.

**Acceptance check:** Use Tab then Enter/Space to open every provider disclosure; assert expanded state and controlled detail association.

**Evidence:** Source and call-chain review. Confidence anchor 100/100.

### #10 P1 - Rerun hides required forward fields

**Location:** [app/views/admins/compass/lookups/new.html.haml:165](/home/stevan/dev/compass/app/views/admins/compass/lookups/new.html.haml:165)

Opening Modify Search for a lookup that requires a forward checks the toggle but leaves all forward inputs hidden. The user sees an enabled requirement without its saved details and can submit without noticing or cannot correct them. Deriving initial visibility from `@lookup.forward_required?`, then running the same toggle logic on connect, keeps reruns faithful to the saved lookup.

**Fix:** Render the forward fields visible when `@lookup.forward_required?` and call `toggleForwards()` from `connect()` so rendered state, disabled state, and checkbox state share one source of truth.

**Acceptance check:** Rerun a stored forward lookup and assert its populated forward fields are visible without toggling the requirement.

**Evidence:** Browser reproduced: forward toggle checked=true, fields visible=false. Confidence anchor 100/100.

### #11 P1 - Request logs retain conversation content and authentication codes

**Location:** [config/initializers/filter_parameter_logging.rb:6](/home/stevan/dev/compass/config/initializers/filter_parameter_logging.rb:6)

The initial transcript body is filtered, but the next wizard screen submits an extracted summary, participant identities and clarification answers under unfiltered names. Ordinary request logging therefore creates another transcript-derived data store even when extraction service/job logging is carefully redacted. 2FA code submissions, recovery-code submissions, and 2FA reset URLs include live authentication material that survives the application parameter filter. A recovery code submitted incorrectly elsewhere can remain unused and valid; reset links remain security-sensitive for their configured lifetime.

**Fix:** Filter summary, participants, answers and transcript-derived decision free text at the request boundary, including nested fields. Extend log-capture requests across conversation create, refine and accept with synthetic sentinels; ensure those sentinels cannot appear in Rails or error-monitor context. Also filter otp_code, reset tokens and the actual nested two_factor_recovery.code parameter; verify with failed as well as successful submissions.

**Acceptance check:** Capture request logs with synthetic summary, participant, answer, OTP, reset-token and recovery-code sentinels; assert none appear.

**Evidence:** Runtime probe plus source trace. Confidence anchor 100/100.

### #12 P1 - The six-digit 2FA challenge allows unlimited guesses

**Location:** [app/controllers/two_factor_auths_controller.rb:66](/home/stevan/dev/compass/app/controllers/two_factor_auths_controller.rb:66)

An attacker who knows an admin password can keep the pending-2FA session and automate unlimited guesses against the six-digit second factor. Wrong codes only redirect and do not consume a credential/IP attempt budget or lock the challenge. This removes the online-guessing protection the second factor needs.

**Fix:** Add an atomic credential-scoped and IP-scoped attempt budget to the OTP challenge and recovery endpoints, with a bounded cooldown and a visible retry-after state. Use a documented default such as 5 failed OTP attempts per 5 minutes and tune operationally; test repeated failures across multiple sessions and successful reset of the budget.

**Acceptance check:** Repeat failed challenges across multiple sessions for one credential and across IPs; assert bounded attempts and a clear cooldown.

**Evidence:** Source and call-chain review. Confidence anchor 75/100.

### #13 P1 - Country-group searches misclassify partners and ignore restrictions

**Location:** [app/services/compass/match_providers/jurisdiction_matching.rb:11](/home/stevan/dev/compass/app/services/compass/match_providers/jurisdiction_matching.rb:11)

Selecting an offered group such as EEA rejects every partner with recorded jurisdictions, even if every member country is supported: the matcher compares an ISO country to group_eea. The entity matcher separately ignores all country-specific restrictions for the same input, so an explicitly blocked German entity becomes green when EEA is selected. These are reachable through the normal grouped client-country field; the prospect restriction matcher also selects only the first matching country in a group, hiding other countries' conflicting rules. A blocked member does not necessarily mean the whole group must fail: that depends on whether the agreed rule is any-member or all-member coverage. The defect is the raw-string comparison and missing member evaluation.

**Fix:** Expand client_countries before evaluating jurisdiction and entity restrictions. Evaluate specific-over-general rules for each member and expose the supported/blocked subset, aggregating with explicitly defined group coverage semantics; do not collapse a group to the first row. Use per-member tests for wholly supported, partly supported, and conflicting restrictions.

**Acceptance check:** Test a group with all supported members, partial coverage, and conflicting member-specific restrictions against the chosen aggregate rule.

**Evidence:** Runtime probe plus source trace. Independently rechecked. Confidence anchor 75/100.

### #14 P2 - Account row controls have no names

**Location:** [app/assets/javascripts/stimulus/pages/admins/compass/account_requirements_controller.js:163](/home/stevan/dev/compass/app/assets/javascripts/stimulus/pages/admins/compass/account_requirements_controller.js:163)

Screen-reader users encounter four unnamed selects in each generated account row and an icon-only unnamed delete button. Even sighted users relying on voice control cannot target a specific field or row reliably. Generate unique ids and accessible labels using the row and column context, and name the delete action with its currency and direction.

**Fix:** Pass a stable row id into the HTML builders, add associated visually-hidden labels or `aria-label` values to each select, and add an `aria-label` such as `Remove GBP collection requirement` to the trash button while hiding its icon from accessibility APIs.

**Acceptance check:** Inspect accessible names for every generated select and removal button, including newly added rows.

**Evidence:** Source and call-chain review. Confidence anchor 100/100.

### #15 P2 - Failed document uploads return silently to the capability tab

**Location:** [app/assets/javascripts/stimulus/pages/admins/compass/upload_polling_controller.js:47](/home/stevan/dev/compass/app/assets/javascripts/stimulus/pages/admins/compass/upload_polling_controller.js:47)

When document extraction fails while the user is watching the spinner, polling sends them straight back to the capability list without an error. The status response includes the failure message, but the browser discards it and bypasses the upload show action that would have set a flash. The user cannot distinguish failure from a successful empty import.

**Fix:** Send failed polling results to showUrlValue so the existing controller-owned failure flash runs, or render an accessible failed state with the safe server message and Retry/Back controls. Add a browser test that starts with processing, receives failed and asserts a visible error and recovery action.

**Acceptance check:** Transition processing to failed in a browser test and assert a visible safe error plus a recovery action.

**Evidence:** Source and call-chain review. Confidence anchor 100/100.

### #16 P2 - Malformed account requirements fail open or crash

**Location:** [app/controllers/admins/compass/lookups_controller.rb:139](/home/stevan/dev/compass/app/controllers/admins/compass/lookups_controller.rb:139)

Malformed account JSON is logged and replaced with [], so a lookup can be saved and matched after silently dropping every account requirement. Valid JSON with the wrong shape fails differently: [null] first adds a schema error but then crashes the duplicate check, while null passes the blank validation and later crashes matching on nil.map. The same data contract therefore reports success with weaker constraints or returns a server error instead of actionable validation.

**Fix:** Treat JSON parse failure as a lookup validation error and re-render without saving or matching. Require an array of hashes, skip duplicate checking for invalid elements, and normalize an omitted account_requirements field to []; distinguish omitted input from explicitly malformed/null input. Test malformed JSON, null, [null], and valid empty arrays.

**Acceptance check:** Submit malformed JSON, null, [null], scalars and valid empty arrays; assert invalid shapes never save or match.

**Evidence:** Source and call-chain review. Independently rechecked. Confidence anchor 100/100.

### #17 P2 - Validate currency elements before producing lookup matches

**Location:** [app/models/compass/lookup.rb:36](/home/stevan/dev/compass/app/models/compass/lookup.rb:36)

An API request with sell_currencies:[""] passes the array presence validation, then loses its only sell currency inside build_currency_pairs. The empty set of pairs is scored amber, which can produce full_match without evaluating any currency pair. Invalid codes, duplicate codes, and empty/invalid nested account currencies are also accepted, polluting logged demand and potentially producing green wildcard-account results for a nonexistent currency.

**Fix:** Normalize currency arrays with compact_blank and uniq at the model boundary before presence validation, validate every currency against CurrencyValidator::ALL_CURRENCIES, and validate each account requirement currency too. Reject an empty pair product defensively and add API cases for blank-only arrays, unknown codes, duplicates, and empty account currency.

**Acceptance check:** Submit blank-only, unknown and duplicate currency arrays and invalid account currencies; assert a field error or documented normalization.

**Evidence:** Source and call-chain review. Confidence anchor 100/100.

### #18 P2 - Missing client country raises a database error

**Location:** [app/models/compass/lookup.rb:89](/home/stevan/dev/compass/app/models/compass/lookup.rb:89)

An API caller omitting client_country passes model validation but fails insertion because compass_lookups.client_country is NOT NULL. The API action only rescues ArgumentError, so this ordinary missing-input case becomes a server error rather than a field validation response. The model spec explicitly tests nil as valid, showing a model/schema contract mismatch.

**Fix:** Require client_country presence in model validation, matching the database and required browser field, and return the existing 422 response. If product intends it to be optional instead, explicitly migrate the column to nullable and keep grey semantics; do not leave validation and persistence disagreeing.

**Acceptance check:** POST an API lookup without client_country and assert a 422 field error with no new record.

**Evidence:** Source and call-chain review. Confidence anchor 100/100.

### #19 P2 - Restricted currency pairs incorrectly receive direct prospect ranking

**Location:** [app/services/compass/match_prospects/corridor_evidence.rb:17](/home/stevan/dev/compass/app/services/compass/match_prospects/corridor_evidence.rb:17)

For a GBP->USD request, a prospect with GBP sell capability and USD buy capability restricted to JPY is ranked tier 1/direct. The restriction is merely printed after both sides have already qualified, although this provider has explicitly not recorded the requested pair as supported. This also inflates the firm's gap coverage and can rank an incompatible lead ahead of usable partial evidence.

**Fix:** Before assigning TIER_DIRECT, find at least one supported requested sell/buy pair whose buy row permits that sell currency, mirroring MatchProviders#pair_status. Keep the relevant restriction visible and only let individually applicable evidence contribute to lower tiers when no allowed pair exists.

**Acceptance check:** Request GBP/USD with USD buys restricted to JPY; assert the prospect is not ranked as direct requested-pair evidence.

**Evidence:** Runtime probe plus source trace. Independently rechecked. Confidence anchor 100/100.

### #20 P2 - Selecting no industry produces a false data-gap indicator

**Location:** [app/services/compass/match_providers/industry_matching.rb:27](/home/stevan/dev/compass/app/services/compass/match_providers/industry_matching.rb:27)

A normal corporate lookup with High-Risk Industry set to None stores the HTML empty string. The partner matcher skips industry checks only for nil, so it looks up a restriction for an empty industry and reports grey, unlike the API's omitted value and unlike the prospect matcher's blank? behavior. This gives an ordinary no-industry lookup an unexplained missing-data indicator and adds a spurious grey to ranking.

**Fix:** Normalize blank high_risk_industry to nil on Lookup and use blank? in the partner skip predicate, matching the prospect matcher. Add a request test with high_risk_industry:"" and verify both stored nil and the same green/not-requested result as omission.

**Acceptance check:** Compare an omitted industry with the browser value empty string; assert identical stored and matched semantics.

**Evidence:** Runtime probe plus source trace. Confidence anchor 100/100.

### #21 P2 - Gap unlocks confuse one dimension with one requirement

**Location:** [app/services/compass/unserved_analysis.rb:107](/home/stevan/dev/compass/app/services/compass/unserved_analysis.rb:107)

A provider missing both an EUR collection account and a USD sending account is labeled a closest miss, with the headline Unlocked by any one of those two missing accounts. Both need fixing before it can serve; the service counts the whole accounts dimension as one requirement and flattens its multiple reasons into alternatives. Multiple exclusions in the same dimension create the same false advice.

**Fix:** Represent each provider's atomic blockers as a conjunction and alternative providers as disjunctions. Only call a single label an unlock when that provider has exactly one independent blocking requirement; otherwise render the grouped set of required fixes and adjust closest-miss text accordingly.

**Acceptance check:** Use a provider missing two independent accounts; assert both are required together and never described as alternatives.

**Evidence:** Runtime probe plus source trace. Confidence anchor 100/100.

### #22 P2 - Jurisdiction gaps name the sending country instead of client

**Location:** [app/services/compass/unserved_analysis.rb:154](/home/stevan/dev/compass/app/services/compass/unserved_analysis.rb:154)

A German client sending from the UK who is rejected for German onboarding jurisdiction appears in analytics as Jurisdiction: GB not accepted. Gap signatures are built from this label, so German and other client-country failures are misgrouped by sending country and the team is directed toward the wrong coverage opportunity. This affects gap headlines, CSV exports, and closest-miss reasons.

**Fix:** Build the jurisdiction label from lookup.client_country, using the shared country/group display helper or an equivalent domain label. Test client_country distinct from send_from_country and ensure different client jurisdictions produce separate signatures.

**Acceptance check:** Use a German client sending from GB and assert the gap is German jurisdiction, including its grouping key and CSV.

**Evidence:** Runtime probe plus source trace. Confidence anchor 100/100.

### #23 P2 - Analytics filters and lookup account rows overflow phone screens

**Location:** [app/views/admins/compass/analytics/index.html.haml:18](/home/stevan/dev/compass/app/views/admins/compass/analytics/index.html.haml:18)

At a 390px viewport, Analytics creates a 573px-wide document: Filter and Export CSV sit outside the visible screen. Lookup Trade Details also expands the document to 415px because the account-requirements table cannot shrink. Unlike the provider and prospect indexes, these flows do not contain their overflow within a deliberate table scroller.

**Fix:** Make the Analytics date/filter form wrap or stack below the medium breakpoint, with full-width date controls and reachable actions. Put the lookup account table in a contained responsive wrapper, or render each requirement as a labeled stacked row on small screens. Verify at 390px and at increased text zoom.

**Acceptance check:** At 390px and increased text zoom, assert document width fits and Filter, Export and account-row actions remain reachable.

**Evidence:** Browser measured: Analytics 573px and Trade Details 415px wide at a 390px viewport. Confidence anchor 100/100.

### #24 P2 - Analytics lookup links fail for colleagues and API searches

**Location:** [app/views/admins/compass/analytics/unserved.html.haml:36](/home/stevan/dev/compass/app/views/admins/compass/analytics/unserved.html.haml:36)

Analytics loads every lookup in the date range, including searches by other admins and API clients, and offers View lookup for all of them. The destination scopes lookup records to the current admin, so those normal cross-team/API entries return 404. The opportunity investigation flow breaks precisely when sharing team demand.

**Fix:** Provide an authorized read-only snapshot/detail route under analytics and link it from the gap breakdown, retaining existing ownership restrictions on rerun/export as appropriate. At minimum, conditionally avoid a link the current admin cannot open; add two-admin and API-source navigation coverage.

**Acceptance check:** Open analytics entries for the current admin, a colleague and an API client; verify the intended read-only detail experience.

**Evidence:** Source and call-chain review. Independently rechecked. Confidence anchor 100/100.

### #25 P2 - Autocomplete omits combobox semantics

**Location:** [app/views/admins/compass/conversations/_form.html.haml:15](/home/stevan/dev/compass/app/views/admins/compass/conversations/_form.html.haml:15)

The participants suggestions visually open and support arrow keys, but assistive technology receives a plain text field and an unrelated menu. It cannot announce that suggestions exist, which option is active, or whether the popup is open. Applying the standard combobox/listbox attributes makes the existing keyboard behavior discoverable.

**Fix:** Give the input `role=combobox`, `aria-autocomplete=list`, `aria-controls`, and dynamic `aria-expanded`/`aria-activedescendant`; give the menu `role=listbox`, options stable ids and `role=option`, and update `aria-selected` during navigation.

**Acceptance check:** Exercise autocomplete with keyboard and inspect combobox/listbox, expanded state and active option relationships.

**Evidence:** Source and call-chain review. Confidence anchor 100/100.

### #26 P2 - Upload selection count becomes false

**Location:** [app/views/admins/compass/uploads/show.html.haml:105](/home/stevan/dev/compass/app/views/admins/compass/uploads/show.html.haml:105)

The confirmation footer continues to promise that every valid row will be imported after users deselect individual rows or use Select all. On a destructive review screen, a stale count weakens the user's last chance to verify scope. Recompute the count from checked valid-row boxes on connect and every change.

**Fix:** Add a counter target, attach a change action to each row checkbox, and update both the footer count and select-all indeterminate state after individual and bulk changes. Label the header control `Select all valid rows` because invalid rows have no checkbox.

**Acceptance check:** Toggle individual rows and Select all; assert footer count and indeterminate state match eligible selected rows.

**Evidence:** Source and call-chain review. Confidence anchor 100/100.

### #27 P2 - Document failures never reach the configured retry policy

**Location:** [app/workers/compass/process_document_upload_worker.rb:23](/home/stevan/dev/compass/app/workers/compass/process_document_upload_worker.rb:23)

A transient document API timeout/rate-limit or processing failure permanently fails the upload after one attempt despite retry:2. The extraction service converts transport exceptions into false; the worker also swallows raised exceptions, so Sidekiq sees success and cannot perform the configured retries.

**Fix:** Separate permanent validation failures from retryable transport failures, let retryable exceptions reach Sidekiq, and set terminal failure only when retries are exhausted. Add an idempotent status/claim guard so duplicate job delivery cannot overwrite completed uploads; provide Retry on the retained failed upload and verify bounded retries with a success on a later attempt.

**Acceptance check:** Make a retryable transport failure succeed on a later attempt; assert bounded retries and no overwrite of confirmed work.

**Evidence:** Source and call-chain review. Confidence anchor 100/100.

### #28 P2 - Duplicate requirements fail without explanation

**Location:** [app/assets/javascripts/stimulus/pages/admins/compass/account_requirements_controller.js:103](/home/stevan/dev/compass/app/assets/javascripts/stimulus/pages/admins/compass/account_requirements_controller.js:103)

Choosing the same currency and direction twice only turns both rows red; there is no text error, focus movement, or submission guard. Submitting then returns a generic page-level message because the server error belongs to a hand-written hidden field with no rendered error block. Users can see that something failed but not what to correct.

**Fix:** Render an `aria-live` error beside the table, set `aria-invalid` on duplicate selects, disable or intercept submit while duplicates exist, and render `f.error :account_requirements` after a server rejection.

**Acceptance check:** Create duplicate account rows, submit, and assert a specific visible error, error focus and retained values.

**Evidence:** Source and call-chain review. Confidence anchor 75/100.

### #29 P2 - Polling can navigate after leaving page

**Location:** [app/assets/javascripts/stimulus/pages/admins/compass/extraction_polling_controller.js:62](/home/stevan/dev/compass/app/assets/javascripts/stimulus/pages/admins/compass/extraction_polling_controller.js:62)

Leaving an extraction progress page while a request is in flight does not cancel its promise. If that response finishes after Turbo has mounted another page, the disconnected controller can still redirect or reload the user's new page. The fixed interval can also overlap requests on a slow connection, letting multiple terminal callbacks compete.

**Fix:** Use one self-scheduling poll at a time, store an AbortController for the active fetch, abort it in disconnect, and guard `handleStatus` with a connected flag. Apply the same pattern to upload polling.

**Acceptance check:** Delay a status response, leave the page, then release it; assert no navigation. Verify at most one in-flight poll.

**Evidence:** Source and call-chain review. Confidence anchor 75/100.

### #30 P2 - Accepted legacy XLS files are parsed as XLSX

**Location:** [app/controllers/admins/compass/uploads_controller.rb:18](/home/stevan/dev/compass/app/controllers/admins/compass/uploads_controller.rb:18)

A genuine legacy Excel .xls workbook is accepted by both file-type detection and attachment validation, then fails to parse because it is forced through Roo::Excelx as a ZIP-based .xlsx workbook. Users encounter an extraction/parsing error after a file the app explicitly accepts.

**Fix:** Either reject legacy .xls before saving with a clear Save as .xlsx or CSV message, or implement a supported legacy parser and preserve the true file type. Add one real legacy-format fixture so allowed upload formats agree with the parser.

**Acceptance check:** Upload a genuine binary XLS fixture; assert supported parsing or a clear pre-upload format rejection.

**Evidence:** Source and call-chain review. Confidence anchor 75/100.

### #31 P2 - CLI extraction retains an extra transcript copy

**Location:** [app/services/compass/claude_subscription_client.rb:56](/home/stevan/dev/compass/app/services/compass/claude_subscription_client.rb:56)

Every extraction launches the normal Claude Code print-mode client with the full prompt on stdin and without disabling session persistence. Its default local session history can retain conversation content and replies outside both the logged conversation and the 24-hour draft cache. Rails log filtering and deleting the draft do not remove this second store.

**Fix:** Pass --no-session-persistence to the CLI on every extraction, add a launcher-argument regression test, and verify persistence behavior with an approved synthetic probe in an isolated CLI home. Inventory and remove only application-created historical extraction sessions under an explicit retention operation.

**Acceptance check:** Assert extraction passes the no-persistence flag; verify a synthetic isolated CLI run creates no session transcript.

**Evidence:** Source and call-chain review. Confidence anchor 75/100.

### #32 P2 - URL extraction downloads unlimited response bodies

**Location:** [app/services/compass/extract_from_document.rb:133](/home/stevan/dev/compass/app/services/compass/extract_from_document.rb:133)

A URL source can return an arbitrarily large response or keep streaming chunks indefinitely. Net::HTTP buffers the entire body before Nokogiri creates another representation, and read_timeout bounds waiting between reads rather than total elapsed time. Unlike attached files, URL content has no 15 MB cap, so one accepted URL can consume a worker and substantial memory.

**Fix:** Stream the response through read_body with a strict byte budget (start with the existing 15 MB upload limit), enforce a total fetch deadline, validate an expected content type, and bound extracted text before model submission. Test chunked bodies with no Content-Length and a body that exceeds the limit.

**Acceptance check:** Serve a chunked response beyond the byte limit and one that streams indefinitely; assert prompt failure within total limits.

**Evidence:** Source and call-chain review. Confidence anchor 75/100.

### #33 P2 - Every rolodex page recomputes all recent gap matching

**Location:** [app/services/compass/gap_prospects.rb:40](/home/stevan/dev/compass/app/services/compass/gap_prospects.rb:40)

Opening, filtering, or paging the prospect index reruns prospect matching for every zero-match lookup in the last 30 days before pagination. Each run loads every prospect and seven evidence associations plus provenance and recency; the existing test contract confirms 1 + 24xlookup_count query events. With 100 unserved searches a simple list request executes about 2,401 gap-query events and repeatedly allocates the same evidence, including contact data GapProspects immediately discards. These are query events and repeated object work; Rails query caching can avoid some database round trips. Production latency was not benchmarked.

**Fix:** Load the prospect evidence set once per coverage calculation, reuse it across lookups, and skip recency work when only gap suggestions are needed. Memoize or cache coverage by capability/lookup revision with a short expiry, and add a benchmark/query assertion that grows lookup count without rereading all associations; preserve the current per-gap dedup semantics.

**Acceptance check:** Increase unserved lookups and evidence volume together; count uncached queries, allocations and latency, proving evidence reuse.

**Evidence:** Source and call-chain review. Confidence anchor 75/100.

### #34 P3 - Breadcrumb landmark name is misspelled

**Location:** [app/helpers/admins/layout_helper.rb:4](/home/stevan/dev/compass/app/helpers/admins/layout_helper.rb:4)

Every admin breadcrumb renders an unknown `aria-lable` attribute, so screen-reader landmark navigation announces only a generic navigation region. Because pages also have the global navigation, users cannot distinguish the breadcrumb landmark. Correcting the attribute fixes every screen through the shared helper.

**Fix:** Rename `aria-lable` to `aria-label` in `admin_breadcrumbs` and add a helper rendering assertion so the typo cannot recur.

**Acceptance check:** Assert the shared navigation landmark has the accessible name Breadcrumb.

**Evidence:** Source and call-chain review. Confidence anchor 100/100.

### #35 P3 - Sign-in document has no language

**Location:** [app/views/layouts/unauthenticated.html.haml:2](/home/stevan/dev/compass/app/views/layouts/unauthenticated.html.haml:2)

The sign-in and password-reset document does not declare its language, so screen readers may choose the wrong pronunciation rules based on user or browser defaults. The admin and two-factor layouts already use `lang: :en`, making the fix consistent and low risk.

**Fix:** Change `%html` to `%html{ lang: :en }`, matching the admin and two-factor layouts.

**Acceptance check:** Assert the unauthenticated document declares English.

**Evidence:** Source and call-chain review. Confidence anchor 100/100.

## UI evidence

Screenshots use synthetic data from this exact checkout.

### Advertised wildcard defaults fail

The form tells the operator to leave both fields blank, then rejects both values. Finding #5.

[Open screenshot](screens/wildcard-account-error.png)

### A checked requirement with hidden details

Modify Search retains the forward toggle but hides its saved fields. Finding #10.

[Open screenshot](screens/rerun-forward-hidden.png)

### Analytics actions outside the viewport

At 390px, Filter and Export CSV are beyond the visible screen. Finding #23.

[Open screenshot](screens/analytics-mobile.png)

### Trade Details exceeds phone width

The account requirement table expands the document. It needs a contained or stacked layout. Finding #23.

[Open screenshot](screens/lookup-step2-mobile.png)

### Decision confidence needs clearer language

Result disclosures need keyboard controls (#9). Separately, unknown-versus-verified confidence should be clearer in the product language.

[Open screenshot](screens/lookup-results.png)

### A useful existing evidence hub

The profile keeps full notes, conversations and contacts together. Keep this structure while improving next actions and confidence.

[Open screenshot](screens/prospect-profile-desktop.png)

## Product and architecture opportunities

These are proposed improvements, separate from confirmed defects. Existing decisions and explicitly future scope are identified.

### O1. Separate "can investigate" from "verified fit" (Product decision)

The current contract intentionally includes unknown (grey) checks in full_match and labels these partners Can Serve. This encourages discovery but can make missing evidence read as approval.

**Proposed change:** Keep inclusive results, add Verified fit / Needs confirmation counts, and put the unresolved criteria directly beside the verdict. Preserve the strict separation between partners and prospects.

**Grounding:** app/services/compass/match_providers.rb:10; app/views/admins/compass/lookups/show.html.haml

**Success check:** Measure whether a user can identify which criteria still need confirmation without opening every row.

### O2. Make saved lookups explain when they were evaluated (High value)

Analytics reads stored results, while opening a lookup or exporting it evaluates current capabilities. The same lookup can therefore tell a different story across screens.

**Proposed change:** Show Searched at and Evaluated at, label the current live evaluation, and offer a comparison with the original snapshot. Store an evaluation/schema version alongside snapshots.

**Grounding:** app/controllers/admins/compass/lookups_controller.rb; app/controllers/api/v1/lookups_controller.rb; app/services/compass/unserved_analysis.rb

**Success check:** Change a capability after saving a lookup and verify the difference is explicit in UI, API and exports.

### O3. Give the lookup wizard a persistent brief summary (High value)

Staff must go Back to check entity, route, currencies and cadence while setting account and forward requirements. That adds navigation and increases the impact of state-loss defects.

**Proposed change:** Show a compact editable brief above Trade Details. Mark affected requirements when currencies change. On rejection, reveal the step with the first error, focus its summary, and preserve all values.

**Grounding:** app/views/admins/compass/lookups/new.html.haml; app/assets/javascripts/stimulus/pages/admins/compass/lookup_wizard_controller.js

**Success check:** Complete and correct a lookup using only keyboard navigation without losing any previously entered requirement.

### O4. Treat import review as a clear decision surface (High value)

The preview is the operator's last chance to protect live data. Incorrect selection semantics and status feedback undermine that role.

**Proposed change:** Show selected, excluded, conflicting and invalid counts; keep the confirmation footer visible for long imports; make before/after changes easy to compare. Separate permanent invalid data from temporary extraction failures and support retry of retained uploads.

**Grounding:** app/views/admins/compass/uploads/show.html.haml; app/services/compass/apply_upload.rb; app/workers/compass/process_document_upload_worker.rb

**Success check:** An operator can say exactly what will change and recover from a timeout without re-uploading or accidentally applying excluded rows.

### O5. Repair contrast while preserving the visual identity (Design decision)

Rendered white text measures 2.28:1 on success green, 2.06:1 on warning amber, and 3.39:1 on primary blue. The sampled labels are 12px or 16px; normal text needs 4.5:1 for WCAG AA. The current design document deliberately sets a 2:1 Sass threshold.

**Proposed change:** Update the design tokens and the binding design document together. Darken filled backgrounds or use dark text where appropriate; preserve hue and component structure. Also add a main landmark and an accessible page heading while retaining the existing visual style.

**Grounding:** app/assets/stylesheets/variables.scss:26; app/views/layouts/admin.html.haml; docs/LIVE-DESIGN-LANGUAGE.md

**Success check:** Recheck computed colors for default, hover, disabled and focus states. This is a measured accessibility tradeoff, not a claim that the implementation departed from the current design spec.

Standard: [W3C contrast guidance](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html).

### O6. Turn stale evidence into an actionable review queue (High value)

Provider freshness is prominent, but users must discover the appropriate capability editor before updating stale information.

**Proposed change:** Link stale criteria to their source and editor. Offer a focused queue of records needing confirmation, with the reason and latest source. Keep prospect recency neutral, as the current product decision requires.

**Grounding:** app/helpers/admins/compass_helper.rb; app/models/compass/capability_provenance.rb; app/services/compass/provider_recency.rb

**Success check:** A user can move from a stale result to the relevant evidence and record a new confirmation with a clear audit trail.

### O7. Use one result contract across matching consumers (Architecture)

Admin and API serializers use different pair and forward keys, and analytics carries compatibility branches. This makes semantics easier to drift.

**Proposed change:** Define a versioned domain result and keep UI/API/CSV/PDF presentation adapters explicit. Share currency, country, boolean and account-requirement normalization at ingress. Keep protocol changes backward compatible.

**Grounding:** app/controllers/concerns/admins/compass/results_serializer.rb; app/controllers/api/v1/lookups_controller.rb; app/services/compass/unserved_analysis.rb

**Success check:** Contract fixtures for one lookup produce equivalent verdicts and parameters in saved snapshots, UI, API and exports.

### O8. Distinguish unavailable calculations from zero results (High value)

Several auxiliary failures become [] or {}, which can look like no prospect evidence or zero gap coverage. Failure resilience currently hides useful status.

**Proposed change:** Keep the main page available, but render an explicit unavailable state with retry and last-successful calculation time. Load evidence once for gap coverage, then cache by a clear data revision if measurements justify it.

**Grounding:** app/controllers/admins/compass/lookups_controller.rb; app/controllers/admins/compass/prospects_controller.rb; app/services/compass/gap_coverage.rb

**Success check:** Force the auxiliary calculation to fail and verify the user can distinguish it from a successful empty result.

### O9. Make opportunity ranking answer the actual missing need (Product decision)

Frequency and broad prospect evidence are useful discovery signals, but evidence on another dimension does not show that a firm can close the gap in question.

**Proposed change:** Separate Worth researching from Evidence for this blocker. Show cadence, transfer currency, amount and pricing context beside demand; avoid combining currencies into one value without an explicit conversion rule.

**Grounding:** app/services/compass/gap_coverage.rb; app/services/compass/gap_prospects.rb; app/controllers/concerns/admins/compass/analytics_queries.rb

**Success check:** Use a missing-account gap and a prospect with only corridor evidence; the UI should make the distinction obvious.

### O10. Connect research to a next action, without expanding V1 silently (Future scope)

Conversations, contacts and source records capture evidence, but they do not capture who will request missing confirmation or when to follow up.

**Proposed change:** For a later product increment, add a lightweight owner, requested confirmation and follow-up date linked to the existing firm/conversation. Keep pipeline dashboards and prospect graduation outside V1 unless deliberately approved.

**Grounding:** prd-rolodex.md:90; app/models/compass/conversation.rb; app/models/compass/provider_contact.rb

**Success check:** A gap can lead to one assigned confirmation request and return to the evidence that resolves it.

### O11. Make everyday navigation and account settings easier to find (Quick UX improvement)

The main navigation does not indicate the current section, the menu does not expose 2FA settings, and recent searches act as rerun links rather than a browsable history.

**Proposed change:** Add a visible current-section state and aria-current, expose Security in the account menu, and give saved searches clear View result versus Modify actions. Let users expand long prospect summaries in place if scanning them becomes a frequent task.

**Grounding:** app/views/admins/shared/_navigation.html.haml; app/views/admins/compass/lookups/_recent_searches.html.haml; app/views/admins/compass/prospects/_table.html.haml

**Success check:** A user can locate their security settings and inspect a previous result without guessing a URL or starting a new evaluation.

### O12. Align setup documentation and regression checks with this app (Engineering quality)

CLAUDE.md describes Rails 8.0.3 and Angular 18, while Gemfile locks Rails 8.1.2 and this checkout uses HAML, Sprockets, Turbo and Stimulus. README still asks for webpacker/yarn and a package.json absent from this checkout.

**Proposed change:** Document the current stack, isolated test database setup, asset pipeline, extraction transport, worker startup and local preview commands. Add focused browser/API contract checks for the failures in this report, and keep existing intentional-invariant tests.

**Grounding:** CLAUDE.md; README.md; Gemfile; spec/spec_helper.rb; spec/system/admins/compass/lookup_wizard_spec.rb

**Success check:** A fresh contributor can run a synthetic preview and the full suite from the documented commands without relying on another checkout or shared database.

## Secondary hardening and unverified risks

**Pin URL validation to the actual connection.** DNS is checked before Net::HTTP resolves the hostname again. HTTPS verification limits a generic private-data exfiltration claim; a rebinding exploit was not demonstrated. Pin the validated address while preserving TLS hostname checks, and test IPv4-mapped IPv6 and changing DNS.

**Constrain the extraction CLI to extraction.** The launcher keeps default CLI tools and local settings active and combines intended system and user content into one prompt. Disable unnecessary tools/configuration for this pure transformation. No prompt-injection exploit was attempted.

**Check operational boundaries in the deployed environment.** Public health responses can expose detailed dependency errors; keep public readiness coarse. Exercise Puma worker boot with the actual deployment configuration. Local findings do not establish a production outage or secret disclosure.

**Complete the reset-token lifecycle when repairing 2FA removal.** Explicitly reject missing, expired and consumed tokens; clear tokens after use and rotate on reenrolment. The missing mailer currently blocks the advertised flow, and no functioning email replay exploit was demonstrated.

## Coverage

**Scope:** Whole current checkout, including existing tracked and untracked 2FA work; not a PR diff or a line-by-line certification.

**Revision:** main at 1ea8a85508b82e033c96c11cbe94ef6580b24adc plus the pre-existing working tree changes.

**Method:** Three specialist subagents, parent source/browser review, two independent validation batches, deterministic findings validation and deduplication.

**Tests:** 2228 examples, 0 failures; seed 46121; 1m45 execution. Full RSpec suite, including existing browser specs, ran against a dedicated scratch database. Live external probes were excluded.

**Browser:** Current checkout booted with synthetic data. Fourteen route captures at 1440px desktop and 390px phone widths, plus lookup step/results/rerun reproductions. Initial seven-route smoke pass recorded no JavaScript page errors.

**Runtime:** Seven matching/analytics method probes, parameter-filter probes, and exact import controller execution on synthetic fixtures. Browser confirmed wildcard rejection, requirement loss, hidden forward rerun and viewport overflow.

**Limits:** No real partner documents, live extraction calls, production traffic/load, deployed access controls or manual screen-reader session were exercised. Performance estimates describe query events and object work, not measured production latency.

**Cross Model:** No external cross-model review was run. In-session subagents covered the full-checkout audit; the skill's external diff-only pass was not used for this scope.

**Reconciliation:** One claimed design violation (Rolodex note truncation) was downgraded to an opportunity because the full note is visible on the profile. A static rejection of partial import commits was overturned by exact runtime evidence. Country-group aggregate semantics remain an explicit decision.

**Preservation:** No application code was changed. Existing dirty and untracked work was preserved. Only review artifacts were added. The temporary UI process is stopped after capture; synthetic review databases are retained for reproducibility.

**Standards:** CLAUDE.md and the repo design/PRD documents informed review. This is not a complete requirement-by-requirement acceptance audit. Existing design decisions are called out as decisions rather than mislabeled implementation regressions.

Accessibility reference: [W3C keyboard guidance](https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html) and [W3C combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/).

## Verdict

Prioritize corrective work before relying on import confirmation and match verdicts for decisions. Preserve the existing partner/prospect boundary and source audit trail. Begin with #3, #6 and #7, then #11/#12, and the matching and wizard groups.

## Actionable Findings

| # | Priority | Finding | Location |
|---|---|---|---|
| 1 | P1 | Back navigation erases account requirements | `app/assets/javascripts/stimulus/pages/admins/compass/account_requirements_controller.js:25` |
| 2 | P1 | Admin email edits update an unused account column | `app/controllers/admins/admins_controller.rb:45` |
| 3 | P1 | Deselected upload rows are still imported | `app/controllers/admins/compass/uploads_controller.rb:161` |
| 4 | P1 | Turning off 2FA calls a missing mailer | `app/controllers/two_factor_auths_controller.rb:80` |
| 5 | P1 | Wildcard capability form options cannot be saved | `app/models/compass/account_capability.rb:24` |
| 6 | P1 | Document boolean false becomes supported true | `app/services/compass/apply_upload.rb:178` |
| 7 | P1 | Failed imports commit earlier writes and deletions | `app/services/compass/apply_upload.rb:22` |
| 8 | P1 | Either forwards accepts providers offering neither forward type | `app/services/compass/match_providers/forward_matching.rb:48` |
| 9 | P1 | Lookup details are mouse-only | `app/views/admins/compass/lookups/_result_row.html.haml:6` |
| 10 | P1 | Rerun hides required forward fields | `app/views/admins/compass/lookups/new.html.haml:165` |
| 11 | P1 | Request logs retain conversation content and authentication codes | `config/initializers/filter_parameter_logging.rb:6` |
| 12 | P1 | The six-digit 2FA challenge allows unlimited guesses | `app/controllers/two_factor_auths_controller.rb:66` |
| 13 | P1 | Country-group searches misclassify partners and ignore restrictions | `app/services/compass/match_providers/jurisdiction_matching.rb:11` |
| 14 | P2 | Account row controls have no names | `app/assets/javascripts/stimulus/pages/admins/compass/account_requirements_controller.js:163` |
| 15 | P2 | Failed document uploads return silently to the capability tab | `app/assets/javascripts/stimulus/pages/admins/compass/upload_polling_controller.js:47` |
| 16 | P2 | Malformed account requirements fail open or crash | `app/controllers/admins/compass/lookups_controller.rb:139` |
| 17 | P2 | Validate currency elements before producing lookup matches | `app/models/compass/lookup.rb:36` |
| 18 | P2 | Missing client country raises a database error | `app/models/compass/lookup.rb:89` |
| 19 | P2 | Restricted currency pairs incorrectly receive direct prospect ranking | `app/services/compass/match_prospects/corridor_evidence.rb:17` |
| 20 | P2 | Selecting no industry produces a false data-gap indicator | `app/services/compass/match_providers/industry_matching.rb:27` |
| 21 | P2 | Gap unlocks confuse one dimension with one requirement | `app/services/compass/unserved_analysis.rb:107` |
| 22 | P2 | Jurisdiction gaps name the sending country instead of client | `app/services/compass/unserved_analysis.rb:154` |
| 23 | P2 | Analytics filters and lookup account rows overflow phone screens | `app/views/admins/compass/analytics/index.html.haml:18` |
| 24 | P2 | Analytics lookup links fail for colleagues and API searches | `app/views/admins/compass/analytics/unserved.html.haml:36` |
| 25 | P2 | Autocomplete omits combobox semantics | `app/views/admins/compass/conversations/_form.html.haml:15` |
| 26 | P2 | Upload selection count becomes false | `app/views/admins/compass/uploads/show.html.haml:105` |
| 27 | P2 | Document failures never reach the configured retry policy | `app/workers/compass/process_document_upload_worker.rb:23` |
| 28 | P2 | Duplicate requirements fail without explanation | `app/assets/javascripts/stimulus/pages/admins/compass/account_requirements_controller.js:103` |
| 29 | P2 | Polling can navigate after leaving page | `app/assets/javascripts/stimulus/pages/admins/compass/extraction_polling_controller.js:62` |
| 30 | P2 | Accepted legacy XLS files are parsed as XLSX | `app/controllers/admins/compass/uploads_controller.rb:18` |
| 31 | P2 | CLI extraction retains an extra transcript copy | `app/services/compass/claude_subscription_client.rb:56` |
| 32 | P2 | URL extraction downloads unlimited response bodies | `app/services/compass/extract_from_document.rb:133` |
| 33 | P2 | Every rolodex page recomputes all recent gap matching | `app/services/compass/gap_prospects.rb:40` |
| 34 | P3 | Breadcrumb landmark name is misspelled | `app/helpers/admins/layout_helper.rb:4` |
| 35 | P3 | Sign-in document has no language | `app/views/layouts/unauthenticated.html.haml:2` |
