Verification System Architecture

The verification system allows users to have their published scores verified by witnesses, upgrading their trust level from SELF_REPORTED to WITNESS_VERIFIED. Implemented in PR #411 as part of Phase 3.

Overview

┌─────────────────────────────────────────────────────────────┐
│                   Score Owner                               │
│  1. Publishes score (SELF_REPORTED)                        │
│  2. Requests verification → generates token                 │
│  3. Shares link/QR code                                     │
└─────────────────────────────┬───────────────────────────────┘
                              │
                      [Token: ABC23XYZ789K]
                              │
┌─────────────────────────────▼───────────────────────────────┐
│                       Witness                               │
│  1. Opens deep link / scans QR                              │
│  2. Reviews score details                                   │
│  3. Confirms verification (+ optional photo)                │
│  4. Proof created, entry upgraded to WITNESS_VERIFIED       │
└─────────────────────────────────────────────────────────────┘

Verification Levels

LevelTrust ScoreHow AchievedBadge Color
SELF_REPORTED1Default on publishGray
IMAGE_RECOGNITION2Camera scoring (future ML)Blue
WITNESS_VERIFIED3Witness confirms via tokenGreen
TOURNAMENT4Official tournament scoreGold

Data Models

VerificationRequest

Tracks pending verification requests with shareable tokens.

Firestore Collection: verificationRequests/{id}

FieldTypeDescription
idstringFirestore document ID
tokenstring12-char shareable token
requesterIdstringFirebase UID of score owner
requesterDisplayNamestringDisplay name for witness UI
leaderboardEntryIdstringReference to leaderboard entry
publishedRoundIdstringReference to published round
scoreintDenormalized score value
maxPossibleScoreintMax possible for condition
conditionKeystringDistance/target/scoring key
scoredAtlongWhen score was recorded
statusenumPENDING, COMPLETED, EXPIRED, CANCELLED
expiresAtlongToken expiry (7 days)
createdAtlongCreation timestamp
updatedAtlongLast update timestamp

VerificationProof

Immutable evidence of witness verification.

Firestore Collection: verificationProofs/{id}

FieldTypeDescription
idstringFirestore document ID
verificationRequestIdstringReference to request
leaderboardEntryIdstringReference to leaderboard entry
witnessUserIdstringFirebase UID of witness
witnessDisplayNamestringWitness display name
imageUrlsListFirebase Storage URLs
notesstringOptional witness notes
verificationLevelenumLevel achieved (WITNESS_VERIFIED)
verifiedAtlongVerification timestamp
createdAtlongCreation timestamp

Token Security

Generation

object VerificationTokenGenerator {
    private const val TOKEN_LENGTH = 12
    private val CHARS = (('A'..'Z') + ('2'..'9'))
        .filterNot { it in listOf('O', 'I', 'L') }  // Exclude ambiguous
 
    fun generate(): String
}

Security Properties

PropertyValueRationale
Length12 charactersBalance of security and usability
Character setA-Z (excl. O,I,L) + 2-9URL-safe, no ambiguous chars
Entropy~4.7 × 10^18 combinationsSufficient for non-critical tokens
Expiry7 daysLimits exposure window
Brute force protectionRate limiting in FirestorePrevents enumeration

URL Formats

FormatExample
Deep linkarcheryapprentice://verify/ABC23XYZ789K
Web linkhttps://archeryapprentice.com/verify/ABC23XYZ789K

Android

AndroidManifest.xml:

<intent-filter android:autoVerify="true">
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="archeryapprentice" android:host="verify" />
    <data android:scheme="https" android:host="archeryapprentice.com"
          android:pathPrefix="/verify" />
</intent-filter>

Navigation Handling:

// In MainActivity
intent?.data?.let { uri ->
    val token = VerificationTokenGenerator.extractTokenFromUrl(uri.toString())
    if (token != null) {
        navController.navigate("verification/$token")
    }
}

iOS

Info.plist (URL Schemes):

<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>archeryapprentice</string>
        </array>
    </dict>
</array>

Universal Links (Associated Domains):

applinks:archeryapprentice.com

Firestore Security Rules

Verification Requests

match /verificationRequests/{requestId} {
  // READ: Any authenticated user (for token lookup)
  allow read: if request.auth != null;
 
  // CREATE: Only requester can create their own requests
  allow create: if request.auth != null &&
                   request.auth.uid == request.resource.data.requesterId;
 
  // UPDATE: Owner can cancel, witness can complete
  allow update: if request.auth != null &&
    ((request.auth.uid == resource.data.requesterId &&
      request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status', 'updatedAt']) &&
      request.resource.data.status == 'CANCELLED') ||
     (request.auth.uid != resource.data.requesterId &&
      request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status', 'updatedAt']) &&
      request.resource.data.status == 'COMPLETED'));
 
  // DELETE: Never allowed (audit trail)
  allow delete: if false;
}

Verification Proofs

match /verificationProofs/{proofId} {
  // READ: Any authenticated user
  allow read: if request.auth != null;
 
  // CREATE: Witness only (cannot be score owner - enforced in app)
  allow create: if request.auth != null &&
                   request.auth.uid == request.resource.data.witnessUserId;
 
  // UPDATE/DELETE: Never allowed (immutable evidence)
  allow update, delete: if false;
}

Storage Rules (Verification Images)

match /verification_images/{userId}/{imageId} {
  // WRITE: Only witness can upload their own images
  allow write: if request.auth != null &&
                  request.auth.uid == userId &&
                  request.resource.size < 10 * 1024 * 1024 &&  // 10MB max
                  request.resource.contentType.matches('image/.*');
 
  // READ: Any authenticated user (for display)
  allow read: if request.auth != null;
}

Service Layer

VerificationService (Android)

class VerificationService(
    private val dataSource: FirebaseVerificationDataSource,
    private val leaderboardDataSource: FirebaseLeaderboardDataSource
) {
    // Create verification request
    suspend fun createVerificationRequest(
        leaderboardEntryId: String,
        publishedRoundId: String
    ): Result<VerificationRequest>
 
    // Look up request by token
    suspend fun getRequestByToken(token: String): Result<VerificationRequest?>
 
    // Submit verification proof
    suspend fun submitVerification(
        requestId: String,
        imageUri: Uri?,
        notes: String
    ): Result<VerificationProof>
 
    // Cancel request (owner only)
    suspend fun cancelRequest(requestId: String): Result<Unit>
 
    // Get user's pending requests
    suspend fun getUserRequests(userId: String): Result<List<VerificationRequest>>
}

Verification Flow

1. createVerificationRequest()
   → Generate token
   → Create request document
   → Return shareable link

2. getRequestByToken()
   → Query by token field
   → Validate not expired
   → Return request for witness UI

3. submitVerification()
   → Validate witness != requester
   → Upload image to Storage (optional)
   → Create proof document
   → Update request status to COMPLETED
   → Update leaderboard entry verificationLevel
   (Atomic batch write)

UI Components

Android

ComponentDescription
RequestVerificationDialogGenerate and share verification link
VerifyScoreDialogWitness verification UI with photo capture
VerificationBadgeIcon + label for verification level
VerificationDeepLinkScreenHandle incoming deep links

iOS

ComponentDescription
VerificationRequestViewGenerate verification link
VerifyScoreViewWitness confirmation UI
VerificationBadgeViewVerification level indicator

Error Handling

ErrorCauseUser Message
TOKEN_INVALIDMalformed token”Invalid verification link”
TOKEN_EXPIREDPast expiresAt”This verification link has expired”
REQUEST_NOT_FOUNDToken doesn’t exist”Verification request not found”
ALREADY_COMPLETEDStatus != PENDING”This score has already been verified”
SELF_VERIFICATIONWitness == Requester”You cannot verify your own score”
IMAGE_UPLOAD_FAILEDStorage error”Failed to upload image”

Firestore Indexes

{
  "indexes": [
    {
      "collectionGroup": "verificationRequests",
      "fields": [
        { "fieldPath": "token", "order": "ASCENDING" }
      ]
    },
    {
      "collectionGroup": "verificationRequests",
      "fields": [
        { "fieldPath": "requesterId", "order": "ASCENDING" },
        { "fieldPath": "createdAt", "order": "DESCENDING" }
      ]
    },
    {
      "collectionGroup": "verificationRequests",
      "fields": [
        { "fieldPath": "status", "order": "ASCENDING" },
        { "fieldPath": "expiresAt", "order": "ASCENDING" }
      ]
    }
  ]
}

Future Enhancements

  1. ML Image Recognition: Automatic score verification from target photos
  2. Video Evidence: Support video capture for additional proof
  3. Witness Reputation: Track witness reliability score
  4. QR Code Generation: In-app QR code display for easy sharing
  5. Push Notifications: Notify when verification is completed