Three uuids on one arrival, three different answers

Asked of the row the feed actually returns.

site_id had a reference all along and the feed was not sending it. A
client could read the shop's NAME off an arrival and still had no way to
ask for that shop except by uuid - the exact gap the reference scheme
exists to close. site_slug now travels with it.

visit_id stays a uuid and needs no reference: no route takes it, it is a
key a client de-duplicates on because delivery is at-least-once, and
nobody says a visit id out loud.

The uuid in a face URL must STAY random. visit_faces.id is
gen_random_uuid() and a derived or sequential one would let somebody
walk a shop's customers by date - the same reason bucket keys are random
rather than derived from the event id. A readable identifier is right
for a customer and wrong for the thing that points at their photograph.

And seq is now json:"-". visits.seq is a plain bigserial, so it counts
every visit on the PLATFORM, and shipping it put the total footfall of
every customer we have on every row of every tenant's feed - the same
German-tank estimate that decided visitors.number had to be per client.
It was a convenience for "have I fallen behind", nothing ever read it,
and the cursor answers that without disclosing a number. The SSE event
id was never the raw value; it has always been the opaque cursor.

The one test that broke was reading seq back off the wire to assert the
cursor pointed at the last row of a burst. It asserts against the seeded
position now: the property is unchanged, and the test can no longer see
what a client cannot.

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 12:12:48 +05:30
parent 9182f70442
commit 08873f4a67
6 changed files with 104 additions and 13 deletions

View File

@@ -86,9 +86,12 @@ func TestFourPeopleArrivingTogetherComeBackInOneRequest(t *testing.T) {
if err != nil {
t.Fatalf("cursor from a burst is unreadable: %v", err)
}
if seq != page.Arrivals[3].Seq {
// Against the SEEDED position, not one read back off the wire: `seq` is
// json:"-" because it counts every visit on the platform, so a client can
// no longer see it - and the cursor is the whole reason it does not need to.
if want := int64(4); seq != want {
t.Errorf("cursor should point at the LAST row of the burst, got %d want %d",
seq, page.Arrivals[3].Seq)
seq, want)
}
}

View File

@@ -2,6 +2,7 @@ package api
import (
"net/http"
"strings"
"testing"
)
@@ -108,3 +109,34 @@ func TestBothSiteParameterNamesAreAccepted(t *testing.T) {
}
}
}
// An arrival names its shop three ways, and the one a client can filter by must
// be among them. Reading "TeNext Chennai" off a row and then having no way to
// ask for that shop except by uuid is the exact gap the reference scheme
// exists to close.
func TestAnArrivalCarriesTheShopReferenceItCanBeFilteredBy(t *testing.T) {
srv, fs := newServer(t)
seedUser(fs)
fs.arrivals = []Arrival{{
VisitID: "c64dc53f-c7f0-4e61-a3b4-9a230f52b3a3", Seq: 265,
SiteID: "7c9bb456-e0c6-436d-ba01-5d6cc4e3f466",
Site: "TeNext Chennai", SiteSlug: "chennai",
}}
tok := login(t, srv, "manager@acme.com", "correct horse battery").Token
rec := do(t, srv, http.MethodGet, "/api/visits", tok, nil)
if rec.Code != http.StatusOK {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, `"site_slug":"chennai"`) {
t.Fatalf("an arrival must carry the shop's reference: %s", body)
}
// seq is a plain bigserial, so it counts every visit on the PLATFORM. It
// must not travel: that is the total footfall of every customer we have,
// on every row of every tenant's feed.
if strings.Contains(body, `"seq"`) {
t.Fatalf("the platform-wide visit counter leaked into the feed: %s", body)
}
}

View File

@@ -238,13 +238,26 @@ type AgentPrincipal struct {
type Arrival struct {
VisitID string `json:"visit_id"`
// Seq is this visit's position in the feed - assigned by the server when it
// learned of the visit, not by the camera. Exposed because a client that
// wants to know whether it has fallen behind can compare two of them; the
// cursor remains the supported way to page.
Seq int64 `json:"seq"`
OccurredAt string `json:"occurred_at"`
SiteID string `json:"site_id"`
Site string `json:"site"`
// learned of the visit, not by the camera. It drives the cursor and the
// ordering, and it is `json:"-"` on purpose.
//
// `visits.seq` is a plain bigserial, so it counts every visit on the
// PLATFORM, not this tenant's. Sending it put the total footfall of every
// customer we have on every row of every feed - the same German-tank
// estimate that decided `visitors.number` had to be per client, and a
// number no tenant should be able to read off another. It used to ship as
// a convenience for "have I fallen behind"; nothing ever read it, and the
// cursor - opaque and version-prefixed for exactly this reason - already
// answers that.
Seq int64 `json:"-"`
OccurredAt string `json:"occurred_at"`
SiteID string `json:"site_id"`
Site string `json:"site"`
// SiteSlug is the shop's reference - "chennai" - and is what `?site=`
// takes. Without it a client could read the shop's NAME off an arrival and
// still had no way to filter by that shop except the uuid, which is the
// gap the whole reference scheme exists to close.
SiteSlug string `json:"site_slug,omitempty"`
CameraID string `json:"camera_id"`
IsNew bool `json:"is_new_visitor"`
Similarity float64 `json:"similarity,omitempty"`