A customer number people can say out loud

Every id in the schema is a uuid and stays one. What was wrong was
putting one in front of a person: RecordVisit named every new customer
'Visitor ' || left(id::text, 8), so the arrivals feed, the shop PC and
the mobile app all read "Visitor 3446ec35" - the string a shop assistant
reads to a colleague and types into a search box. label is a stored
column staff can overwrite and SearchVisitors matches on, so formatting
around it in a front end would have left the data wrong on three
surfaces.

Migration 012 adds a per-client visitors.number, taken from a counter on
clients with UPDATE ... RETURNING inside the visit transaction. Per
client rather than global: a global sequence would tell any customer who
signs up how many people the whole platform has ever seen, from their
own first visitor number. The backfill numbers existing rows by
first_seen_at and relabels only the eight-hex pattern the old statement
produced, so a human-typed name is never overwritten.

Three of the four things anyone addresses by URL already had a human
name and the API simply refused it - a site has a slug, a camera has the
id the engine knows it by. refs.go accepts either form anywhere an id is
taken; a uuid resolves with no lookup, so every URL a client already
stored keeps working.

- An ambiguous camera name resolves to nothing, never to a guess: two
  shops may each have an "Office1" and acting on the first row would
  edit the wrong shop's camera.
- 404 on a path, 400 on a query filter. /api/visits answered fine and it
  was the filter that was wrong.
- site and site_id are both accepted everywhere now. They differed per
  endpoint, and an unknown query parameter is silently ignored, so
  getting it the wrong way round returned the whole estate.
- The search matches V-13, which is what the product now shows.

Two bugs found by running it rather than testing it:

- 'Visitor ' || $2::text beside number = $2 makes Postgres deduce two
  types for one parameter and refuse the insert. It compiled and passed
  every in-memory test; the first real database rejected it, along with
  the existing face tests that share the path.
- The fallback avatar said "V1" for Visitor 13, Visitor 10 and Visitor
  15 alike, and read as the V-1 reference for a fourth person. It shows
  the number now. The prop is customerRef, not ref - React reserves
  that name and it would never have arrived.

Verified on the live database and through the running API: 13 hex labels
became Visitor 1-13 in first-seen order, two typed names left alone, and
the same customer reachable by uuid, V-13 and 13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
This commit is contained in:
2026-09-07 11:52:32 +05:30
parent 3f9fb33b24
commit 9182f70442
30 changed files with 1055 additions and 83 deletions

View File

@@ -1954,6 +1954,105 @@ Verified live 2026-08-31, whole chain: enrol → upload-url → PUT → anonymou
JPEG downloaded → erase → presigned GET **404**, 0 templates, 0 profiles, label
`Erased`, visit row kept.
## Identifiers: a customer number people can say (migration 012)
Every id in the schema is a uuid and stays one. What was wrong was putting one
in front of a person. `RecordVisit` named every new customer from theirs:
```sql
UPDATE visitors SET label = 'Visitor ' || left(id::text, 8)
```
So the name on the arrivals feed, on the shop PC, and in the mobile app was
**"Visitor 3446ec35"** — the string a shop assistant reads out to a colleague,
writes on a card, and types into a search box. Not a display problem to paper
over in a front end either: `label` is a stored column staff can overwrite and
`SearchVisitors` matches on, so it had to be fixed where it is written.
`visitors.number` is a **per-client** sequence and the label is now
`Visitor 42`, referenced as **`V-42`**. Three properties, each ruling out an
alternative:
- **Speakable.** The whole point.
- **Per client, not global.** A global sequence tells any customer who signs up
how many people the entire platform has ever seen, from their own first
visitor number. Per tenant it reveals a tenant's own count to that tenant's
own staff, who know it already.
- **Not the primary key.** Ids are minted where nothing can ask a database for
the next value, and eleven tables reference `visitors.id`. This is a public
*reference* beside the key, which is the part humans needed.
The counter is `clients.visitor_seq`, taken with `UPDATE ... RETURNING` inside
the visit transaction. That returns the value **after** the update — the same
semantics that silently broke the face prune in 011 by handing back what it had
just written, and here exactly what is wanted. It row-locks the client for the
length of the insert, which serialises new-visitor creation per tenant and costs
nothing: it runs only for a face nobody in the estate has ever seen.
The backfill numbers existing rows by `first_seen_at` and relabels **only** the
eight-lowercase-hex pattern the old statement produced, so a name a human typed
is never overwritten. Verified on the live database: 13 hex labels became
Visitor 1-13 in first-seen order, two "Walk-in test" names were left alone, and
`visitor_seq` landed on 15.
### Three of the four things already had a human name; the API refused it
That is the part worth keeping. Only visitors genuinely lacked a reference:
| thing | reference | since |
|---|---|---|
| shop | `slug` — "chennai" | 001 |
| camera | `camera_id` — "Office1", and what `visits.camera_id` holds | 005 |
| customer | `V-<number>` | 012 |
| person | email | 002 |
`refs.go` accepts either form anywhere an id is taken. A uuid resolves with no
lookup at all, so nothing that worked yesterday changes — including every URL a
client has already stored. Only a non-uuid costs a query.
- **A camera id is unique per SITE, not per tenant.** Two shops may each have an
`Office1`, so an ambiguous name resolves to **nothing** rather than to
whichever row sorted first — acting on a guess would edit the wrong shop's
camera.
- **404 on a path, 400 on a query filter.** `/api/visits` exists and answered;
what was wrong was the filter, and a 404 there reads as "the arrivals feed is
gone". A path segment names the resource itself, so an unknown one *is* a 404.
- **`site` and `site_id` are both accepted everywhere now.** Reports took one and
the arrivals feed the other, and an unknown query parameter is silently
ignored — so getting it the wrong way round returned the whole estate instead
of an error, which is a wrong number nobody would question.
- **The search matches the reference.** `V-13` is what the product now shows, so
it is what gets pasted into the search box, and `label ILIKE '%V-13%'` finds
nothing because the label says "Visitor 13". A search that comes back empty
for the identifier you were just shown is worse than no search.
The **edge** engine has always numbered its identities from a SQLite rowid, so
"Visitor 3" there and "Visitor 47" here are the same person under two numbers.
Left alone deliberately: making them agree means the shop PC asking the server
for a number, which cannot work offline — and the edge number appears only on
the engine's own diagnostic dashboard.
### Two bugs, one from a real database and one from a real browser
- **`'Visitor ' || $2::text` next to `number = $2`.** Postgres deduces two types
for one parameter and refuses the whole insert: *"inconsistent types deduced
for parameter $2"*. It compiled, it passed every in-memory test, and it failed
on the first real database — along with the existing face tests, which go
through the same path. The label is formatted in Go now.
- **The avatar said `V1` for three different people.** With no photograph the
arrivals feed draws initials, and `initials("Visitor 13")` takes the first
letter of each word — `V1`, which is also what "Visitor 10" and "Visitor 15"
produce, and which reads as the `V-1` reference for a fourth person. It shows
the number itself now. Found by opening the page: every test here passes a
human name. The prop carrying it is `customerRef`, not `ref` — React reserves
that name, so it would never have reached the component.
Fixture note: `embedding(seed)` fills every dimension with one value, so after
L2 normalisation 0.31 and 0.62 are the **same direction** and the matcher
correctly calls them one person. Tests that need several different people use
`distinctFace(i)`, which is orthogonal per index.
## Setting up on a new machine
1. Copy the `Behavision` folder **including `.env`** (gitignored, holds