# Quest Protocol

**Specification v0.7 — Draft**

February 2025 · protocol.quest

---

## Table of Contents

- [Terminology](#s1)
- [Credential Format](#s2)
- [Claim Definitions](#s3)
- [Key Discovery](#s4)
- [Issuer Manifest](#s5)
- [Issuance Flow](#s6)
- [Verification Algorithm](#s7)
- [Cross-App Interoperability](#s8)
- [Attestation Levels](#s9)
- [Credential Binding Tiers](#s10)
- [Challenge Methods](#s11)
- [Credential Portability](#s12)
- [Identity Model](#s13)
- [Coordinate Precision](#s14)
- [Security Considerations](#s15)
- [Reference Implementation](#s16)
- [IANA Considerations](#s17)
- [Extension: Quest Chains *(optional)*](#s18)
- [Quest Object Definition](#s19)

---

Quest Protocol defines an open format for **verifiable proof-of-presence credentials**. A Quest credential is a signed JWT attesting that a subject was physically present at a specified geographic location at a given time. Credentials are issued by any conforming server, carried by the subject, and verified by any party using the issuer's published public key. No centralized registry or proprietary infrastructure is required.

## Terminology

**Issuer**: A server that validates presence and signs Quest credentials. Identified by its HTTPS origin.

**Subject**: The individual whose physical presence is being attested.

**Credential**: A signed JWT containing proof-of-presence claims conforming to this specification.

**Verifier**: Any party that validates a credential's authenticity and claims.

**Quest**: A defined geographic experience consisting of one or more markers, each representing a location to be visited. A quest carries its own identity, visual branding, and rules. See §19.

**Marker**: A single geographic point within a quest. Every quest has at least one marker. Each marker has its own identity, name, location, and optional visual icon. The protocol uses the neutral term "marker" — applications may theme markers with any vocabulary (e.g., "islands", "birds", "stamps", "checkpoints"). See §19.

**Chain**: An ordered or unordered collection of quests that form a single logical journey. See §18.

**Subject Key**: A cryptographic key pair held by the subject on their device. The public key thumbprint is bound to the credential to prevent bearer-token theft. See §10.

## Credential Format

A Quest credential is a JWS Compact Serialization (RFC 7515) signed with the **EdDSA** algorithm using an **Ed25519** key pair (RFC 8037). The JWT consists of a JOSE header and a claims payload.

### 2.1 JOSE Header

```json
{
  "alg": "EdDSA",
  "kid": "quest-key-2025-01",
  "typ": "JWT"
}
```

The `kid` (Key ID) parameter MUST match a key in the issuer's published JWKS. The `alg` parameter MUST be `EdDSA`.

### 2.2 Claims Payload

```json
{
  "iss": "https://sallyforth.quest",
  "sub": "usr_8k29xm3f",
  "iat": 1739284800,
  "jti": "qc_a7f3b2e1-9d4c-4e8a-b6f0-2c1d3e4f5a6b",
  "cnf": {
    "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"
  },
  "quest": {
    "v": 1,
    "id": "isla-barataria",
    "name": "Isla Barataria",
    "lat": 39.857900,
    "lng": -4.024400,
    "radius_m": 200,
    "v_lat": 39.857600,
    "v_lng": -4.024100,
    "attest": "device"
  }
}
```

## Claim Definitions

### 3.1 Registered JWT Claims

| Claim | Type | Req | Description |
| --- | --- | --- | --- |
| iss | string | REQUIRED | HTTPS origin of the issuing server. Used for key discovery and manifest lookup. |
| sub | string | REQUIRED | Issuer-scoped, opaque subject identifier. |
| iat | number | REQUIRED | Unix timestamp (seconds) of credential issuance. |
| jti | string | REQUIRED | Unique credential identifier. MUST be prefixed with `qc_` followed by a UUID v4. |
| cnf | object | RECOMMENDED | Subject confirmation. Contains a `jkt` field with the JWK Thumbprint (RFC 7638) of the subject's public key. See §10. When present, verifiers SHOULD require proof of key possession. |

### 3.2 Quest Claims

All Quest-specific claims are nested under the `quest` object.

| Claim | Type | Req | Description |
| --- | --- | --- | --- |
| quest.v | integer | REQUIRED | Protocol version. MUST be `1` for this specification. |
| quest.id | string | REQUIRED | Quest identifier, unique within the issuer's namespace. Lowercase alphanumeric and hyphens. |
| quest.name | string | REQUIRED | Human-readable quest name. UTF-8, max 256 characters. |
| quest.lat | number | REQUIRED | Target latitude in decimal degrees (WGS 84). MUST be rounded to 6 decimal places. |
| quest.lng | number | REQUIRED | Target longitude in decimal degrees (WGS 84). MUST be rounded to 6 decimal places. |
| quest.radius_m | number | REQUIRED | Acceptance radius in meters. Min: 50. Max: 10000. |
| quest.v_lat | number | REQUIRED | Subject's verified latitude at time of issuance. MUST be rounded to 6 decimal places. |
| quest.v_lng | number | REQUIRED | Subject's verified longitude at time of issuance. MUST be rounded to 6 decimal places. |
| quest.attest | string | REQUIRED | Attestation method. One of: `gps`, `device`, `nfc`. See §9. |
| quest.id_hash | object | OPTIONAL | Identity binding. Contains `alg` (hash algorithm), `method` (identifier type: `email`, `phone`, `custom`), and `value` (hex-encoded hash). See §10.3. |
| quest.challenge | string | OPTIONAL | Challenge method completed. One of: `single`, `multi`, `sequential`, `duration`, `datebound`, `recurring`. See §11. Default: `single`. |
| quest.duration_m | integer | OPTIONAL | For `duration` challenges: minutes of verified continuous presence. See §11. |
| quest.window_start | string | OPTIONAL | For `datebound` challenges: ISO 8601 start of valid claim window. See §11. |
| quest.window_end | string | OPTIONAL | For `datebound` challenges: ISO 8601 end of valid claim window. See §11. |
| quest.occurrences | integer | OPTIONAL | For `recurring` challenges: number of verified presence events. See §11. |
| quest.interval_days | integer | OPTIONAL | For `recurring` challenges: minimum days between each occurrence. See §11. |
| quest.dates | string[] | OPTIONAL | For `recurring` challenges: ISO 8601 dates (`YYYY-MM-DD`, UTC) of each verified presence event, in chronological order. Length MUST equal `quest.occurrences`. See §11. |
| quest.chain | string | OPTIONAL | Chain identifier if this credential is part of a chain. See §18. |
| quest.seq | integer | OPTIONAL | Sequence number within a chain (1-indexed). See §18. |
| quest.of | integer | OPTIONAL | Total number of waypoints in the chain. See §18. |
| quest.meta | object | OPTIONAL | Issuer-defined metadata. No required schema. Verifiers MAY ignore. |

## Key Discovery

Issuers MUST publish their public signing keys as a JWK Set (RFC 7517) at the following well-known URI relative to their `iss` origin:

```
GET {iss}/.well-known/quest-jwks.json
```

```
{
  "keys": [
    {
      "kty": "OKP",
      "crv": "Ed25519",
      "kid": "quest-key-2025-01",
      "x": "<base64url-encoded-public-key>",
      "use": "sig"
    }
  ]
}
```

The JWKS endpoint MUST be served over HTTPS. Verifiers SHOULD cache the JWKS response per standard HTTP cache headers. Issuers SHOULD support key rotation by publishing multiple keys and retiring old `kid` values.

## Issuer Manifest

Issuers MUST publish a manifest file that declares protocol conformance and provides metadata for verifiers and directories. This is the mechanism by which a verifier confirms that an issuer claims to follow the Quest Protocol — not merely that they can sign JWTs.

```
GET {iss}/.well-known/quest-manifest.json
```

```json
{
  "quest_protocol": 1,
  "issuer": "https://sallyforth.quest",
  "name": "Sally Forth!",
  "description": "Proof-of-presence quests across La Mancha and beyond",
  "jwks_uri": "https://sallyforth.quest/.well-known/quest-jwks.json",
  "attestation_levels": ["gps", "device"],
  "contact": "admin@sallyforth.quest",
  "url": "https://sallyforth.quest"
}
```

### 5.1 Manifest Fields

| Field | Type | Req | Description |
| --- | --- | --- | --- |
| quest_protocol | integer | REQUIRED | Protocol version. MUST be `1`. |
| issuer | string | REQUIRED | HTTPS origin. MUST match the origin serving the manifest. |
| name | string | REQUIRED | Human-readable issuer name. |
| description | string | OPTIONAL | Brief description of the issuer's focus or quest types. |
| jwks_uri | string | REQUIRED | Absolute URL to the issuer's JWKS endpoint. |
| attestation_levels | string[] | REQUIRED | Attestation methods this issuer supports. |
| contact | string | OPTIONAL | Contact email for the issuer. |
| url | string | OPTIONAL | Public-facing URL for the issuer's application. |

### 5.2 Manifest Validation

When a verifier encounters a credential from an unknown issuer, it SHOULD:

1. Fetch `{iss}/.well-known/quest-manifest.json`
2. Confirm `quest_protocol` is a supported version
3. Confirm `issuer` matches the `iss` claim in the credential
4. Confirm `jwks_uri` points to a valid JWKS containing the `kid` referenced in the credential

If the manifest is absent, malformed, or the `issuer` field does not match the serving origin, the verifier SHOULD treat the credential as unverifiable. A valid manifest does not guarantee honest issuance — it confirms the issuer has declared conformance with this protocol.

> **Trust Model:** The manifest is analogous to a DKIM record in email. It proves the issuer has opted into the protocol and published their identity. It does not prove honest behavior. Verifiers make trust decisions based on the combination of: (1) a valid manifest, (2) a valid signature, (3) the attestation level declared, and (4) the issuer's reputation. See §8 for cross-app trust guidance.

## Issuance Flow

The protocol does not mandate a specific issuance API. Implementations may use REST, GraphQL, or any transport. The following describes the logical flow:

1. Subject's client reads device GPS coordinates via the platform geolocation API.
2. Client sends coordinates and, optionally, a platform attestation token to the issuer. For Tier 2 credentials (§10.2), the client MUST sign the issuance request with its device key to prove key ownership at the location.
3. Issuer validates that the subject's coordinates fall within `radius_m` of the quest's target point using the Haversine formula (§7.2).
4. If device attestation is provided, issuer validates the token with the platform's verification service.
5. If a signed issuance request is provided (Tier 2), issuer verifies the signature and computes the JWK Thumbprint for the `cnf` claim.
6. Issuer constructs the JWT claims payload, signs it with the issuer's Ed25519 private key, and returns the JWS Compact Serialization to the client.
7. Client stores the credential. Issuer MAY also store a record of issuance.

## Verification Algorithm

### 7.1 Credential Verification

A credential is valid if and only if ALL of the following conditions are met:

1. The issuer publishes a valid Quest manifest at `{iss}/.well-known/quest-manifest.json` (§5.2).
2. The JWS signature is valid against a public key from the issuer's published JWKS, matched by `kid`.
3. The `iss` claim is a valid HTTPS origin and matches the manifest's `issuer` field.
4. The `iat` timestamp is not in the future (with a maximum clock skew allowance of 60 seconds).
5. The `quest.v` claim equals a version supported by the verifier.
6. All REQUIRED claims defined in §3 are present and correctly typed.
7. The Haversine distance between (`quest.v_lat`, `quest.v_lng`) and (`quest.lat`, `quest.lng`) is ≤ `quest.radius_m`.

> **Note:** Verification is performed entirely by the verifier using the issuer's public key. No API call to the issuing server is required or expected.

### 7.2 Haversine Distance

```typescript
function haversine(lat1, lng1, lat2, lng2) {
  const R = 6_371_000 // Earth radius in meters
  const toRad = (d) => d * Math.PI / 180

  const dLat = toRad(lat2 - lat1)
  const dLng = toRad(lng2 - lng1)

  const a = Math.sin(dLat / 2) ** 2
    + Math.cos(toRad(lat1))
    * Math.cos(toRad(lat2))
    * Math.sin(dLng / 2) ** 2

  return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}
```

## Cross-App Interoperability

Quest Protocol is designed so that credentials issued by one conforming application are verifiable by any other conforming application, without prior coordination between the two. This section describes how that works and what "trust" means in practice.

### 8.1 How Two Independent Apps Interoperate

### 8.2 What Verification Proves

When a verifier successfully validates a Quest credential, they know three things with certainty:

1. **The credential was signed by the declared issuer.** Cryptographic fact, not a claim.
2. **The credential has not been tampered with.** Any modification invalidates the signature.
3. **The issuer declares conformance with Quest Protocol.** Confirmed by the presence of a valid manifest.

They know one thing by claim (not by proof):

1. **The issuer performed the stated attestation.** The `attest` field is a declaration by the issuer. A dishonest issuer could lie. The verifier trusts this claim to the degree they trust the issuer.

### 8.3 Trust Decisions

The protocol does not impose a trust model. Verifiers are expected to establish their own policies. Common approaches:

| Approach | Description |
| --- | --- |
| Open | Accept credentials from any issuer with a valid manifest and signature. Suitable for low-stakes applications (personal collections, social sharing). |
| Allowlist | Maintain a list of trusted issuer domains. Accept credentials only from those issuers. Suitable for professional or gated contexts. |
| Attestation Floor | Accept from any issuer but require a minimum attestation level (e.g., only `device` or `nfc`). Suitable for medium-stakes applications. |
| Directory | Reference a community-maintained directory of known issuers. See §8.4. |

### 8.4 Issuer Directory (Optional)

A public, community-maintained JSON file listing known conforming issuers MAY be hosted at `protocol.quest/directory.json`. This is not an authority — it is a convenience. Inclusion is not endorsement, and verifiers are not required to consult it.

```
{
  "version": 1,
  "updated": "2025-02-11T00:00:00Z",
  "issuers": [
    {
      "issuer": "https://sallyforth.quest",
      "name": "Sally Forth!",
      "attestation_levels": ["gps", "device"],
      "added": "2025-02-01"
    },
    {
      "issuer": "https://caminoquest.es",
      "name": "Camino Quest",
      "attestation_levels": ["gps", "device", "nfc"],
      "added": "2025-03-15"
    }
  ]
}
```

## Attestation Levels

| Value | Method | Requirements |
| --- | --- | --- |
| gps | GPS only | Browser or native geolocation API. No platform attestation. Suitable for personal use and low-stakes applications. |
| device | GPS + platform attestation | GPS plus Apple App Attest (iOS) or Google Play Integrity API (Android). Confirms a genuine, unmodified device running the legitimate application. |
| nfc | GPS + NFC tag | GPS plus a cryptographic challenge-response with a physical NFC tag installed at the quest location. |

Verifiers SHOULD document which attestation levels they accept. Issuers MUST NOT claim a higher attestation level than was actually performed.

## Credential Binding Tiers

A Quest credential can optionally be bound to its subject to prevent unauthorized use. The protocol defines three binding tiers. Each tier adds a layer of proof on top of the cryptographic credential itself. Issuers choose which tier to support. Verifiers choose which tier to require.

An analogy: a movie ticket (Tier 1) gets you into the theater — nobody checks your ID. A concert wristband (Tier 2) is physically attached to you — it can't be handed off. A plane ticket (Tier 3) requires matching government ID — the airline verifies both the ticket and the person.

### 10.1 Tier 1 — Bearer Credential (No Binding)

The credential contains no binding claims. Anyone who possesses the JWT string can present it. The verifier checks only that the credential is cryptographically valid and was issued by a legitimate issuer.

| Property | Value |
| --- | --- |
| Additional claims | None |
| Verification | Signature + manifest only |
| Analogy | Movie ticket, gift card, subway pass |
| Good for | Social sharing, personal collections, public display, QR codes on websites |
| Risk | Credential can be copied and presented by anyone |

### 10.2 Tier 2 — Device-Bound Credential

The credential is bound to a specific physical device using a cryptographic key pair stored in the device's secure hardware (iOS Secure Enclave, Android Keystore). The key is generated on the device, never leaves it, and cannot be extracted.

| Property | Value |
| --- | --- |
| Additional claims | `cnf.jkt` — JWK Thumbprint (RFC 7638) of the subject's device public key |
| Verification | Signature + manifest + challenge-response proving possession of the device key |
| Analogy | Concert wristband, car key fob |
| Good for | Preventing copy-paste theft, moderate-stakes verification, community gating |
| Risk | Credential is lost if device is lost (key pair can be regenerated on a new device but old credentials won't match) |

### 10.2.1 The `cnf` Claim

```
{
  "cnf": {
    "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"
  }
}
```

The `jkt` value is a base64url-encoded SHA-256 hash of the subject's public key in canonical JWK format (RFC 7638). This binds the credential to a specific device key without revealing the key itself.

### 10.2.2 Signed Issuance Request (Key Binding at Issuance)

To prevent an attacker from submitting their own device key alongside a legitimate subject's GPS coordinates, the issuance request for Tier 2 credentials MUST be signed by the subject's device key. The issuer verifies this signature before issuing the credential, proving the person at the location is the one who owns the key being bound.

```typescript
// Client: sign the issuance request with device key
const issuanceRequest = await new SignJWT({
  quest_id: 'isla-barataria',
  lat: coords.latitude,
  lng: coords.longitude,
})
  .setProtectedHeader({ alg: 'EdDSA' })
  .setIssuedAt()
  .setExpirationTime('60s')
  .sign(subjectPrivateKey)

// Client: send signed request + public key to issuer
const res = await fetch('/api/quest/claim', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    signed_request: issuanceRequest,
    public_key: subjectPublicJwk,
  }),
})
```

```typescript
// Server: verify the signed request before issuing
const subjectKey = await importJWK(body.public_key, 'EdDSA')

// 1. Verify the client signed this request
const { payload } = await jwtVerify(body.signed_request, subjectKey)

// 2. Check request is fresh (not replayed)
if (payload.exp < Date.now() / 1000) {
  throw new Error('Issuance request expired')
}

// 3. Validate GPS against quest radius
const distance = haversine(quest.lat, quest.lng, payload.lat, payload.lng)
if (distance > quest.radius_m) {
  throw new Error('Outside quest radius')
}

// 4. Compute thumbprint and include in credential
const jkt = await calculateJwkThumbprint(body.public_key, 'sha-256')

// 5. Issue credential with cnf bound to verified key
const credential = await issueCredential(userId, quest, payload, jkt)
```

This ensures the device key is cryptographically proven to belong to the person who was physically at the location at the time of the request. An attacker cannot substitute their own key because they cannot sign the GPS coordinates from the correct location.

### 10.2.3 Challenge-Response Verification (Presentation)

When a subject later presents a Tier 2 credential to a verifier, the verifier performs a challenge-response to confirm the subject still holds the device key. The proof JWT MUST include the `jti` of the Quest credential being verified, preventing proof reuse across credentials.

```typescript
// Verifier sends a random challenge
const challenge = crypto.randomUUID()

// Subject's device signs the challenge + credential jti
const proof = await new SignJWT({
  challenge,
  credential_jti: 'qc_a7f3b2e1-9d4c-4e8a-b6f0-2c1d3e4f5a6b',
})
  .setProtectedHeader({ alg: 'EdDSA' })
  .setIssuedAt()
  .setExpirationTime('60s')
  .sign(subjectPrivateKey)

// Verifier validates:
// 1. Verify proof signature with subject's public key
// 2. Confirm challenge matches what was sent
// 3. Confirm credential_jti matches the credential being verified
// 4. Compute thumbprint of subject's public key
// 5. Confirm thumbprint matches credential's cnf.jkt
const thumbprint = await calculateJwkThumbprint(
  subjectPublicKey, 'sha-256'
)
if (thumbprint !== credential.cnf.jkt) {
  throw new Error('Device binding verification failed')
}
if (proofPayload.credential_jti !== credential.jti) {
  throw new Error('Proof does not match credential')
}
```

This works in PWAs (Web Crypto API / WebAuthn), React Native (expo-secure-store, react-native-keychain), and native apps. The secure enclave key generation and signing are handled by the platform — no auth provider dependency.

> **Security Note:** If a verifier receives a credential with a `cnf` claim, the verifier MUST require the challenge-response and MUST reject the credential if the challenge-response fails. A `cnf` claim with no corresponding proof is worse than no `cnf` claim at all — it indicates a potentially stolen credential. Verifiers MUST NOT downgrade a Tier 2 credential to Tier 1 by ignoring the `cnf` claim.

### 10.3 Tier 3 — Identity-Bound Credential

The credential is bound to a verified identity. The issuer authenticates the subject using any method (email, phone, social login, passkey — the protocol does not specify which) and includes a hash of the verified identifier in the credential. The raw identifier (email, phone number) is never stored in the credential, preserving privacy.

| Property | Value |
| --- | --- |
| Additional claims | `cnf.jkt` (optional) + `quest.id_hash` — SHA-256 hash of a verified identifier |
| Verification | Signature + manifest + subject provides their raw identifier, verifier hashes it and confirms match |
| Analogy | Plane ticket, professional certification, notarized document |
| Good for | Job applications, professional credentials, access control, any context where "who" matters as much as "where" |
| Risk | Requires the subject to reveal their identifier (email, phone) to the verifier during verification |

### 10.3.1 The `quest.id_hash` Claim

```json
{
  "quest": {
    "v": 1,
    "id": "isla-barataria",
    "id_hash": {
      "alg": "sha-256",
      "method": "email",
      "value": "c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2"
    },
    ...
  }
}
```

The `method` field indicates what was hashed (`email`, `phone`, or `custom`). The `value` is the lowercase hex-encoded SHA-256 hash. The verifier asks Sancho for his email, computes the hash, and checks it matches. The credential itself never contains the raw email.

### 10.3.2 Identity Verification Flow

```typescript
// At issuance: issuer hashes Sancho's verified email
const encoder = new TextEncoder()
const data = encoder.encode(email.toLowerCase().trim())
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
const hashHex = [...new Uint8Array(hashBuffer)]
  .map(b => b.toString(16).padStart(2, '0'))
  .join('')

// At verification: verifier asks Sancho for his email
// and performs the same hash
const sanchoHash = await hashEmail(sanchoProvidedEmail)
if (sanchoHash !== credential.quest.id_hash.value) {
  throw new Error('Identity verification failed')
}
```

### 10.4 Tier Compatibility

Tiers are additive, not exclusive. A credential MAY include both `cnf` (Tier 2) and `quest.id_hash` (Tier 3). A credential with no binding claims is Tier 1 by default. Verifiers SHOULD document which tier(s) they require.

The protocol is authentication-agnostic. It does not specify how issuers verify their users' identities. Any authentication method (email/password, phone SMS, social login via Google/Apple/GitHub, passkeys, magic links, or any future method) is valid as long as the issuer is confident the identity is genuine. The `quest.id_hash.method` field tells the verifier what *type* of identifier was hashed, not which auth provider was used.

> **Design Note:** The three-tier model mirrors real-world credential systems. A library card (Tier 1) doesn't prove who you are. A hotel key card (Tier 2) is bound to a device. A passport (Tier 3) is bound to a verified identity. Different contexts demand different levels of assurance. The protocol provides the slots; the ecosystem decides the policy.

## Challenge Methods

The `quest.challenge` claim declares what type of presence challenge was completed to earn the credential. This makes credentials self-describing — a verifier knows not just WHERE the subject was, but what they had to DO. If `quest.challenge` is absent, verifiers SHOULD assume `single`.

Challenge enforcement is **issuer-side application logic**, not protocol logic. The protocol defines the claim values and their semantics. Issuers implement the verification rules. Verifiers that do not understand a challenge method can still verify the credential's signature and location — they simply ignore the challenge semantics.

### 11.1 Challenge Types

| Value | Description | Additional Claims |
| --- | --- | --- |
| single | Be at one location. The basic "I was here" check. This is the default if `quest.challenge` is absent. | None |
| multi | Visit all locations in a set, any order. Each waypoint issues its own credential. A completion credential is issued when all waypoints are done. Used with Quest Chains (§18). | `quest.chain`, `quest.seq`, `quest.of` |
| sequential | Visit locations in a specific order. The issuer refuses to issue waypoint N until waypoint N-1 is complete. Used with Quest Chains (§18). | `quest.chain`, `quest.seq`, `quest.of` |
| duration | Be at a location for a minimum continuous time. The issuer validates sustained presence through periodic GPS pings from the client. The credential records the verified duration. | `quest.duration_m` (minutes of verified presence) |
| datebound | Be at a location within a specific date/time window. The issuer validates that the `iat` falls within the window. Useful for events, seasonal quests, and time-limited challenges. | `quest.window_start`, `quest.window_end` (ISO 8601) |
| recurring | Be at a location repeatedly over time, proving a pattern of presence. The issuer tracks individual presence events and issues a pattern credential when the threshold is met. The credential records the date of each verified event, so verifiers can audit the actual pattern — not just the count. Useful for proof of residency, regular attendance, or habit verification. | `quest.occurrences` (count), `quest.interval_days` (minimum gap between events), `quest.dates` (dates of each verified event) |

### 11.2 Example: Duration Challenge Credential

```json
{
  "quest": {
    "v": 1,
    "id": "molinos-de-consuegra",
    "name": "Molinos de Viento de Consuegra",
    "challenge": "duration",
    "duration_m": 30,
    "lat": 39.461200,
    "lng": -3.610400,
    "radius_m": 300,
    "v_lat": 39.461000,
    "v_lng": -3.610200,
    "attest": "gps"
  }
}
```

### 11.3 Example: Recurring Challenge Credential (Proof of Governance)

```json
{
  "quest": {
    "v": 1,
    "id": "barataria-governor-q1-2025",
    "name": "Gobernador de Barataria — Q1 2025",
    "challenge": "recurring",
    "occurrences": 6,
    "interval_days": 7,
    "dates": [
      "2025-01-06",
      "2025-01-13",
      "2025-01-20",
      "2025-01-27",
      "2025-02-03",
      "2025-02-10"
    ],
    "lat": 39.857900,
    "lng": -4.024400,
    "radius_m": 500,
    "v_lat": 39.858000,
    "v_lng": -4.024200,
    "attest": "gps"
  }
}
```

The `dates` array is the pattern itself, signed. A verifier reading this credential does not have to take "6 occurrences" on faith as an aggregate — it can see the six Mondays. This mirrors the chain completion credential (§18.3), which enumerates its waypoint `jti` values rather than asserting a count.

## Credential Portability

A Quest credential must never be trapped inside the app that issued it. The protocol defines mandatory export mechanisms that ensure every credential is portable by default, regardless of issuer. Any conforming wallet, verifier, or application can receive and validate credentials from any issuer.

### 12.1 Mandatory Export Requirements

Every conforming application that displays Quest credentials to a subject MUST implement all of the following export mechanisms:

| Mechanism | Description | Use Case |
| --- | --- | --- |
| Deep Link | A button or link in the format `{wallet-url}/import?credential={jwt}`. When tapped, opens the target wallet app directly with the credential ready to import. The issuer MAY default to a well-known wallet or allow the subject to choose. | Same-device transfer from issuing app to wallet app. One tap. |
| Share Sheet | Integration with the platform's native share mechanism (Web Share API on PWAs, native share on mobile). Shares the credential as a `.quest` file or a URL. Apps registered as `.quest` file handlers appear in the share menu. | Same-device transfer using the phone's native share UI. |
| Clipboard | A "Copy Credential" button that copies the raw JWT string to the system clipboard. Wallet apps SHOULD offer an "Import from Clipboard" function. | Universal fallback. Works across all platforms and contexts. |
| .quest File | A "Download" button that saves the credential as a `.quest` file. See §12.2 for format. | File-based transfer. On Android, tapping the downloaded file opens the registered handler app. |
| QR Code | A QR code containing a URL that resolves to the credential (see §12.3). Displayed on-screen for scanning by another device. | Cross-device transfer. Showing a verifier (restaurant cashier, event check-in). Person-to-person sharing. |

### 12.2 The `.quest` File Format

A `.quest` file is a JSON file with the `.quest` extension. It contains the signed credential and display metadata. The MIME type is `application/x-quest-credential`.

```json
{
  "quest_protocol": 1,
  "credential": "eyJhbGciOiJFZERTQSIsImtpZCI6...",
  "issuer": "https://sallyforth.quest",
  "quest_name": "Isla Barataria",
  "issued_at": "2025-02-11T14:30:00Z"
}
```

The `credential` field contains the signed JWT. All other fields are display-only metadata — the receiving application MUST verify the JWT independently and MUST NOT trust the metadata fields without verification. The metadata exists solely to provide a human-readable preview before the JWT is parsed.

### 12.3 Credential Public URL

Issuers SHOULD serve each credential at a stable public URL:

```
GET {iss}/credentials/{jti}
```

This endpoint returns the raw JWT string (content type `text/plain`) or, if the request includes `Accept: application/json`, a JSON object containing the JWT and display metadata (same format as the `.quest` file).

This URL is what QR codes encode. It is what gets shared on social media. It provides a permanent, linkable home for every credential, even if the subject never exports it. If the issuer's server goes offline, credentials that were previously exported (as JWT strings or `.quest` files) remain independently verifiable via the cached JWKS — but the public URL will be unavailable.

### 12.4 Import Convention

Applications that accept Quest credentials from external issuers (wallet apps, verifiers, forms) SHOULD implement the following import URL:

```
{app-origin}/import?credential={jwt-string}
```

Upon receiving a credential via this URL, the application MUST verify the credential (§7) before storing or displaying it. Invalid credentials MUST be rejected with a clear error message.

### 12.5 Personal Credential Store

The protocol defines a standard file format for a personal collection of credentials. This file is owned by the subject, stored wherever they choose (local device, iCloud, Google Drive, Dropbox, USB drive), and readable by any conforming application. No app, server, or service is required to maintain it.

The file is named `quest-credentials.json` and contains an array of credential entries:

```json
{
  "quest_protocol": 1,
  "exported_at": "2025-02-12T10:00:00Z",
  "credentials": [
    {
      "credential": "eyJhbGciOiJFZERTQSIs...",
      "issuer": "https://sallyforth.quest",
      "quest_name": "Isla Barataria",
      "issued_at": "2025-02-11T14:30:00Z",
      "added_at": "2025-02-11T14:31:00Z"
    },
    {
      "credential": "eyJhbGciOiJFZERTQSIs...",
      "issuer": "https://caminoquest.es",
      "quest_name": "Ruta de los Molinos",
      "issued_at": "2025-03-05T11:15:00Z",
      "added_at": "2025-03-05T11:16:00Z"
    }
  ]
}
```

The `credential` field is the signed JWT. The other fields are display metadata — applications MUST verify each JWT independently upon import and MUST NOT trust the metadata without verification.

### 12.5.1 Store Requirements

| Requirement | Description |
| --- | --- |
| Export All | Every conforming app that stores credentials MUST offer a "Export All" function that generates a `quest-credentials.json` file containing all of the subject's credentials. |
| Import All | Every conforming app that stores credentials MUST accept a `quest-credentials.json` file and import its contents, verifying each credential individually. |
| Non-destructive | Importing a store file MUST NOT overwrite or delete existing credentials. Duplicate `jti` values are skipped. |
| No lock-in | The store file is plain JSON. No encryption, no proprietary format, no app-specific dependencies. Any text editor can read it. |

### 12.6 Credential Self-Sovereignty

A Quest credential is a self-contained proof. The signed JWT carries its own evidence: the issuer, the subject, the location, the timestamp, the attestation method, and the cryptographic signature. Verification requires only the issuer's public key, which is published at a well-known URL and can be cached indefinitely.

This means:

- **If the issuing app shuts down:** credentials remain valid. Any party that cached the issuer's JWKS can verify them. The credential does not phone home.
- **If the wallet app shuts down:** credentials are still in the subject's `quest-credentials.json` file. They can be imported into any other conforming app.
- **If no app exists at all:** the JWT string is the credential. It can be verified by any developer with the `jose` library and access to the issuer's public key.

The subject's credentials belong to the subject. No app, company, or service can revoke, withhold, or gate access to credentials the subject has already received. This is a core design principle of the protocol.

> **Design Note:** The portability requirements ensure that the Quest Protocol ecosystem cannot fragment into isolated silos. Even if an issuing app is abandoned, shut down, or never updated, the credentials it issued remain portable and verifiable. The JWT string is the credential — it carries its own proof. The export mechanisms, the personal credential store, and the self-sovereignty principle together guarantee that credentials outlive the apps that created them.

## Identity Model

A subject is uniquely identified by the compound key (`iss`, `sub`). The `sub` value is opaque and meaningful only to the issuer. There is no global identity registry.

Consuming applications that wish to associate credentials from multiple issuers with a single user identity MUST maintain their own mapping. The protocol intentionally avoids cross-issuer identity correlation to preserve subject privacy.

When device binding (Tier 2, §10.2) is used, the `cnf.jkt` value provides an additional correlation signal: if the same device key appears in credentials from multiple issuers, a verifier can infer they belong to the same physical device. This enables optional cross-issuer correlation *only when the subject actively participates* by presenting their key.

When identity binding (Tier 3, §10.3) is used, the `quest.id_hash` value enables verifiers to confirm the presenter's identity matches the credential, but only when the subject voluntarily provides their raw identifier (e.g., email address). The hash cannot be reversed to discover the identifier.

## Coordinate Precision

All latitude and longitude values (`quest.lat`, `quest.lng`, `quest.v_lat`, `quest.v_lng`) MUST be rounded to exactly **6 decimal places** before signing. Six decimal places provides approximately 11 centimeters of precision, which exceeds the accuracy of consumer GPS hardware (~3-5 meters).

The minimum value for `quest.radius_m` is **50 meters** (~164 feet). This accounts for consumer GPS inaccuracy, indoor signal drift, and the practical reality that proof-of-presence does not require pinpoint accuracy. The maximum value is 10,000 meters.

```typescript
// Issuers MUST normalize coordinates before signing
const normalize = (coord) => Math.round(coord * 1e6) / 1e6
```

## Security Considerations

### 15.1 GPS Spoofing

GPS coordinates from consumer devices can be spoofed via software. The `gps` attestation level provides no protection against this. Applications requiring higher assurance SHOULD require `device` or `nfc` attestation. Issuers MAY implement additional heuristics including velocity checks and coordinate accuracy validation.

### 15.2 Dishonest Issuers

An issuer can sign credentials without performing genuine location verification. The protocol cannot prevent this. Verifiers mitigate this risk through trust policies (§8.3): allowlists, minimum attestation requirements, and community directories. This is the same trust model used by TLS certificate authorities, DKIM in email, and federated identity providers.

### 15.3 Key Security

Issuer private keys MUST be stored securely and MUST NOT be exposed to client-side code. In serverless environments, private keys should be stored as encrypted environment variables and accessed only in server-side functions.

### 15.4 Replay Prevention

The `jti` claim provides a unique credential identifier. Verifiers concerned with replay attacks SHOULD maintain a set of seen `jti` values. The protocol does not mandate expiration (`exp`) as credentials are intended to be permanent records, but issuers MAY include `exp` for time-bounded credentials.

### 15.5 Transport Security

The `iss` claim MUST be an HTTPS origin. The JWKS and manifest endpoints MUST be served over HTTPS.

### 15.6 Issuance Window

Issuers SHOULD enforce a maximum time window between the GPS reading on the client and the issuance request arriving at the server. A window of 60 seconds is RECOMMENDED. This prevents a subject from capturing valid coordinates and submitting them hours or days later. This is an issuer implementation concern, not a credential claim, because the credential itself is a permanent record and SHOULD NOT expire.

### 15.7 Bearer Token Risk

Without credential binding, Quest credentials are bearer tokens (Tier 1, §10.1). Anyone who obtains a credential string can present it as their own. For contexts where impersonation is a concern, issuers SHOULD issue Tier 2 (device-bound) or Tier 3 (identity-bound) credentials, and verifiers SHOULD require the corresponding verification.

## Reference Implementation

The following TypeScript examples use the `jose` library (v5+), which supports Ed25519 and runs in Node.js, browser, and React Native environments.

### 16.1 Key Generation

```typescript
import { generateKeyPair, exportJWK, exportPKCS8 } from 'jose'

const { publicKey, privateKey } = await generateKeyPair('EdDSA', {
  crv: 'Ed25519',
})

// Publish at /.well-known/quest-jwks.json
const publicJwk = await exportJWK(publicKey)
publicJwk.kid = 'quest-key-2025-01'
publicJwk.use = 'sig'

// Store securely (environment variable)
const privatePem = await exportPKCS8(privateKey)
```

### 16.2 Credential Issuance

```typescript
import { SignJWT, importPKCS8, calculateJwkThumbprint } from 'jose'

const privateKey = await importPKCS8(
  process.env.QUEST_PRIVATE_KEY,
  'EdDSA'
)

const normalize = (c) => Math.round(c * 1e6) / 1e6

async function issueCredential(userId, quest, coords, subjectJwk) {
  const distance = haversine(
    quest.lat, quest.lng, coords.lat, coords.lng
  )

  if (distance > quest.radius_m) {
    throw new Error('Subject outside quest radius')
  }

  // Compute subject key thumbprint for cnf binding
  const jkt = await calculateJwkThumbprint(subjectJwk, 'sha-256')

  return new SignJWT({
    cnf: { jkt },
    quest: {
      v: 1,
      id: quest.id,
      name: quest.name,
      lat: normalize(quest.lat),
      lng: normalize(quest.lng),
      radius_m: quest.radius_m,
      v_lat: normalize(coords.lat),
      v_lng: normalize(coords.lng),
      attest: coords.attestation,
    },
  })
    .setProtectedHeader({
      alg: 'EdDSA',
      kid: 'quest-key-2025-01',
      typ: 'JWT',
    })
    .setIssuer('https://sallyforth.quest')
    .setSubject(userId)
    .setIssuedAt()
    .setJti(`qc_${crypto.randomUUID()}`)
    .sign(privateKey)
}
```

### 16.3 Credential Verification

```typescript
import { jwtVerify, createRemoteJWKSet } from 'jose'

async function verifyCredential(token) {
  // Extract issuer to discover keys
  const [, payloadB64] = token.split('.')
  const { iss } = JSON.parse(atob(payloadB64))

  // 1. Fetch and validate manifest
  const manifest = await fetch(
    `${iss}/.well-known/quest-manifest.json`
  ).then(r => r.json())

  if (manifest.quest_protocol !== 1) throw new Error('Unsupported version')
  if (manifest.issuer !== iss) throw new Error('Issuer mismatch')

  // 2. Verify JWT signature against published keys
  const JWKS = createRemoteJWKSet(new URL(manifest.jwks_uri))
  const { payload } = await jwtVerify(token, JWKS, { issuer: iss })

  // 3. Validate location math
  const q = payload.quest
  const distance = haversine(q.lat, q.lng, q.v_lat, q.v_lng)
  if (distance > q.radius_m) throw new Error('Location mismatch')

  return {
    valid: true,
    issuer: iss,
    issuerName: manifest.name,
    subject: payload.sub,
    quest: payload.quest,
    issuedAt: new Date(payload.iat * 1000),
  }
}
```

### 16.4 PWA Geolocation (Client)

```typescript
async function getCurrentPosition() {
  return new Promise((resolve, reject) => {
    navigator.geolocation.getCurrentPosition(
      (pos) => resolve(pos.coords),
      (err) => reject(err),
      { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }
    )
  })
}

async function claimQuest(questId) {
  const coords = await getCurrentPosition()
  const res = await fetch('/api/quest/claim', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      questId,
      lat: coords.latitude,
      lng: coords.longitude,
    }),
  })
  const { credential } = await res.json()
  return credential // signed JWT string
}
```

## IANA Considerations

This specification registers the following well-known URI suffixes per RFC 8615:

| URI Suffix | Change Controller | Reference |
| --- | --- | --- |
| quest-jwks.json | Quest Protocol Authors | This document, §4 |
| quest-manifest.json | Quest Protocol Authors | This document, §5 |

This specification defines a private JWT claim namespace `quest`. Registration with the IANA JWT Claims Registry is pending.

This specification defines a file extension `.quest` with MIME type `application/x-quest-credential` for portable credential files (§12.2).

## Extension: Quest Chains *(Optional Extension)*

A Quest Chain is an ordered or unordered collection of individual quests that form a logical journey. Chains enable multi-location experiences such as trails, city tours, and scavenger hunts. This section is an optional extension to the core protocol. Issuers MAY implement chains; verifiers MAY ignore chain-related claims.

### 18.1 Chain Model

A chain is **not a credential**. It is a logical grouping defined by the issuer in their application layer (e.g., in a database). The protocol does not define a chain format — only the claims that individual credentials carry to indicate chain membership.

When a subject completes a waypoint in a chain, the issuer issues a standard Quest credential with additional chain claims. When all waypoints are completed, the issuer MAY issue a **chain completion credential**.

### 18.2 Chain Claims on Waypoint Credentials

The following OPTIONAL claims are added to the `quest` object:

| Claim | Type | Description |
| --- | --- | --- |
| quest.chain | string | Chain identifier, unique within the issuer's namespace. |
| quest.seq | integer | This waypoint's position in the chain (1-indexed). |
| quest.of | integer | Total number of waypoints in the chain. |

```json
{
  "iss": "https://sallyforth.quest",
  "sub": "usr_8k29xm3f",
  "iat": 1739371200,
  "jti": "qc_b8e4c3f2-1a5d-4f9b-c7e1-3d2e4f6a7b8c",
  "quest": {
    "v": 1,
    "id": "islas-de-sancho:isla-de-formentera",
    "name": "Isla de Formentera",
    "lat": 38.706100,
    "lng": 1.436300,
    "radius_m": 500,
    "v_lat": 38.705900,
    "v_lng": 1.436100,
    "attest": "gps",
    "chain": "islas-de-sancho",
    "seq": 2,
    "of": 5
  }
}
```

### 18.3 Chain Completion Credential

When a subject completes all waypoints in a chain, the issuer MAY issue a completion credential. The completion credential uses the chain ID as the `quest.id` and sets `quest.type` to `"chain_complete"`. The location fields reflect the final waypoint.

```json
{
  "iss": "https://sallyforth.quest",
  "sub": "usr_8k29xm3f",
  "iat": 1739630400,
  "jti": "qc_d9f5e4a3-2b6e-4c0d-a8f2-4e3f5a7b9c0d",
  "quest": {
    "v": 1,
    "id": "islas-de-sancho",
    "name": "Las Islas de Sancho Panza",
    "type": "chain_complete",
    "chain": "islas-de-sancho",
    "of": 5,
    "lat": 38.908900,
    "lng": 1.432600,
    "radius_m": 500,
    "v_lat": 38.908700,
    "v_lng": 1.432400,
    "attest": "gps",
    "waypoints": [
      "qc_a7f3b2e1-9d4c-4e8a-b6f0-2c1d3e4f5a6b",
      "qc_b8e4c3f2-1a5d-4f9b-c7e1-3d2e4f6a7b8c",
      "qc_c9f5d4e3-2b6e-4c0d-a8f2-4e3f5a7b9c0d",
      "qc_d0a6e5f4-3c7f-4d1e-b9a3-5f4a6b8c0d1e",
      "qc_e1b7f6a5-4d8a-4e2f-c0b4-6a5b7c9d1e2f"
    ]
  }
}
```

### 18.4 Chain Enforcement

Sequential ordering, date windows, and other chain business rules are **issuer-side application logic**, not protocol concerns. The protocol defines only the credential format. Examples of issuer-enforced rules:

Verifiers inspecting a chain completion credential can see the `waypoints` array of `jti` values, and MAY independently verify each waypoint credential if the issuer or subject makes them available.

### 18.5 Additional Chain Claim

| Claim | Type | Context | Description |
| --- | --- | --- | --- |
| quest.type | string | Completion only | MUST be `"chain_complete"` on completion credentials. Absent on waypoint credentials. |
| quest.waypoints | string[] | Completion only | Array of `jti` values for each waypoint credential in the chain, in chain order. |

## Quest Object Definition

The preceding sections define the **credential format** — how to sign, structure, and verify proof-of-presence JWTs. This section defines the **Quest Object** — a standard format for describing a quest as a standalone entity, independent of any credential. The Quest Object exists before anyone plays the quest, before any credential is issued.

The protocol intentionally does not mandate how quests are created, stored, or managed — those are application-layer concerns. This section defines the minimum schema that any conforming application can use to represent, exchange, and render a quest.

### 19.1 Quest Object Schema

A Quest Object is a JSON document describing a quest. It contains metadata about the quest itself and an array of one or more markers representing the locations to be visited.

```json
{
  "quest_protocol": 1,
  "id": "islas-de-sancho",
  "name": "Las Islas de Sancho Panza",
  "issuer": "https://sallyforth.quest",
  "image": "https://sallyforth.quest/quests/islas-de-sancho.jpg",
  "description": "Don Quixote promised Sancho the governorship of an island. Find all 5 islands across the Spanish coast to claim your title.",
  "lat": 38.908900,
  "lng": 1.432600,
  "radius_m": 500,
  "attest": "gps",
  "challenge": "sequential",
  "geo_name": "Islas Baleares, Spain",
  "active": true,
  "version": 1,
  "marker_type": "island",
  "marker_reveal": "on_claim",
  "markers": [
    {
      "id": "isla-de-ibiza",
      "name": "Isla de Ibiza",
      "type": "island",
      "icon": "https://sallyforth.quest/islands/ibiza.png",
      "lat": 38.908900,
      "lng": 1.432600,
      "hint": "Where the salt flats meet the sea",
      "seq": 1,
      "reveal": "on_claim"
    },
    {
      "id": "isla-de-formentera",
      "name": "Isla de Formentera",
      "type": "island",
      "icon": "https://sallyforth.quest/islands/formentera.png",
      "lat": 38.706100,
      "lng": 1.436300,
      "hint": "The smallest of the Pitiusas",
      "seq": 2,
      "reveal": "on_claim"
    },
    {
      "id": "isla-de-mallorca",
      "name": "Isla de Mallorca",
      "type": "island",
      "icon": "https://sallyforth.quest/islands/mallorca.png",
      "lat": 39.613200,
      "lng": 2.987300,
      "hint": "The cathedral watches over the harbor",
      "seq": 3,
      "reveal": "on_claim"
    },
    {
      "id": "isla-de-menorca",
      "name": "Isla de Menorca",
      "type": "island",
      "icon": "https://sallyforth.quest/islands/menorca.png",
      "lat": 39.949600,
      "lng": 3.821600,
      "hint": "Easternmost of the Balearics",
      "seq": 4,
      "reveal": "on_claim"
    },
    {
      "id": "isla-de-cabrera",
      "name": "Isla de Cabrera",
      "type": "island",
      "icon": "https://sallyforth.quest/islands/cabrera.png",
      "lat": 39.143500,
      "lng": 2.947100,
      "hint": "The goat island — a national park",
      "seq": 5,
      "reveal": "on_claim"
    }
  ],
  "meta": {
    "difficulty": "legendary",
    "estimated_days": 14,
    "theme": "Don Quixote's promise to Sancho Panza"
  }
}
```

### 19.2 Quest Object Fields

| Field | Type | Req | Description |
| --- | --- | --- | --- |
| quest_protocol | integer | REQUIRED | Protocol version. MUST be `1`. |
| id | string | REQUIRED | Quest identifier, unique within the issuer's namespace. Lowercase alphanumeric and hyphens. This becomes `quest.id` in issued credentials. |
| name | string | REQUIRED | Human-readable quest name. UTF-8, max 256 characters. |
| issuer | string | REQUIRED | HTTPS origin of the issuing server. |
| image | string or object | REQUIRED | The quest's visual — a hero graphic, card image, or header. Used for display in lists, maps, cards, and share previews. May be a URL string (backward compat) or a QuestImage object (§19.8). Conforming applications MUST generate a default header image when no custom image is provided. See §19.8. |
| lat | number | REQUIRED | Central latitude of the quest area in decimal degrees (WGS 84). MUST be rounded to 6 decimal places. |
| lng | number | REQUIRED | Central longitude of the quest area in decimal degrees (WGS 84). MUST be rounded to 6 decimal places. |
| radius_m | number | REQUIRED | Overall quest area radius in meters. For single-marker quests, this is the acceptance radius. For multi-marker quests, this defines the bounding area. Min: 50. Max: 10,000. |
| attest | string | REQUIRED | Required attestation method. One of: `gps`, `device`, `nfc`. See §9. |
| challenge | string | OPTIONAL | Challenge type. One of: `single`, `multi`, `sequential`, `duration`, `datebound`, `recurring`. Default: `single`. See §11. |
| description | string | OPTIONAL | Human-readable description of the quest. What it is, why go here, what to expect. UTF-8, max 1024 characters. |
| geo_name | string | OPTIONAL | Human-readable place name (city, park, neighborhood). For display only — not used in verification. |
| active | boolean | OPTIONAL | Whether the quest is currently accepting claims. Default: `true`. Issuers MUST reject claims against inactive quests. |
| version | integer | OPTIONAL | Quest definition version. Increments each time the creator edits the quest. Enables the snapshot principle (§19.6). |
| marker_type | string | OPTIONAL | Default marker type for all markers in this quest (e.g., `"bird"`, `"island"`, `"checkpoint"`, `"stamp"`). Individual markers MAY override this with their own `type` field. |
| marker_reveal | string | OPTIONAL | Default marker visibility for all markers in this quest. One of: `"immediate"` (marker icon and details are visible before the subject visits the location) or `"on_claim"` (marker icon and details are hidden until the subject claims the marker by visiting its location). Default: `"immediate"`. Individual markers MAY override this with their own `reveal` field. |
| markers | array | REQUIRED | Array of Marker Objects (§19.3). Every quest MUST have at least one marker. |
| expires_at | string | OPTIONAL | ISO 8601 timestamp. The quest is no longer claimable after this date. See §19.5. |
| completion_window_days | integer | OPTIONAL | For multi-marker quests: maximum number of days from first marker claim to quest completion. See §19.5. |
| duration_m | integer | OPTIONAL | For `duration` challenges: minutes of required continuous presence at a marker location. |
| window_start | string | OPTIONAL | For `datebound` challenges: ISO 8601 start of valid claim window. |
| window_end | string | OPTIONAL | For `datebound` challenges: ISO 8601 end of valid claim window. |
| occurrences | integer | OPTIONAL | For `recurring` challenges: number of required presence events. |
| interval_days | integer | OPTIONAL | For `recurring` challenges: minimum days between each occurrence. |
| audience | string | OPTIONAL | Who can see and play this quest. One of: `solo`, `group`, `org`, `public`. Default: `solo`. See §20. |
| play_mode | string | OPTIONAL | How a group completes the quest. One of: `individual`, `together`, `divide`. Default: `individual`. Only meaningful when `audience` is `group`. See §20.3. |
| meta | object | OPTIONAL | Issuer-defined metadata. Open schema. No required fields. Applications MAY use this for app-specific data (categories, tags, difficulty, estimated time, etc.). |

### 19.3 Marker Object

A Marker is a single geographic point within a quest. Every quest has at least one marker. A single-marker quest has exactly one; a multi-marker quest has two or more.

The protocol uses the neutral term **marker**. Applications theme markers with their own vocabulary — "islands" in a Quixote-themed app, "birds" in a birding app, "stamps" in a passport app, "taps" in a brewery app. The `type` field carries this vocabulary. The `icon` field carries the visual. The protocol does not constrain either.

| Field | Type | Req | Description |
| --- | --- | --- | --- |
| id | string | REQUIRED | Marker identifier, unique within the quest. Lowercase alphanumeric and hyphens. For multi-marker quests, the marker credential's `quest.id` is typically `{quest-id}:{marker-id}`. |
| name | string | REQUIRED | Human-readable marker name. UTF-8, max 256 characters. This becomes `quest.name` in the marker-level credential. |
| type | string | REQUIRED | What this marker is — the application's themed vocabulary. Free string (e.g., `"bird"`, `"island"`, `"checkpoint"`, `"landmark"`, `"dog"`, `"stamp"`). If omitted, defaults to the quest-level `marker_type`. At least one of marker-level `type` or quest-level `marker_type` MUST be present. |
| lat | number | REQUIRED | Marker latitude in decimal degrees (WGS 84). MUST be rounded to 6 decimal places. |
| lng | number | REQUIRED | Marker longitude in decimal degrees (WGS 84). MUST be rounded to 6 decimal places. |
| icon | string | OPTIONAL | URL to the marker's map pin image. This is what appears on the map — a bird illustration, an island drawing, a branded icon. Applications SHOULD provide a default icon per `type` when no `icon` is specified. |
| hint | string | OPTIONAL | A clue or tip to help the subject find this marker. UTF-8, max 512 characters. |
| seq | integer | OPTIONAL | Sequence number within the quest (1-indexed). REQUIRED for `sequential` challenge quests. For `multi` quests, MAY be present for display ordering but is not enforced. |
| radius_m | number | OPTIONAL | Override radius for this specific marker. If omitted, the quest-level `radius_m` applies. Min: 50. Max: 10,000. |
| reveal | string | OPTIONAL | Marker visibility override. One of: `"immediate"` or `"on_claim"`. If omitted, the quest-level `marker_reveal` applies (defaulting to `"immediate"`). When set to `"on_claim"`, the marker's `icon`, `name`, and `hint` SHOULD be hidden from the subject until they have physically visited the location and claimed the marker. Applications MAY generate or reveal marker assets dynamically at claim time (e.g., AI-generated illustrations, unlockable collectibles). |
| meta | object | OPTIONAL | Marker-level issuer-defined metadata. Open schema. Applications MAY use this for themed data (e.g., `{ "governor": "Sancho Panza" }` in an island quest, `{ "elevation": 2400 }` in a hiking app). |

### 19.4 Credential Relationship

The Quest Object defines the quest before it is played. Credentials prove the quest was completed. The relationship between the two levels is:

| Quest type | Markers | Credentials issued | Details |
| --- | --- | --- | --- |
| Single-marker | 1 marker | 1 credential | The marker credential IS the quest credential. One point, one JWT. |
| Multi-marker | 2+ markers | 1 per marker + 1 completion | Each marker found issues a waypoint credential (§18.2). When all markers are found, a completion credential is issued (§18.3). |

For **single-marker quests**, the credential's `quest.id` matches the quest's `id`, and the credential's `quest.name` matches the quest's `name`. There is no distinction between marker-level and quest-level — they are the same credential.

For **multi-marker quests**, each marker credential carries the chain claims defined in §18.2 (`quest.chain`, `quest.seq`, `quest.of`). The completion credential carries `quest.type: "chain_complete"` and the `quest.waypoints` array (§18.3). Both the individual marker credentials and the completion credential are independently verifiable, portable, and meaningful.

Applications SHOULD present both levels to the subject. Each marker credential represents an accomplishment worth celebrating. The completion credential represents the full journey.

### 19.5 Quest Expiration

Credentials are permanent records — they do not expire (§15.4). But the quest itself may have a limited window for participation. The protocol defines two expiration mechanisms:

#### 19.5.1 Absolute Expiration

The `expires_at` field is an ISO 8601 timestamp after which the quest is no longer claimable. The issuer MUST reject any claim attempt where the server's current time exceeds `expires_at`.

```json
{
  "id": "feria-de-la-mancha-2025",
  "name": "Feria de La Mancha — Busca las Islas",
  "expires_at": "2025-08-31T23:59:59Z",
  "markers": [...]
}
```

Use cases: seasonal events, limited-time promotions, festival tie-ins, time-boxed challenges.

#### 19.5.2 Relative Expiration

The `completion_window_days` field defines the maximum number of days a subject has to complete all markers in a multi-marker quest, counted from the moment they claim their first marker.

```json
{
  "id": "ruta-de-los-molinos",
  "name": "Ruta de los Molinos de Don Quijote",
  "challenge": "multi",
  "completion_window_days": 7,
  "markers": [...]
}
```

The issuer tracks when the subject claimed their first marker and MUST reject subsequent marker claims if the window has elapsed. The window applies per-subject — different subjects may start at different times.

Use cases: adding urgency to multi-marker quests, preventing indefinite partial completion, challenge-style quests.

#### 19.5.3 Combining Expiration

Both fields MAY be present on the same quest. When both are present, the stricter constraint applies. A quest with `expires_at: "2025-12-31"` and `completion_window_days: 7` means: you must finish within 7 days of starting AND before December 31 — whichever comes first.

Expiration is a quest-layer concern, not a credential-layer concern. Credentials issued before expiration remain permanently valid. A subject who completed 3 of 6 markers before the window closed keeps those 3 marker credentials forever — they simply cannot earn the remaining markers or the completion credential.

### 19.6 Snapshot Principle

When a subject begins a quest (claims their first marker), the issuer SHOULD record the quest definition at that moment. This frozen state is the subject's **snapshot** — the version of the quest they are playing.

Subsequent changes to the quest by the creator (moving a marker, renaming the quest, changing the radius, adding or removing markers) MUST NOT affect in-progress attempts. The credential MUST reflect the quest as it existed when the subject was participating.

The protocol does not mandate how snapshots are implemented. Issuers may copy the full Quest Object, store a version pointer, or use any mechanism that preserves the subject's view. The `version` field on the Quest Object supports this — the snapshot records which version the subject started with.

```
Creator publishes quest v1 (5 islands)
  → Sancho subscribes → snapshot: v1 (5 islands)
Creator updates quest to v2 (moves Isla de Cabrera, adds Isla de Dragonera)
  → Dulcinea subscribes → snapshot: v2 (6 islands)
  → Sancho continues playing v1 (5 islands, original locations)
```

This principle exists because a quest is a contract between the creator and the player. Changing the rules mid-game violates that contract. The snapshot ensures fairness.

> **Design Note:** This is analogous to academic course catalogs. A student who enrolls under the 2024 catalog completes the 2024 requirements, even if the university updates the catalog in 2025. The snapshot preserves the agreement at the time of enrollment.

### 19.7 Quest Discovery *(Future Extension)*

A standardized mechanism for quest discovery — allowing applications to advertise available quests to other applications, aggregators, and wallets — is planned for a future version of this specification. Discovery will build on the `audience` field defined in §20. Only quests with `audience: "public"` are candidates for open discovery. All other audience types MUST NOT be surfaced in public discovery feeds.

Until discovery is standardized, quest distribution is an application-layer concern. Issuers may share quests via direct links, QR codes, invite systems, or any mechanism that fits their audience and privacy requirements.

### 19.8 Quest Image Object

The `image` field on the Quest Object (§19.2) MAY be a simple URL string (for backward compatibility) or a structured QuestImage object that provides richer metadata:

```json
{
  "isShowable": true,
  "url": "https://sallyforth.quest/api/quest-header?name=Isla+Barataria&type=sequential&lat=38.9089&lng=1.4326&checkpoints=5&found=0",
  "alt": "Isla Barataria quest header",
  "source": "generated"
}
```

| Field | Type | Req | Description |
| --- | --- | --- | --- |
| isShowable | boolean | REQUIRED | Whether the image should be displayed to players. Set to `false` to suppress display (e.g., during moderation). |
| url | string | REQUIRED | Image URL. May be a CDN URL, external URL, or a relative URL to a generative endpoint. |
| alt | string | OPTIONAL | Accessibility alt text for the image. |
| source | string | OPTIONAL | How the image was created. One of: `upload` (user-uploaded photo), `library` (selected from a stock library), `ai` (AI-generated), `generated` (algorithmically generated from quest metadata). |

#### 19.8.1 Generative Header Images

When no custom image is provided, conforming applications SHOULD generate a deterministic header image from the quest's metadata. The reference implementation uses an OG-image endpoint (`/api/quest-header`) that produces unique abstract art based on the quest's geographic coordinates:

- **Color palette**: Derived from a hash of the quest's latitude and longitude, ensuring each location produces a distinct and consistent color scheme.
- **Shape style**: Varies by challenge type — elongated horizontal shapes for `sequential`, scattered circles for `multi`, and radial forms for `single`.
- **Text overlay**: Displays the quest name, challenge type badge, and progress indicator (e.g., "3/8 found").
- **Determinism**: The same quest always produces the same image, regardless of when or where it is rendered.

The generative header URL is captured in the quest snapshot (§19.6) at subscription time, ensuring the player's visual experience is preserved even if the quest is later edited.

---

## §20 Audience, Groups & Collaborative Play

### 20.1 Audience Model

Every quest has an intended audience that determines who can see, join, and play it. The `audience` field on the Quest Object (§19.2) declares this intent.

| Value | Label | Description |
| --- | --- | --- |
| `solo` | Personal | The creator is the only player. The quest is invisible to all other users. This is the default. |
| `group` | Group | Shared among a set of people who know each other in real life. Membership requires in-person copresence verification (§20.2). The group sees shared activity. |
| `org` | Organization | Published by an organization (company, school, nonprofit) to its members. Members subscribe and play independently. The organization receives only aggregate, anonymous metrics. |
| `public` | Public | Open to anyone. Discoverable. *(Reserved for future use — not yet implemented.)* |

**Design principle:** Audience scope only *expands* with explicit action. A `solo` quest cannot become `group` or `public` without the creator deliberately changing it. There is no implicit sharing, no algorithmic discovery, no "friends of friends" visibility.

### 20.2 Copresence Verification (Groups)

Group membership is gated by **copresence verification** — cryptographic proof that two people were physically in the same place at the same time. This is the antithesis of remote social sharing: you cannot join a group from your couch.

**Verification flow:**

1. The group admin (or any existing member, at the admin's discretion) opens the group invite screen on their device, which displays a QR code.
2. The joining user scans the QR code with their device's camera.
3. Both devices submit their GPS coordinates and a timestamp to the server.
4. The server validates that both coordinates are within a reasonable copresence radius (e.g., 50 meters) and that the timestamps are within a reasonable window (e.g., 60 seconds).
5. If validation passes, the joining user is added to the group. The membership record stores the copresence proof: both users' truncated coordinates, the timestamp, and a reference to the inviting member.
6. If validation fails, the join is rejected. No membership is created.

**There are no remote invites.** No share links, no "send to contact," no email invitations. You must be physically together to form a group. This is a deliberate design choice that prioritizes real-world relationships over digital convenience.

**The QR code serves two purposes:**
1. **Group association** — the code identifies which group the joining user will be added to.
2. **Copresence trigger** — scanning initiates the bilateral GPS check.

### 20.3 Group Play Modes

When `audience` is `group`, the `play_mode` field determines how the group's collective progress is tracked. Play mode is set by the quest creator and cannot be changed after any member begins playing.

| Value | Label | Description |
| --- | --- | --- |
| `individual` | Solo tracking | Each member plays independently. Their visits, progress, and credentials are entirely their own. The group provides social context (shared feed, comments) but not shared progress. Each member earns their own credential upon personal completion. |
| `together` | Team play | The group shares a single progress track. Any member's valid visit counts toward the group's collective progress. When the group completes the quest, every member receives a credential. The credential notes the group context (`quest.group`). |
| `divide` | Divide & conquer | Multi-marker quests only. Different members may claim different markers. The group must collectively cover all markers, but no individual member must visit them all. When all markers are covered, every member receives a credential listing which markers they personally visited. |

**Credential implications:**

- `individual`: Standard credential. No group claims. The group's existence is not recorded in the credential.
- `together`: Credential includes a `quest.group` claim with the group ID, group size, and the member's role. The credential attests that the *group* completed the quest, not necessarily that *this member* visited every location.
- `divide`: Credential includes `quest.group` with the same fields as `together`, plus a `markers_visited` array listing the marker IDs this specific member claimed. The credential attests both the group's collective completion and the individual's specific contribution.

### 20.4 User Rolodex

Each user accumulates a **rolodex** — a personal address book of every other user they have ever successfully copresence-verified with, across all groups. The rolodex is:

- **Private**: visible only to its owner. No other user, group, or organization can read another user's rolodex.
- **Append-only via copresence**: entries can only be added through successful QR-code copresence verification. There is no way to add someone remotely.
- **Cross-group**: a single copresence verification with another user adds them to the rolodex permanently, regardless of which group the verification occurred in.

The rolodex enables future features:
- **1:1 quest sharing**: send a quest directly to a specific person from your rolodex (without creating a group).
- **New group creation**: form a new group by selecting members from your existing rolodex (no new copresence verification required — they've already been verified).
- **Trust graph**: the rolodex forms a web of verified real-world relationships that can inform future trust and discovery mechanisms.

> **Privacy note:** The rolodex stores only the minimum information needed: the other user's app-scoped ID, a display name, and the timestamp/location of the most recent copresence verification. It does NOT store the other user's Clerk ID, email, phone number, or any other identifying information beyond what is visible in-app.

### 20.5 Organization Quests

Organization quests are published to a catalog visible to organization members. The privacy model is strict:

**What the organization CAN see:**
- Quest catalog (the quests it published)
- Aggregate metrics: total subscriber count per quest, total marker completions per quest, completion rate percentages
- These metrics are anonymous — counts only, no user identifiers attached

**What the organization CANNOT see:**
- Which specific members subscribed to which quests
- Individual member progress, visit history, or credentials
- Any mapping between a metric and a specific user
- Member rolodex or group membership information

**What the member experiences:**
- Browse the organization's quest catalog
- Subscribe to quests privately — the act of subscribing is not reported to the organization
- Play independently — no shared feed, no group progress, no social features within org quests
- Earn credentials that name the organization as context (`quest.org`) but are held privately by the member

**Play mode for org quests is always `individual`.** Organizations cannot mandate group play. If organization members want to play together, they must independently form a `group` and share the quest among themselves — the organization has no role in that process.

> **Design principle:** An organization is a *publisher*, not a *surveillor*. It creates quests and publishes them. It receives anonymous engagement metrics. It never learns who specifically is doing what. This is the opposite of typical corporate gamification platforms where management tracks individual employee participation.

### 20.6 Credential Claims for Audience Context

When a quest has a non-solo audience, the issued credential MAY include audience context in the `quest` claim object. This context is informational — it enriches the credential's provenance without compromising privacy.

| Claim | Type | Condition | Description |
| --- | --- | --- | --- |
| quest.audience | string | OPTIONAL | The quest's audience type at time of issuance: `solo`, `group`, `org`, `public`. |
| quest.play_mode | string | OPTIONAL | The group play mode, if applicable: `individual`, `together`, `divide`. |
| quest.group | object | OPTIONAL | Present when `audience` is `group` and `play_mode` is `together` or `divide`. |
| quest.group.id | string | conditional | Opaque group identifier (issuer-scoped, not globally meaningful). |
| quest.group.size | integer | conditional | Number of members in the group at time of issuance. |
| quest.group.role | string | conditional | This member's role: `admin` or `member`. |
| quest.group.markers_visited | string[] | conditional | For `divide` mode: marker IDs this member personally visited. |
| quest.org | string | OPTIONAL | Present when `audience` is `org`. Opaque organization identifier. Does NOT reveal the organization's name or any member information. |

> **Privacy invariant:** No credential ever contains information that would allow a third party to identify other members of a group or organization. Group IDs and org IDs are opaque, issuer-scoped identifiers. The credential proves *this subject's* participation in a group context, not who else was in the group.

### 20.7 Future Considerations

The following features are anticipated but not yet specified:

- **Public audience and quest discovery** (§19.7): Open quests discoverable by anyone. Requires content moderation, geographic safety review, and abuse prevention mechanisms.
- **1:1 quest sharing**: Sending a quest to a specific rolodex contact without creating a group. Requires a notification/inbox model.
- **Group activity feed**: Shared timeline of visits, comments, and photos within a group quest. Requires content storage and moderation.
- **Group admin controls**: Removing members, transferring admin role, archiving groups. Requires careful UX to prevent accidental data loss.
- **Cross-group quest duplication**: Starting a quest that another group is playing. Requires quest template sharing without exposing the original group's progress.

---

*Quest Protocol Specification v0.7 — Draft*