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
| Level | Trust Score | How Achieved | Badge Color |
|---|---|---|---|
SELF_REPORTED | 1 | Default on publish | Gray |
IMAGE_RECOGNITION | 2 | Camera scoring (future ML) | Blue |
WITNESS_VERIFIED | 3 | Witness confirms via token | Green |
TOURNAMENT | 4 | Official tournament score | Gold |
Data Models
VerificationRequest
Tracks pending verification requests with shareable tokens.
Firestore Collection: verificationRequests/{id}
| Field | Type | Description |
|---|---|---|
id | string | Firestore document ID |
token | string | 12-char shareable token |
requesterId | string | Firebase UID of score owner |
requesterDisplayName | string | Display name for witness UI |
leaderboardEntryId | string | Reference to leaderboard entry |
publishedRoundId | string | Reference to published round |
score | int | Denormalized score value |
maxPossibleScore | int | Max possible for condition |
conditionKey | string | Distance/target/scoring key |
scoredAt | long | When score was recorded |
status | enum | PENDING, COMPLETED, EXPIRED, CANCELLED |
expiresAt | long | Token expiry (7 days) |
createdAt | long | Creation timestamp |
updatedAt | long | Last update timestamp |
VerificationProof
Immutable evidence of witness verification.
Firestore Collection: verificationProofs/{id}
| Field | Type | Description |
|---|---|---|
id | string | Firestore document ID |
verificationRequestId | string | Reference to request |
leaderboardEntryId | string | Reference to leaderboard entry |
witnessUserId | string | Firebase UID of witness |
witnessDisplayName | string | Witness display name |
imageUrls | List | Firebase Storage URLs |
notes | string | Optional witness notes |
verificationLevel | enum | Level achieved (WITNESS_VERIFIED) |
verifiedAt | long | Verification timestamp |
createdAt | long | Creation 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
| Property | Value | Rationale |
|---|---|---|
| Length | 12 characters | Balance of security and usability |
| Character set | A-Z (excl. O,I,L) + 2-9 | URL-safe, no ambiguous chars |
| Entropy | ~4.7 × 10^18 combinations | Sufficient for non-critical tokens |
| Expiry | 7 days | Limits exposure window |
| Brute force protection | Rate limiting in Firestore | Prevents enumeration |
URL Formats
| Format | Example |
|---|---|
| Deep link | archeryapprentice://verify/ABC23XYZ789K |
| Web link | https://archeryapprentice.com/verify/ABC23XYZ789K |
Deep Link Handling
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
| Component | Description |
|---|---|
RequestVerificationDialog | Generate and share verification link |
VerifyScoreDialog | Witness verification UI with photo capture |
VerificationBadge | Icon + label for verification level |
VerificationDeepLinkScreen | Handle incoming deep links |
iOS
| Component | Description |
|---|---|
VerificationRequestView | Generate verification link |
VerifyScoreView | Witness confirmation UI |
VerificationBadgeView | Verification level indicator |
Error Handling
| Error | Cause | User Message |
|---|---|---|
TOKEN_INVALID | Malformed token | ”Invalid verification link” |
TOKEN_EXPIRED | Past expiresAt | ”This verification link has expired” |
REQUEST_NOT_FOUND | Token doesn’t exist | ”Verification request not found” |
ALREADY_COMPLETED | Status != PENDING | ”This score has already been verified” |
SELF_VERIFICATION | Witness == Requester | ”You cannot verify your own score” |
IMAGE_UPLOAD_FAILED | Storage 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
- ML Image Recognition: Automatic score verification from target photos
- Video Evidence: Support video capture for additional proof
- Witness Reputation: Track witness reliability score
- QR Code Generation: In-app QR code display for easy sharing
- Push Notifications: Notify when verification is completed
Related Documentation
- 2025-12-29-phase3-verification-system - Session notes
- arrows-leaderboard-feature - Arrows leaderboard
- firebase-security-rules - Security rules reference