Finding a person or company
listing answers a bounded, searched, paged set of records from the database rather than from a slice held in memory. The count is one aggregate, the filter and the page are SQL, and a % or _ typed into the box is escaped and matched as a character. Results are bounded on purpose — a page you can read beats a list nobody scrolls.
What a search actually reads#
The searchable material is what identifies a record to a colleague: the display name, the company it belongs to, and the addresses that resolve to it. Looking somebody up by an address they wrote from is the common case, and it is the same resolution by_handle performs when a message arrives — one indexed lookup rather than a scan.
Message bodies are not what this box searches. Finding a conversation by something said in it is the Conversations screen's job, and the Assistant's find_anything is the tool for a question that crosses records.
The defect this design came from#
The conversation list once read 400 rows and did the counting, filtering, searching and paging in Python over that slice. On a workspace with 430 conversations whose 25 closed ones were the oldest, the results were not merely slow — they were wrong:
| What you did | What you got | Why |
|---|---|---|
| Opened the Closed tab | Nothing at all | The 25 closed rows were the oldest, and the 400-row slice never reached them |
| Read the total | 400 | The total counted the slice rather than the workspace |
| Asked for page five | An empty page | Paging ran over the slice, which had already ended |
Counts are one GROUP BY now; the filter and the page are SQL. The lesson generalises past this one screen: a limit applied before the question is answered produces a confident wrong answer, and confident wrong answers are harder to notice than slow ones.
Wildcards you did not mean to type#
% and _ are wildcards in a SQL LIKE. Somebody searching for an account code containing an underscore, or a discount note containing a per cent sign, was matching far more than they asked for — an underscore quietly meant *any single character*.
typed: ACME_01
matched: ACME_01, ACME-01, ACME 01, ACMEX01 (before)
matched: ACME_01 (now)Both characters are escaped before the query runs and matched literally. This is a small correctness fix with a large trust consequence: a search that returns extra rows teaches people not to believe the result, and a search nobody believes gets replaced by scrolling.
Why results are bounded#
- A page is a page.
listingreturns a window with a total, so the count is honest even though the rows on screen are not all of them. - Duplicate proposals are capped at a readable number — a queue of pairs is for reading, not for bulk acceptance.
- A person's history is bounded at 200 entries by default, newest first, which is where the interesting part of a relationship is.
- Cost must not track the page size. The measured pattern behind that is on the timeline: per-row follow-up queries turned 40 rows into 133 statements, and the set-based rewrite made it 15, flat.
Questions#
Why does a search for an underscore no longer return extra rows?
Because % and _ are escaped before the query runs. They used to reach the database as wildcards, so an account code with an underscore matched anything with any character in that position.
Can I search by phone number?
Yes — the addresses that resolve to a record are searchable, which includes phone identities. The same normalisation used for resolution applies, so spacing does not decide whether you find somebody.
Why is the total sometimes larger than the rows I can see?
Because the total is a real count of what matches and the rows are one page of it. That is the fix for a screen which used to count only the slice it had already loaded, and then disagreed with itself.