Codebase + experience review

Compass codebase
& experience review.

Compass has useful provider, prospect and evidence workflows. The urgent work is protecting the operator's intent: what they select, what they enter, and what a match actually means.

35Actionable findings
13High priority defects
2,228Existing tests passing
12Product opportunities
Fix import integrity first.

Deselected rows still import. Extracted false can become supported. A failed import can retain earlier writes and deletions. These are confirmed behaviors, including an exact controller/database reproduction.

Keep the existing strengths: partner/prospect separation, server-stamped source evidence, and the substantial regression suite. All application code and existing work were left intact.

02 / Fix order

Five coherent workstreams.

Resolve the data and security failures first. Then repair matching and interaction behavior before broadening the product. Finding numbers remain stable across this report and the downloads.

Prioritized workstreams and their findings
Start hereImport integrity

Make preview selection and typed values authoritative, then prove rollback through the controller. Follow with recoverable jobs and bounded file/URL handling.

Start hereAuthentication and privacy

Close log-retention and OTP-guessing gaps. Repair email changes and 2FA removal; keep extraction history out of the CLI session store.

NextMatching and input contracts

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.

NextUsability and accessibility

Preserve wizard state; reveal and focus actionable errors; provide keyboard-operable disclosures and named controls. Contain mobile layout and cancel stale polling.

ThenAnalytics and scale

Represent the actual blockers, keep team drill-downs accessible, and reuse loaded prospect evidence before adding coverage caches.

03 / Findings

Concrete defects, with a check for each fix.

13 high priority, 20 moderate, 2 low. No critical P0 was established. Open a row for its consequence, fix direction, source evidence and acceptance check. The passing suite does not cover these behaviors.

Showing 35 of 35 findings

01Back navigation erases account requirementsapp/assets/javascripts/stimulus/pages/admins/compass/account_requirements_controller.js:25P1

Usability and accessibility / Browser reproduced: Named Required changed to Any after Back/Next.

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 direction

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.

Source evidence / confidence 100
app/assets/javascripts/stimulus/pages/admins/compass/account_requirements_controller.js:25-27 -- } else { this.generateDefaults(); }
app/assets/javascripts/stimulus/pages/admins/compass/lookup_wizard_controller.js:30 -- this.populateStep2Currencies();
app/assets/javascripts/stimulus/pages/admins/compass/account_requirements_controller.js:46 -- this.tbodyTarget.innerHTML = '';
02Admin email edits update an unused account columnapp/controllers/admins/admins_controller.rb:45P1

Authentication and privacy / Source and call-chain review. Independently rechecked.

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 direction

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.

Source evidence / confidence 100
app/controllers/admins/admins_controller.rb:45: @admin.assign_attributes(admin_params)
app/views/admins/admins/_form.html.haml:4 renders the email input and lines 6-9 display credentials.unconfirmed_email.
Account:18 delegates only the email reader to credentials; db/schema.rb:70 still defines an accounts.email column whose generated writer receives this assignment.
AdminsController#admin_params:79 permits email alongside account fields, while create correctly splits it into AccountCredentials at :24-31.
03Deselected upload rows are still importedapp/controllers/admins/compass/uploads_controller.rb:161P1

Import integrity / Source and call-chain review. Independently rechecked.

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 direction

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.

Source evidence / confidence 100
app/controllers/admins/compass/uploads_controller.rb:161: params.permit(
app/views/admins/compass/uploads/_preview_row.html.haml:8-11 submits selection as row_include[index]; the action input/select is independent at lines 30-39.
app/assets/javascripts/stimulus/pages/admins/compass/upload_preview_controller.js:4-10 changes checkbox.checked and opacity only. It never updates row_actions on deselection.
UploadsController#confirm_params:161-164 permits only row_actions and replace_all; ApplyUpload#process_rows:57 defaults missing actions to import.
04Turning off 2FA calls a missing mailerapp/controllers/two_factor_auths_controller.rb:80P1

Authentication and privacy / Source and call-chain review. Independently rechecked.

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 direction

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.

Source evidence / confidence 100
app/controllers/two_factor_auths_controller.rb:80: AccountCredentialsMailer
app/views/two_factor_auths/show.html.haml:16 and app/views/two_factor_auth_recoveries/show.html.haml:15 link to request_reset_two_factor_auth_path.
TwoFactorAuthsController#request_reset:78 creates the reset token, then :80-81 calls AccountCredentialsMailer.reset_two_factor_auth.
Only app/mailers/devise_mailer.rb exists. Repository search found no AccountCredentialsMailer definition or reset_two_factor_auth method/template.
05Wildcard capability form options cannot be savedapp/models/compass/account_capability.rb:24P1

Matching and input contracts / Browser reproduced: both wildcard defaults return field validation errors.

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 direction

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.

Source evidence / confidence 100
app/models/compass/account_capability.rb:24: validates :currency, currency: true, allow_nil: true
app/models/compass/account_capability.rb:25: validates :iban_country, country_or_group: true, allow_nil: true
app/models/compass/entity_restriction.rb:19: validates :client_country, country: true, allow_nil: true
app/views/admins/compass/account_capabilities/_form.html.haml:24: include_blank: 'All currencies',; line 32: include_blank: 'Any country',
06Document boolean false becomes supported trueapp/services/compass/apply_upload.rb:178P1

Import integrity / Runtime probe plus source trace. Independently rechecked.

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 direction

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.

Source evidence / confidence 100
app/services/compass/apply_upload.rb:178: supported: data['supported'] != 'false',
ExtractFromDocument#build_results:271 keeps raw JSON values without normalization. A raw supported:false value passes through unchanged.
ApplyUpload#mutable_attributes:178 and :190 compare only with the string false. Ruby false != string false is true.
app/views/admins/compass/uploads/_data_cells.html.haml:8 and :17 repeat the same string-only check, so the preview also misrepresents the negative result.
07Failed imports commit earlier writes and deletionsapp/services/compass/apply_upload.rb:22P1

Import integrity / Runtime probe plus source trace. Independently rechecked.

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 direction

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.

Source evidence / confidence 100
app/services/compass/apply_upload.rb:22: model_class.transaction do
app/controllers/admins/compass/uploads_controller.rb:72 opens @upload.with_lock, which owns the outer transaction, then invokes applier.perform at line 86.
ApplyUpload#perform:22 joins that existing transaction, processes rows at line 27, and rescues StandardError at lines 32-34 without re-raising or marking the outer transaction for rollback.
ExtractFromDocument#build_results:275 labels any nonempty extracted object valid, so a document row with an invalid enum reaches record construction; ApplyUpload#upsert_row rescues only RecordInvalid, not ArgumentError.
08Either forwards accepts providers offering neither forward typeapp/services/compass/match_providers/forward_matching.rb:48P1

Matching and input contracts / Runtime probe plus source trace. Independently rechecked.

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 direction

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.

Source evidence / confidence 100
app/services/compass/match_providers/forward_matching.rb:48: return if lookup.forward_type_either?
app/services/compass/match_providers/forward_matching.rb:35: { status: :green, conditions: capability.conditions, deposit: deposit }
spec/services/compass/match_providers_spec.rb:1655-1679 deliberately builds deliverable:false, ndf_available:false and expects green for either; this assertion currently locks in the defect.
app/services/compass/match_prospects/forward_evidence.rb: forward_contradiction only rejects specific deliverable/ndf requests.
09Lookup details are mouse-onlyapp/views/admins/compass/lookups/_result_row.html.haml:6P1

Usability and accessibility / Source and call-chain review.

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 direction

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.

Source evidence / confidence 100
app/views/admins/compass/lookups/_result_row.html.haml:6-9 -- %tr{ class: row_class, data: { compass_results_target: 'row', action: 'click->compass-results#toggleDetail' }, style: 'cursor: pointer' }
app/assets/javascripts/stimulus/pages/admins/compass/compass_results_controller.js:14 -- detailRow.classList.toggle('d-none');
10Rerun hides required forward fieldsapp/views/admins/compass/lookups/new.html.haml:165P1

Usability and accessibility / Browser reproduced: forward toggle checked=true, fields visible=false.

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 direction

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.

Source evidence / confidence 100
app/views/admins/compass/lookups/new.html.haml:165 -- .row{ data: { 'lookup-wizard-target': 'forwardFields' }, style: 'display: none' }
app/views/admins/compass/lookups/new.html.haml:161 -- checked: @lookup.forward_required?
app/assets/javascripts/stimulus/pages/admins/compass/lookup_wizard_controller.js:16-20 -- connect() updates entity type and label but never calls toggleForwards().
11Request logs retain conversation content and authentication codesconfig/initializers/filter_parameter_logging.rb:6P1

Authentication and privacy / Runtime probe plus source trace.

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 direction

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.

Source evidence / confidence 100
config/initializers/filter_parameter_logging.rb:6: :identity_token, :authorization_code, :raw_text, :transcript
app/views/admins/compass/transcript_ingestions/clarify.html.haml:28, :37 and :58 submit summary, participants and answers[qN].
TranscriptIngestionsController#refine:75-77 reads and persists these fields; Rails logs request Parameters before action-level handling.
Synthetic parameter-filter runtime probe returned raw_text=[FILTERED], but preserved summary, participants, answers.q1 verbatim.
12The six-digit 2FA challenge allows unlimited guessesapp/controllers/two_factor_auths_controller.rb:66P1

Authentication and privacy / Source and call-chain review.

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 direction

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.

Source evidence / confidence 75
app/controllers/two_factor_auths_controller.rb:66: if current_account_credentials.try_to_otp_authenticate!(params[:otp_code])
TwoFactorAuthsController#authenticate:61-71 checks and redirects without an attempt counter or cooldown.
HasOneTimePassword#try_to_otp_authenticate!:35-37 only updates otp_last_login on success; failure leaves the account eligible for another guess.
Rack::Attack throttle in config/initializers/rack_attack.rb:7-13 applies only to /api/* with Bearer tokens. The /two_factor_auth/authenticate route is outside that throttle.
13Country-group searches misclassify partners and ignore restrictionsapp/services/compass/match_providers/jurisdiction_matching.rb:11P1

Matching and input contracts / Runtime probe plus source trace. Independently rechecked.

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 direction

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.

Source evidence / confidence 75
app/services/compass/match_providers/jurisdiction_matching.rb:11: match = jurisdictions.find { |j| j.country == lookup.client_country }
app/services/compass/match_providers/entity_matching.rb:28: r.entity_type == entity_type && r.client_country == client_country
app/models/compass/lookup.rb:59 and 80-82: VALID_COUNTRY_GROUP_KEYS admits group_*; client_countries already expands them.
app/views/admins/compass/lookups/new.html.haml:78: = f.input :client_country, as: :grouped_country_select,; app/inputs/grouped_country_select_input.rb offers group_eea and five other groups.
14Account row controls have no namesapp/assets/javascripts/stimulus/pages/admins/compass/account_requirements_controller.js:163P2

Usability and accessibility / Source and call-chain review.

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 direction

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.

Source evidence / confidence 100
app/assets/javascripts/stimulus/pages/admins/compass/account_requirements_controller.js:163-165 -- <button type="button" class="btn btn-sm btn-outline-danger" ...><i class="bi bi-trash"></i></button>
app/assets/javascripts/stimulus/pages/admins/compass/account_requirements_controller.js:172-173 -- <select class="form-select form-select-sm req-currency" data-action=...>
15Failed document uploads return silently to the capability tabapp/assets/javascripts/stimulus/pages/admins/compass/upload_polling_controller.js:47P2

Import integrity / Source and call-chain review.

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 direction

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.

Source evidence / confidence 100
app/assets/javascripts/stimulus/pages/admins/compass/upload_polling_controller.js:47: window.location.href = this.redirectUrlValue;
UploadPollingController:36-37 passes only data.status to handleStatus, discarding error_message from the response.
UploadsController#status:55-58 includes error_message for failed uploads.
UploadsController#show:44-47 is the path that sets an error flash; processing.html.haml configures redirectUrlValue as the capability tab via redirect_path_for, not this show path.
16Malformed account requirements fail open or crashapp/controllers/admins/compass/lookups_controller.rb:139P2

Matching and input contracts / Source and call-chain review. Independently rechecked.

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 direction

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.

Source evidence / confidence 100
app/controllers/admins/compass/lookups_controller.rb:139: []
app/controllers/admins/compass/lookups_controller.rb:136-139: JSON.parse(raw) rescues JSON::ParserError and substitutes an empty list.
app/models/compass/lookup.rb:131: return if account_requirements.blank?
app/models/compass/lookup.rb:202: currency = (req['currency'] || req[:currency]).to_s
17Validate currency elements before producing lookup matchesapp/models/compass/lookup.rb:36P2

Matching and input contracts / Source and call-chain review.

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 direction

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.

Source evidence / confidence 100
app/models/compass/lookup.rb:36: validates :sell_currencies, presence: true
app/models/compass/lookup.rb:37: validates :buy_currencies, presence: true
app/services/compass/match_providers.rb:65: lookup.sell_currencies.compact_blank.product(lookup.buy_currencies.compact_blank)
app/services/compass/match_providers.rb:136: statuses.size == 1 ? statuses.first : :amber
18Missing client country raises a database errorapp/models/compass/lookup.rb:89P2

Matching and input contracts / Source and call-chain review.

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 direction

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.

Source evidence / confidence 100
app/models/compass/lookup.rb:89: validate_country_or_group(:client_country, client_country) if client_country.present?
db/schema.rb:280: t.string "client_country", null: false
app/controllers/api/v1/lookups_controller.rb:19: rescue ArgumentError => e
spec/models/compass/lookup_spec.rb:49: it 'allows nil client_country' do
19Restricted currency pairs incorrectly receive direct prospect rankingapp/services/compass/match_prospects/corridor_evidence.rb:17P2

Matching and input contracts / Runtime probe plus source trace. Independently rechecked.

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 direction

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.

Source evidence / confidence 100
app/services/compass/match_prospects/corridor_evidence.rb:17: tier = sell.any? && buy.any? ? TIER_DIRECT : TIER_PARTIAL
app/services/compass/match_prospects/corridor_evidence.rb:84: findings.limit("#{currency} buy recorded from #{allowed.join(', ')} only")
app/services/compass/match_providers.rb:112-113 already rejects a buy_match whose allowed_sell_currencies excludes the requested sell.
prd-rolodex.md:840: Tier 1 direct requires the requested sell→buy pair fully recorded supported.
20Selecting no industry produces a false data-gap indicatorapp/services/compass/match_providers/industry_matching.rb:27P2

Matching and input contracts / Runtime probe plus source trace.

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 direction

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.

Source evidence / confidence 100
app/services/compass/match_providers/industry_matching.rb:27: lookup.high_risk_industry.nil?
app/views/admins/compass/lookups/new.html.haml:93: include_blank: 'None',
app/models/compass/lookup.rb:51-53 permits blank high_risk_industry without normalizing it.
app/services/compass/match_prospects/restriction_evidence.rb uses high_risk_industry.blank? instead.
21Gap unlocks confuse one dimension with one requirementapp/services/compass/unserved_analysis.rb:107P2

Analytics and scale / Runtime probe plus source trace.

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 direction

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.

Source evidence / confidence 100
app/services/compass/unserved_analysis.rb:107: closest = sets.select { |set| set[:dimensions].one? }
app/services/compass/unserved_analysis.rb:108: unlocks = closest.flat_map { |set| set[:reasons] }.uniq.sort
app/services/compass/unserved_analysis.rb:29: when :unlock then 'Unlocked by any one of'
app/services/compass/unserved_analysis.rb:129-130 collapses any number of failed account requirements into :accounts.
22Jurisdiction gaps name the sending country instead of clientapp/services/compass/unserved_analysis.rb:154P2

Analytics and scale / Runtime probe plus source trace.

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 direction

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.

Source evidence / confidence 100
app/services/compass/unserved_analysis.rb:154: when :jurisdiction then "Jurisdiction: #{lookup.send_from_country} not accepted"
app/services/compass/match_providers/jurisdiction_matching.rb:11 actually evaluates lookup.client_country.
app/services/compass/unserved_analysis.rb:57 and 85-88 derive grouping and stable gap key from these labels.
Read-only Ruby service probe: client_country DE and send_from_country GB => Jurisdiction: GB not accepted.
23Analytics filters and lookup account rows overflow phone screensapp/views/admins/compass/analytics/index.html.haml:18P2

Usability and accessibility / Browser measured: Analytics 573px and Trade Details 415px wide at a 390px viewport.

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 direction

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.

Source evidence / confidence 100
app/views/admins/compass/analytics/index.html.haml:18 -- = form_tag admin_compass_analytics_path, method: :get, class: 'd-flex align-items-end gap-3' do
app/views/admins/compass/lookups/new.html.haml:129 -- %table.table.table-sm.table-bordered.mb-2
Playwright current-checkout reproduction: Analytics viewport=390, document.scrollWidth=573; lookup Trade Details viewport=390, document.scrollWidth=415. See analytics-mobile.png and lookup-step2-mobile.png.
24Analytics lookup links fail for colleagues and API searchesapp/views/admins/compass/analytics/unserved.html.haml:36P2

Analytics and scale / Source and call-chain review. Independently rechecked.

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 direction

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.

Source evidence / confidence 100
app/views/admins/compass/analytics/unserved.html.haml:36: = link_to 'View lookup', admin_compass_lookup_path(lookup),
app/controllers/admins/compass/lookups_controller.rb:92: ::Compass::Lookup.existing.where(admin: current_admin).find(params[:id])
app/controllers/admins/compass/analytics_controller.rb:51 loads ::Compass::Lookup.existing.where(created_at: date_range) without owner scoping.
25Autocomplete omits combobox semanticsapp/views/admins/compass/conversations/_form.html.haml:15P2

Usability and accessibility / Source and call-chain review.

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 direction

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.

Source evidence / confidence 100
app/views/admins/compass/conversations/_form.html.haml:15-16 -- = f.text_field :participants, class: 'form-control', maxlength: 255, autocomplete: 'off', data: participants_input_data
app/views/admins/compass/shared/_participant_suggestions.html.haml:8-15 -- the dropdown and buttons define Stimulus targets/actions but no combobox, listbox, option, or ARIA state attributes.
26Upload selection count becomes falseapp/views/admins/compass/uploads/show.html.haml:105P2

Import integrity / Source and call-chain review.

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 direction

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.

Source evidence / confidence 100
app/views/admins/compass/uploads/show.html.haml:105-107 -- %span.text-muted.small; = valid_count; rows will be imported
app/assets/javascripts/stimulus/pages/admins/compass/upload_preview_controller.js:4-10 -- toggleSelectAll() changes checkboxes and opacity but no counter.
app/views/admins/compass/uploads/_preview_row.html.haml:6-9 -- only valid rows render rowCheckbox targets.
27Document failures never reach the configured retry policyapp/workers/compass/process_document_upload_worker.rb:23P2

Import integrity / Source and call-chain review.

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 direction

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.

Source evidence / confidence 100
app/workers/compass/process_document_upload_worker.rb:23: rescue StandardError => e
ProcessDocumentUploadWorker:5 declares retry:2.
ExtractFromDocument#perform:99-101 rescues all StandardError and returns false; worker lines 19-21 turn that into terminal failed status without raising.
Worker lines 23-25 catch remaining processing exceptions, persist failed and notify, then return normally.
28Duplicate requirements fail without explanationapp/assets/javascripts/stimulus/pages/admins/compass/account_requirements_controller.js:103P2

Usability and accessibility / Source and call-chain review.

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 direction

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.

Source evidence / confidence 75
app/assets/javascripts/stimulus/pages/admins/compass/account_requirements_controller.js:103-108 -- row.classList.remove('table-danger'); if (seen[key]) { row.classList.add('table-danger'); seen[key].classList.add('table-danger'); hasDuplicates = true; }
app/controllers/admins/compass/lookups_controller.rb:26-29 -- invalid records render with only `Please correct the errors below.`
app/views/admins/compass/lookups/new.html.haml:147-148 -- account requirements are represented by a hand-written hidden input.
29Polling can navigate after leaving pageapp/assets/javascripts/stimulus/pages/admins/compass/extraction_polling_controller.js:62P2

Usability and accessibility / Source and call-chain review.

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 direction

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.

Source evidence / confidence 75
app/assets/javascripts/stimulus/pages/admins/compass/extraction_polling_controller.js:62-72 -- fetch(this.urlValue, ...).then(... self.handleStatus(data)).catch(function() {});
app/assets/javascripts/stimulus/pages/admins/compass/extraction_polling_controller.js:22-25 -- disconnect clears intervals only; it does not cancel an active fetch.
app/assets/javascripts/stimulus/pages/admins/compass/extraction_polling_controller.js:81-83 -- terminal callbacks assign window.location or reload.
30Accepted legacy XLS files are parsed as XLSXapp/controllers/admins/compass/uploads_controller.rb:18P2

Import integrity / Source and call-chain review.

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 direction

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.

Source evidence / confidence 75
app/controllers/admins/compass/uploads_controller.rb:18: '.xls' => 'xlsx',
UploadsController CONTENT_TYPE_MAP:9 maps application/vnd.ms-excel to xlsx, and EXTENSION_MAP:18 maps .xls to xlsx.
Upload file validation allows application/vnd.ms-excel.
ParseUpload#parse_xlsx:64-65 always creates a .xlsx tempfile and uses Roo::Excelx.new; there is no legacy XLS parser or conversion path in the repository.
31CLI extraction retains an extra transcript copyapp/services/compass/claude_subscription_client.rb:56P2

Authentication and privacy / Source and call-chain review.

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 direction

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.

Source evidence / confidence 75
app/services/compass/claude_subscription_client.rb:56: cli_binary, '-p', '--model', @model, '--output-format', 'json',
ClaudeSubscriptionClient#combined_prompt:44-49 combines the entire system prompt, transcript and clarification text; #run supplies it through stdin_data at :57.
The installed claude --help explicitly documents --no-session-persistence as the print-mode option that prevents sessions from being saved to disk and resumed. The launcher omits it.
TranscriptDraftStore:7 sets TTL=24.hours and #delete clears only Rails cache keys; there is no CLI-session cleanup path.
32URL extraction downloads unlimited response bodiesapp/services/compass/extract_from_document.rb:133P2

Import integrity / Source and call-chain review.

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 direction

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.

Source evidence / confidence 75
app/services/compass/extract_from_document.rb:133: response = http.request(Net::HTTP::Get.new(uri))
Upload validates a 15 MB maximum only when file.attached? (app/models/compass/upload.rb:45-59); URL uploads are validated only for presence and URL string length.
ExtractFromDocument#fetch_url:130-136 sets open/read timeout, then buffers response.body and parses it; no Content-Length guard, streaming byte limit or total deadline exists.
Any admin allowed to add a prospect URL can trigger this path via UploadsController#create and ProcessDocumentUploadWorker; an ordinary oversized provider page can trigger the same failure without malicious intent.
33Every rolodex page recomputes all recent gap matchingapp/services/compass/gap_prospects.rb:40P2

Analytics and scale / Source and call-chain review.

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 + 24×lookup_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 direction

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.

Source evidence / confidence 75
app/services/compass/gap_prospects.rb:40: MatchProspects.new(lookup).perform.each_with_index do |result, rank|
app/controllers/admins/compass/prospects_controller.rb:17: @gap_coverage = gap_coverage
app/services/compass/gap_coverage.rb:43: GapProspects.new(gap.lookups).perform.each do |suggestion|
spec/services/compass/gap_coverage_spec.rb query-bound examples explicitly assert base_cost=1, per_lookup_cost=24 and linear growth in zero-match lookups.
34Breadcrumb landmark name is misspelledapp/helpers/admins/layout_helper.rb:4P3

Usability and accessibility / Source and call-chain review.

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 direction

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.

Source evidence / confidence 100
app/helpers/admins/layout_helper.rb:4 -- tag.nav('aria-lable': 'breadcrumb') do
35Sign-in document has no languageapp/views/layouts/unauthenticated.html.haml:2P3

Usability and accessibility / Source and call-chain review.

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 direction

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

Acceptance check

Assert the unauthenticated document declares English.

Source evidence / confidence 100
app/views/layouts/unauthenticated.html.haml:2 -- %html
app/views/layouts/admin.html.haml:2 -- %html{ lang: :en }
04 / UI evidence

Observed in this checkout.

Desktop and phone captures use synthetic data. Open an image for its full resolution. The account requirement reset was also measured directly: Named Required became Any after Back / Next.

05 / Improvements

Where the app can become more useful.

These are grounded product and architecture proposals, separate from the defect count. Current intentional behavior and future scope are stated explicitly.

O01Separate "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.

Success check

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

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

O02Make saved lookups explain when they were evaluatedHigh 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.

Success check

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

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

O03Give the lookup wizard a persistent brief summaryHigh 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.

Success check

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

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

O04Treat import review as a clear decision surfaceHigh 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.

Success check

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

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

O05Repair contrast while preserving the visual identityDesign 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.

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.

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

W3C contrast guidance

O06Turn stale evidence into an actionable review queueHigh 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.

Success check

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

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

O07Use one result contract across matching consumersArchitecture

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.

Success check

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

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

O08Distinguish unavailable calculations from zero resultsHigh 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.

Success check

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

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

O09Make opportunity ranking answer the actual missing needProduct 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.

Success check

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

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

O10Connect research to a next action, without expanding V1 silentlyFuture 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.

Success check

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

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

O11Make everyday navigation and account settings easier to findQuick 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.

Success check

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

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

O12Align setup documentation and regression checks with this appEngineering 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.

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.

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

Further investigation

Secondary hardening.

These observations need bounded follow-up. They are not counted as confirmed exploits or outages.

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.

06 / Evidence & limits

A broad review, with explicit limits.

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 references: W3C keyboard guidance, combobox pattern, and contrast requirements.