Passport¶
Purpose¶
The per-user earned and done state for gamification. The passport is where "this user has been here" and "this user has earned that" are recorded. It holds two things:
- The travel log: the append-only, immutable factual record of where every user has been. One row per visit. This is the substrate beneath passport counts, year-in-places, leaderboards, retroactive awards, and every geographic statistic on the user profile.
- The earned ledger: per-user progress and grants for curated stamps, challenges, curios, and first discoveries. The game engine writes these; the content catalog defines what they mean.
Passport is an internal domain: backend/internal/services/passport/. It
has no handler.go or routes.go. The travel log is written by producer
services (moment, presence check-in) and the earned ledger is written by
the game engine; reads flow through the user profile denorm stats and the
engine's rule leaves.
Visits are historical facts. Deleting the artifact that proved a visit (a moment, an event RSVP) does not unmake the visit. The travel log is the source of truth; everything else projects from it.
Mental model¶
Every write that proves "user was at location X at time T" calls
PassportService.Record. Record snapshots the geographic context,
folds denormalized deltas into the user profile, claims the
first-discovery slot, and fires the engine subscriber. Reads pull distinct
counts, enumerations, or presence checks.
Surface area¶
Wired in passport/wire.go. Constructors named New<Thing>.
| Component | Owns | Written by | Read by |
|---|---|---|---|
PassportService (passport_service.go) + TravelLogStore (travel_store.go) |
user_travel_logs |
moment, presence check-in | profile stats, engine leaves, replay |
UserStampProgressRepository (stamp_progress_store.go) |
user_stamp_progress |
engine stamp resolver | engine stamp_earned_count leaf |
ChallengeProgressRepository (challenge_progress_store.go) |
challenge_progress |
engine challenge resolver | engine candidate query + challenge_completed leaf |
CurioGrantStore (curio_grant_store.go) |
user_curios_earned, curio_earn_events |
engine (on reward-curio grant) | engine curio_earned_count leaf |
FirstDiscoveriesRepository (first_discoveries_store.go) |
first_discoveries |
PassportService.Record |
engine first_at_location leaf |
The curated-stamp passport row itself (user_stamps, kind =
tomoda_curated) is defined in models.UserStamp. It records the earned
curated stamp per (user, tomoda_stamp).
PassportService (travel log)¶
Record(entry RecordRequest) is the single write path. Contracts:
Sourcemust be a validTravelLogSource;Countryis required (the minimum geographic anchor). Invalid input returnsErrInvalidTravelLogSource/ErrMissingCountry.- Location-keyed writes dedup: a second visit to the same location inside
TravelLogDedupWindow(one hour) is skipped. Off-grid writes (LocationID == nil) always record. Continentis derived from the country when the caller omits it (geo.ContinentForCountryCode).- Denorm deltas are computed before the insert (so the just-inserted row
isn't double-counted) and applied to
user_profilesafter, best-effort. - After the row commits: claim the first-discovery slot (best-effort) and fire the engine subscriber (best-effort).
Reads: distinct counts (CountryCount, RegionCount, CityCount,
DistrictCount, ContinentCount, UniqueLocationCount), enumerations
(Countries, CitiesInCountry, DistrictsInCity), presence checks
(HasVisited, VisitCount, FirstVisitedAt, LastVisitedAt), scoped
aggregations (DistinctLocationsInScope, DistinctCitiesInScope), and
the bulk GetForUser.
Source taxonomy¶
Every Record call passes one TravelLogSource (models.TravelLogSource*
in backend/internal/models/travel_log.go):
| Source | When it fires |
|---|---|
moment |
User published a moment at this location |
tagged_moment |
User was tagged on someone else's moment at this location |
event |
User attended an event at this location |
checkin |
User published a check-in at this location |
tagged_checkin |
User was tagged as a companion on someone else's check-in |
other |
Catch-all so new write paths can land without churning the enum |
Cross-domain interfaces¶
passport/deps.go declares the slices of other domains the recorder
needs, so passport imports no heavier dependency:
ProfileDeltas(implemented by the user profile service):EnsureProfile,ComputeVisitDeltas,ApplyVisitDeltas. Folds a visit into the owner's denormalized profile stats.FirstDiscoveryClaimer:ClaimIfFirstrecords the first-ever visit to a location so the discovery bonus is awarded exactly once.TravelLogSubscriber(declared inpassport_service.go, implemented ingameengine):TravelLogRecorded(travelLogID)is the post-write hook. Declared here so passport stays free of engine imports.
Data model¶
user_travel_logs¶
models.UserTravelLog. Append-only; id is BIGSERIAL (monotonic order
is useful; no delete or update path exists).
| Column | Notes |
|---|---|
user_id, visited_at |
Owner + original visit timestamp (chronology truth, not write time) |
location_id (nullable) |
Set when the visit resolved to a location |
moment_id (nullable) |
Forensic pointer; may dangle after a moment delete |
source |
See source taxonomy |
district / city / region / country / continent |
Snapshotted from the location at write time; country + continent are NOT NULL |
local_time (nullable) |
Device-local time at the visited location, derived from visited_at + coords. Powers the engine's time-of-day rule filter |
Indexes (declared in the goose migration SQL): idx_travel_user_country
(user_id, country), idx_travel_user_time (user_id, visited_at DESC),
idx_travel_user_location (user_id, location_id), plus a single index
on moment_id.
Why denormalize geography? Joining locations on every passport read
is expensive at scale. Snapshotting makes the read a single indexed scan.
The trade-off is a one-shot correction script when a location's city or
country changes, which is rare.
Why one row per visit? Preserves chronology. The same cafe visited ten
times is ten rows, powering "most-visited spot", "1 year ago today", and
frequent-visitor rules. Uniqueness ("distinct cities") uses
COUNT(DISTINCT).
user_stamp_progress¶
models.UserStampProgress, per-(user, stamp). matched_ids (JSONB)
accumulates the stamp_guide_entry IDs the user has satisfied;
current_count is the denormalized length so completion checks don't walk
the JSONB. earned_at is set the first time current_count crosses the
stamp's minimum_visits; awarded_at is set when downstream consumers
acknowledge the earn (the gap is for crash-safe replay). Re-visiting a
place that satisfies an already-matched entry is a no-op.
challenge_progress¶
models.ChallengeProgress, per-(scope_kind, scope_id, challenge_id).
Lazy: created on the first matching action, not on challenge publish.
state is in_progress or completed; completed_at persists forever
as audit. leaf_state (JSONB, keyed by rule-tree leaf path) carries
per-leaf state for stateful leaves and sequence-operator anchors.
user_curios_earned + curio_earn_events¶
The dedup row and the audit trail. Defined in models.Curio's file;
written here by CurioGrantStore.Grant, which inserts the dedup row
(ON CONFLICT DO NOTHING) plus an audit row in the same transaction.
Grant returns whether this was the first grant for the pair. Source
(CurioEarnSource) is rule, challenge, or admin_grant.
first_discoveries¶
models.FirstDiscovery, keyed by location_id. ClaimIfFirst uses
INSERT … ON CONFLICT DO NOTHING so concurrent first-arrival races
resolve atomically (only the earliest visitor's row survives). Powers the
first_at_location rule leaf.
Notable behavior¶
Immutable means immutable
No write path may DELETE or UPDATE a user_travel_logs row. There is
no Delete method and no FK cascade. When a moment is hard-deleted,
the log row stays with moment_id becoming a dangling pointer, which
the read path never joins.
Off-grid moments don't write to the log
A moment with LocationID = NULL has no anchor to count, so it emits
no visit row.
The earned ledger is engine-owned
Passport owns the tables and the query methods, but only the game
engine writes stamp / challenge / curio progress.
PassportService.Record is the one exception that self-writes
(first_discoveries and the profile deltas) as part of the visit.
Where to look¶
backend/internal/services/passport/passport_service.go,travel_store.go,deps.go,wire.gobackend/internal/services/passport/stamp_progress_store.go,challenge_progress_store.go,curio_grant_store.go,first_discoveries_store.gobackend/internal/models/travel_log.go,user_stamp.go,user_stamp_progress.go,challenge.go(ChallengeProgress),curio.go(UserCurioEarned/CurioEarnEvent),first_discoveries.go
Cross-links¶
- Content - the catalog these earned rows point at
- Game Engine - writes the earned ledger; fires on
TravelLogRecorded - Moments - the first wired
Recordcaller - Presence - check-ins write the log and fire
UserActivated