Skip to content

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

moment publish
check-in
event attend
↓ Record
PassportServiceuser_travel_logs (append-only)
↓ subscriber
↓ deltas
game engine
user_profilesdenorm stats
↓ writes earned rows
earned ledger
user_stamp_progress
challenge_progress
user_curios_earned+ curio_earn_events
first_discoveries
Every proven visit flows through Record. The engine and profile-stats read it; the engine writes the earned ledger back.

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:

  • Source must be a valid TravelLogSource; Country is required (the minimum geographic anchor). Invalid input returns ErrInvalidTravelLogSource / 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.
  • Continent is 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_profiles after, 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: ClaimIfFirst records the first-ever visit to a location so the discovery bonus is awarded exactly once.
  • TravelLogSubscriber (declared in passport_service.go, implemented in gameengine): 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

  • Content - the catalog these earned rows point at
  • Game Engine - writes the earned ledger; fires on TravelLogRecorded
  • Moments - the first wired Record caller
  • Presence - check-ins write the log and fire UserActivated