{
  "schema_version": "1.0",
  "baseline": "A-feature-flags-batch-4-readiness",
  "phase": "2b+ → 2c readiness",
  "updated_at": "2026-04-19",
  "owner": "session_a",
  "purpose_th": "JWKS integration readiness สำหรับ FF Service · ครอบคลุม fetch semantics · kid resolution · caching · rotation · error modes · relation กับ verified mode · honest gaps ก่อนไป 2c",
  "purpose_en": "JWKS integration readiness notes for FF Service. Covers fetch semantics, kid resolution, caching, rotation handling, error modes, relation to verified mode, and honest gaps before Phase 2c.",
  "honest_note": "No JWKS fetch code is wired in 2b+ runtime. This document prescribes the shape so Phase 2c integration has a conformable target. Anything here described as 'current_state: not_wired' MUST NOT be claimed as live.",
  "planned_endpoint": {
    "url": "https://auth.pattayatogether.internal/.well-known/jwks.json",
    "auth": "none (public JWKS)",
    "expected_content_type": "application/json",
    "expected_shape": {
      "keys": [
        {
          "kty": "RSA",
          "kid": "<key-id>",
          "use": "sig",
          "alg": "RS256",
          "n": "<modulus · base64url>",
          "e": "AQAB"
        }
      ]
    },
    "current_state": "not_wired · documented target only"
  },
  "fetch_semantics": {
    "transport": "HTTPS GET · TLS 1.2+ · certificate verify required in production",
    "timeout_ms": 3000,
    "retry_policy": "no retry on first boot · fail-safe deny → fallback to decode-only",
    "refresh_trigger": [
      "startup (cold fetch · required)",
      "kid lookup miss (stale cache · one retry only)",
      "periodic refresh (planned 1h · not implemented)",
      "403/404 on well-known path → log + alert + DO NOT retry tight loop"
    ],
    "current_state": "not_wired"
  },
  "kid_resolution": {
    "algorithm": [
      "1. Extract kid from JWT header (decode header portion only · no verify)",
      "2. Lookup kid in cached JWKS set",
      "3. On miss → one refresh attempt with 3s timeout",
      "4. On miss after refresh → signature_unverifiable · fall through to decode-only mode",
      "5. On hit → run alg-appropriate verifier (RS256 default)"
    ],
    "kid_required": true,
    "kid_missing_behaviour": "reject with token_malformed · do NOT attempt multi-key trial",
    "multi_key_support": false,
    "current_state": "not_wired"
  },
  "caching": {
    "in_process_cache": {
      "ttl_seconds": 3600,
      "max_entries": 16,
      "eviction": "LRU",
      "stampede_protection": "single-flight lock on refresh (planned · not wired)",
      "current_state": "not_wired"
    },
    "shared_cache": {
      "backend": "Redis",
      "key": "ptt:ff:jwks:current",
      "ttl_seconds": 3600,
      "relation": "secondary to in-process cache · see cache_invalidation_contract.json",
      "current_state": "not_wired"
    },
    "cold_boot_behaviour": "first request may block up to timeout_ms while JWKS fetch completes · subsequent requests hit cache"
  },
  "rotation_handling": {
    "graceful_rotation_window_seconds": 86400,
    "strategy": [
      "IdP publishes new kid alongside old kid for the rotation_window",
      "FF accepts both during overlap",
      "On kid miss for a token signed with retired kid → token_expired response · caller must refresh JWT"
    ],
    "overlap_detection": "jwks.keys array contains ≥2 entries during rotation · FF caches all",
    "current_state": "not_wired · documented strategy only"
  },
  "error_modes": [
    {
      "code": "jwks_unreachable",
      "http": 503,
      "meaning": "JWKS endpoint not reachable on startup · verify mode disabled · fallback to decode-only with warning=['jwks_unreachable']",
      "severity": "warn",
      "recovery": "retry on next refresh_trigger · not on every request"
    },
    {
      "code": "jwks_malformed",
      "http": 503,
      "meaning": "JWKS JSON parsed but keys array missing or invalid · verify mode disabled",
      "severity": "warn"
    },
    {
      "code": "kid_not_found",
      "http": 401,
      "meaning": "Token kid not in cached JWKS and refresh did not produce it · reject",
      "severity": "deny"
    },
    {
      "code": "signature_invalid",
      "http": 401,
      "meaning": "kid found but signature does not verify · reject immediately",
      "severity": "deny"
    },
    {
      "code": "algorithm_mismatch",
      "http": 401,
      "meaning": "Token alg header not in allowlist [RS256, RS384, RS512] · reject · NEVER accept 'none' or HS* with public key",
      "severity": "deny"
    }
  ],
  "config_requirements": {
    "env_vars": {
      "FF_JWKS_URL": "required in production · absence falls to decode-only",
      "FF_JWKS_CACHE_TTL_SECONDS": "default 3600",
      "FF_JWKS_FETCH_TIMEOUT_MS": "default 3000",
      "FF_JWT_EXPECTED_AUDIENCE": "pty-feature-flags",
      "FF_JWT_EXPECTED_ISSUER": "https://auth.pattayatogether.internal/",
      "FF_JWT_ALG_ALLOWLIST": "RS256,RS384,RS512 (comma-separated)",
      "FF_JWT_CLOCK_SKEW_SECONDS": "default 60",
      "FF_ALLOW_DEV_HEADERS": "MUST be false in production · default true in dev"
    },
    "secrets": "none · JWKS is public",
    "dependency": "requests (sync) or httpx (async · preferred with FastAPI)",
    "current_state": "none of these env vars are read by app.py today"
  },
  "relation_to_verification_mode": {
    "verified_mode_requires": [
      "FF_JWKS_URL set + reachable on startup",
      "Token kid resolves to a cached public key",
      "alg in allowlist",
      "signature verifies",
      "exp > now - clock_skew",
      "aud == FF_JWT_EXPECTED_AUDIENCE",
      "iss == FF_JWT_EXPECTED_ISSUER"
    ],
    "any_missing_drops_to": "decode-only mode if FF_ALLOW_DEV_HEADERS=true · rejects if FF_ALLOW_DEV_HEADERS=false",
    "see_also": "verification_mode_matrix.json for the full decision table"
  },
  "security_notes_honest": [
    "Public JWKS fetch has no auth · integrity relies on HTTPS cert verification",
    "Do NOT cache JWKS to disk · Redis TTL is the only persistence layer",
    "Do NOT accept alg='none' under any mode · algorithm_mismatch is mandatory deny",
    "HS256 is permitted ONLY in dev mode with a shared secret · never with a public key",
    "JWKS refresh must be single-flight to avoid thundering-herd on IdP",
    "Rate-limiting the JWKS endpoint is IdP responsibility · FF must not retry tight loops"
  ],
  "pair_with": {
    "verification_mode_matrix": "docs/runtime/feature-flags-service/verification_mode_matrix.json",
    "jwt_claim_examples": "docs/runtime/feature-flags-service/jwt_claim_examples.json",
    "jwt_notes_2b_plus": "docs/runtime/feature-flags-service/jwt_verification_notes.json",
    "cache_contract": "docs/runtime/feature-flags-service/cache_invalidation_contract.json",
    "admin_2b_contract": "docs/runtime/admin-control-plane-service/phase_2b_contract.json"
  }
}
