diff --git a/src/godotiq/license.py b/src/godotiq/license.py
new file mode 100644
index 0000000..ac4ab8b
--- /dev/null
+++ b/src/godotiq/license.py
@@ -0,0 +1,383 @@
+"""GodotIQ licensing — PRO tool gating with Polar.sh key validation.
+
+Community users can call PRO tools. The tool runs fully, then the result
+is discarded and replaced with a teaser string showing real counts.
+
+License sources (checked in order):
+1. GODOTIQ_DEV_KEY      — SHA-256 hashed dev key (replaces GODOTIQ_LICENSE=pro)
+2. GODOTIQ_LICENSE_KEY   — Polar.sh license key, validated via API
+3. Disk cache at ~/.config/godotiq/license_cache.json (24h TTL)
+4. Expired cache used as offline fallback when API is unreachable
+
+Sandbox / testing env vars:
+- GODOTIQ_POLAR_SANDBOX=true  — use Polar sandbox API endpoint
+- GODOTIQ_POLAR_ORG_ID        — override the default organization ID
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import logging
+import os
+import sys
+import time
+from pathlib import Path
+from typing import Any
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+# ── 13 PRO tools (intelligence & analysis) ────────────────────────────
+
+PRO_TOOLS: frozenset[str] = frozenset({
+    "godotiq_project_summary",
+    "godotiq_file_context",
+    "godotiq_dependency_graph",
+    "godotiq_validate",
+    "godotiq_signal_map",
+    "godotiq_trace_flow",
+    "godotiq_impact_check",
+    "godotiq_animation_audit",
+    "godotiq_asset_registry",
+    "godotiq_scene_map",
+    "godotiq_spatial_audit",
+    "godotiq_suggest_scale",
+    "godotiq_placement",
+})
+
+_UPGRADE_SUFFIX = " \u2192 upgrade: godotiq.com/pro"
+
+# SHA-256 hash of the dev key (passphrase never stored in source)
+_DEV_KEY_HASH = "854afcd070329eb87128469abeb9b919acfd001a6a67e0b42b42f32231a6b208"
+
+# ── Polar.sh configuration ────────────────────────────────────────────
+
+_POLAR_PROD_URL = "https://api.polar.sh/v1/customer-portal/license-keys/validate"
+_POLAR_SANDBOX_URL = "https://sandbox-api.polar.sh/v1/customer-portal/license-keys/validate"
+_POLAR_DEFAULT_ORG_ID = "a06a7d09-881c-430d-9775-e6a7529a0831"
+
+
+def _polar_validate_url() -> str:
+    """Return the Polar validation URL, using sandbox if GODOTIQ_POLAR_SANDBOX=true."""
+    if os.environ.get("GODOTIQ_POLAR_SANDBOX", "").lower() == "true":
+        return _POLAR_SANDBOX_URL
+    return _POLAR_PROD_URL
+
+
+def _polar_org_id() -> str:
+    """Return the Polar org ID, allowing override via GODOTIQ_POLAR_ORG_ID."""
+    return os.environ.get("GODOTIQ_POLAR_ORG_ID", "").strip() or _POLAR_DEFAULT_ORG_ID
+
+_CACHE_TTL_SECONDS = 86400  # 24 hours
+
+
+def _cache_dir() -> Path:
+    """Return the platform-appropriate cache directory."""
+    if sys.platform == "win32":
+        appdata = os.environ.get("APPDATA")
+        if appdata:
+            return Path(appdata) / "godotiq"
+    return Path.home() / ".config" / "godotiq"
+
+
+_CACHE_DIR = _cache_dir()
+_CACHE_FILE = _CACHE_DIR / "license_cache.json"
+
+
+# ── In-process license cache ──────────────────────────────────────────
+# Avoids repeated disk reads / API calls within a single server process.
+
+_license_status: bool | None = None
+
+
+def _reset_license_cache() -> None:
+    """Reset the in-process license cache.  For testing only."""
+    global _license_status
+    _license_status = None
+
+
+# ── License check ─────────────────────────────────────────────────────
+
+def is_pro() -> bool:
+    """Return True if the current environment has a valid PRO license.
+
+    Check order:
+    1. GODOTIQ_DEV_KEY (SHA-256 hashed dev key)
+    2. GODOTIQ_LICENSE_KEY validated against Polar.sh API
+    3. Disk cache (24h TTL)
+    4. Expired cache as offline fallback
+    """
+    global _license_status
+    if _license_status is not None:
+        return _license_status
+    _license_status = _check_license()
+    return _license_status
+
+
+def _check_license() -> bool:
+    """Run the full license validation chain."""
+    # 1. Dev key override (SHA-256 hashed)
+    dev_key = os.environ.get("GODOTIQ_DEV_KEY", "").strip()
+    if dev_key:
+        if hashlib.sha256(dev_key.encode("utf-8")).hexdigest() == _DEV_KEY_HASH:
+            return True
+        # Wrong dev key -- fall through to license key check
+
+    # 2. License key from env
+    key = os.environ.get("GODOTIQ_LICENSE_KEY", "").strip()
+    if not key:
+        return False
+
+    key_hash = _hash_key(key)
+
+    # 3. Check disk cache
+    cache = _read_cache()
+    if cache is not None and cache.get("key_hash") == key_hash:
+        age = time.time() - cache.get("validated_at", 0)
+        if age < _CACHE_TTL_SECONDS:
+            return cache.get("valid", False)
+
+    # 4. Validate against Polar API
+    try:
+        valid = _validate_polar(key)
+        _write_cache(key_hash, valid)
+        return valid
+    except Exception:
+        logger.debug("Polar API unreachable, falling back to cache")
+        # 5. Offline fallback — use expired cache if key matches
+        if cache is not None and cache.get("key_hash") == key_hash:
+            return cache.get("valid", False)
+        return False
+
+
+# ── Polar API validation ──────────────────────────────────────────────
+
+def _validate_polar(key: str) -> bool:
+    """Validate a license key against the Polar.sh API.
+
+    POST https://api.polar.sh/v1/customer-portal/license-keys/validate
+    Body: {"key": key, "organization_id": org_id}
+    No auth header needed.
+    Valid if: HTTP 200 AND response.status == "granted".
+    """
+    url = _polar_validate_url()
+    org = _polar_org_id()
+
+    # Mask the key: show first 10 and last 4 chars
+    masked = key[:10] + "..." + key[-4:] if len(key) > 14 else key
+
+    resp = httpx.post(
+        url,
+        json={"key": key, "organization_id": org},
+        timeout=10,
+    )
+
+    if resp.status_code != 200:
+        logger.info("License: key=%s → denied (HTTP %s)", masked, resp.status_code)
+        logger.debug("License detail: org=%s url=%s", org, url)
+        return False
+
+    data = resp.json()
+    status = data.get("status", "unknown")
+    result = "granted" if status == "granted" else f"denied ({status})"
+    logger.info("License: key=%s → %s", masked, result)
+    logger.debug("License detail: org=%s url=%s", org, url)
+    return status == "granted"
+
+
+# ── Disk cache ────────────────────────────────────────────────────────
+
+def _hash_key(key: str) -> str:
+    """SHA-256 hash of the license key (never store the key in plaintext)."""
+    return hashlib.sha256(key.encode("utf-8")).hexdigest()
+
+
+def _read_cache() -> dict | None:
+    """Read license cache from disk.  Returns None on any error."""
+    try:
+        if not _CACHE_FILE.exists():
+            return None
+        data = json.loads(_CACHE_FILE.read_text(encoding="utf-8"))
+        if not isinstance(data, dict):
+            return None
+        return data
+    except Exception:
+        return None
+
+
+def _write_cache(key_hash: str, valid: bool) -> None:
+    """Write license validation result to disk cache."""
+    try:
+        _CACHE_DIR.mkdir(parents=True, exist_ok=True)
+        cache = {
+            "key_hash": key_hash,
+            "valid": valid,
+            "validated_at": time.time(),
+        }
+        _CACHE_FILE.write_text(json.dumps(cache), encoding="utf-8")
+    except Exception:
+        logger.debug("Failed to write license cache", exc_info=True)
+
+
+# ── Teaser builders ───────────────────────────────────────────────────
+# Each takes the full tool result dict and returns a human-readable
+# summary string.  Keys are accessed with .get() so missing data
+# degrades gracefully.
+
+def _teaser_project_summary(r: dict) -> str:
+    counts = r.get("counts", {})
+    scripts = counts.get("scripts", 0)
+    scenes = counts.get("scenes", 0)
+    autoloads = len(r.get("autoloads", []))
+    return f"{scripts} scripts, {scenes} scenes, {autoloads} autoloads analyzed"
+
+
+def _teaser_file_context(r: dict) -> str:
+    api = r.get("public_api", {})
+    funcs = len(api.get("functions", []))
+    signals = len(r.get("signals_defined", []))
+    depended = len(r.get("depended_by", []))
+    return f"{funcs} public functions, {signals} signals, depended by {depended} scripts"
+
+
+def _teaser_dependency_graph(r: dict) -> str:
+    emits = len(r.get("emits_signals", []))
+    refs = len(r.get("referenced_by", []))
+    impact = r.get("impact_rating", "unknown")
+    return f"{emits} signals emitted, {refs} reverse refs, impact: {impact}"
+
+
+def _teaser_validate(r: dict) -> str:
+    total = r.get("total_issues", 0)
+    by_sev = r.get("by_severity", {})
+    errors = by_sev.get("error", 0)
+    warnings = by_sev.get("warning", 0)
+    info = by_sev.get("info", 0)
+    return f"{total} issues: {errors} errors, {warnings} warnings, {info} info"
+
+
+def _teaser_signal_map(r: dict) -> str:
+    defined = r.get("total_signals_defined", 0)
+    connections = r.get("total_connections", 0)
+    orphans = len(r.get("orphans", []))
+    return f"{defined} signals, {connections} connections, {orphans} orphans"
+
+
+def _teaser_trace_flow(r: dict) -> str:
+    steps = r.get("total_steps", 0)
+    files = r.get("files_involved", 0)
+    failures = len(r.get("failure_points", []))
+    return f"{steps} steps across {files} files, {failures} failure points"
+
+
+def _teaser_impact_check(r: dict) -> str:
+    risk = r.get("risk", "unknown")
+    affected = r.get("total_files_affected", 0)
+    callers = len(r.get("callers", []))
+    return f"risk: {risk}, {affected} files affected, {callers} callers found"
+
+
+def _teaser_animation_audit(r: dict) -> str:
+    anims = r.get("total_animations", 0)
+    total = r.get("total_issues", 0)
+    by_sev = r.get("by_severity", {})
+    critical = by_sev.get("critical", 0)
+    warnings = by_sev.get("warning", 0)
+    return f"{anims} animations scanned, {total} issues: {critical} critical, {warnings} warnings"
+
+
+def _teaser_asset_registry(r: dict) -> str:
+    total = r.get("total_assets", 0)
+    unused = r.get("unused_count", 0)
+    categories = len(r.get("by_category", {}))
+    return f"{total} assets cataloged, {unused} unused ({categories} categories)"
+
+
+def _teaser_scene_map(r: dict) -> str:
+    nodes = r.get("total_nodes", 0)
+    summary = r.get("spatial_summary", {})
+    bounds = summary.get("bounds", {})
+    bmin = bounds.get("min", [0, 0, 0])
+    bmax = bounds.get("max", [0, 0, 0])
+    w = abs(bmax[0] - bmin[0]) if len(bmin) >= 3 and len(bmax) >= 3 else 0
+    h = abs(bmax[1] - bmin[1]) if len(bmin) >= 3 and len(bmax) >= 3 else 0
+    d = abs(bmax[2] - bmin[2]) if len(bmin) >= 3 and len(bmax) >= 3 else 0
+    return f"{nodes} nodes mapped, bounds: {w:.0f}x{h:.0f}x{d:.0f}m"
+
+
+def _teaser_spatial_audit(r: dict) -> str:
+    total = r.get("total_issues", 0)
+    by_sev = r.get("by_severity", {})
+    warnings = by_sev.get("warning", 0)
+    info = by_sev.get("info", 0)
+    checks = len(r.get("checks_run", []))
+    return f"{total} issues: {warnings} warnings, {info} info across {checks} checks"
+
+
+def _teaser_suggest_scale(r: dict) -> str:
+    refs = len(r.get("reference_models", r.get("references", [])))
+    tier = r.get("match_tier", "unknown")
+    return f"{refs} reference models analyzed, match: {tier}"
+
+
+def _teaser_placement(r: dict) -> str:
+    evaluated = r.get("candidates_evaluated", 0)
+    suggestions = len(r.get("suggestions", []))
+    zones = len(r.get("excluded_zones", []))
+    return f"{evaluated} positions evaluated, {suggestions} valid, {zones} excluded zones"
+
+
+_TEASER_BUILDERS: dict[str, Any] = {
+    "godotiq_project_summary": _teaser_project_summary,
+    "godotiq_file_context": _teaser_file_context,
+    "godotiq_dependency_graph": _teaser_dependency_graph,
+    "godotiq_validate": _teaser_validate,
+    "godotiq_signal_map": _teaser_signal_map,
+    "godotiq_trace_flow": _teaser_trace_flow,
+    "godotiq_impact_check": _teaser_impact_check,
+    "godotiq_animation_audit": _teaser_animation_audit,
+    "godotiq_asset_registry": _teaser_asset_registry,
+    "godotiq_scene_map": _teaser_scene_map,
+    "godotiq_spatial_audit": _teaser_spatial_audit,
+    "godotiq_suggest_scale": _teaser_suggest_scale,
+    "godotiq_placement": _teaser_placement,
+}
+
+
+# ── Gate function ─────────────────────────────────────────────────────
+
+def gate_pro(tool_name: str, result: dict) -> dict:
+    """Gate a PRO tool result.  Returns result as-is for PRO users.
+
+    For Community users, discards the full result and returns a teaser
+    dict with real counts extracted from the analysis.
+
+    Error results (containing "error" key) always pass through ungated
+    so Community users still see actionable error messages.
+    """
+    # Not a PRO tool — pass through
+    if tool_name not in PRO_TOOLS:
+        return result
+
+    # PRO license — pass through
+    if is_pro():
+        return result
+
+    # Error results — pass through (community users need error messages)
+    if "error" in result:
+        return result
+
+    # Community user calling PRO tool — build teaser
+    builder = _TEASER_BUILDERS.get(tool_name)
+    if builder is None:
+        teaser = f"Analysis complete{_UPGRADE_SUFFIX}"
+    else:
+        teaser = builder(result) + _UPGRADE_SUFFIX
+
+    return {
+        "tier": "community",
+        "tool": tool_name,
+        "teaser": teaser,
+    }
diff --git a/tests/test_license.py b/tests/test_license.py
new file mode 100644
index 0000000..8f3940a
--- /dev/null
+++ b/tests/test_license.py
@@ -0,0 +1,611 @@
+"""Tests for GodotIQ PRO licensing gate with Polar.sh validation."""
+
+from __future__ import annotations
+
+import json
+import time
+
+import pytest
+
+from godotiq.license import (
+    PRO_TOOLS,
+    _CACHE_TTL_SECONDS,
+    _DEV_KEY_HASH,
+    _POLAR_DEFAULT_ORG_ID,
+    _POLAR_PROD_URL,
+    _POLAR_SANDBOX_URL,
+    _TEASER_BUILDERS,
+    _UPGRADE_SUFFIX,
+    _hash_key,
+    _polar_org_id,
+    _polar_validate_url,
+    _reset_license_cache,
+    gate_pro,
+    is_pro,
+)
+
+# Dev passphrase for testing (hash is hardcoded in license.py as _DEV_KEY_HASH)
+_TEST_DEV_KEY = "D6QNeuMndkUOy8Ic6uNkW3rwi9ZOOi8N0T2lz88wxXw"
+
+
+@pytest.fixture(autouse=True)
+def _clean_license_state(monkeypatch):
+    """Reset in-process license cache and clear env vars before each test."""
+    _reset_license_cache()
+    monkeypatch.delenv("GODOTIQ_LICENSE", raising=False)
+    monkeypatch.delenv("GODOTIQ_LICENSE_KEY", raising=False)
+    monkeypatch.delenv("GODOTIQ_DEV_KEY", raising=False)
+    monkeypatch.delenv("GODOTIQ_POLAR_SANDBOX", raising=False)
+    monkeypatch.delenv("GODOTIQ_POLAR_ORG_ID", raising=False)
+    yield
+    _reset_license_cache()
+
+
+@pytest.fixture
+def cache_dir(tmp_path, monkeypatch):
+    """Redirect license cache to a temp directory."""
+    cache_file = tmp_path / "license_cache.json"
+    import godotiq.license as lic
+    monkeypatch.setattr(lic, "_CACHE_FILE", cache_file)
+    monkeypatch.setattr(lic, "_CACHE_DIR", tmp_path)
+    return tmp_path, cache_file
+
+
+@pytest.fixture
+def mock_polar_valid(monkeypatch):
+    """Mock _validate_polar to return True (valid key)."""
+    import godotiq.license as lic
+    monkeypatch.setattr(lic, "_validate_polar", lambda key: True)
+
+
+@pytest.fixture
+def mock_polar_invalid(monkeypatch):
+    """Mock _validate_polar to return False (invalid key)."""
+    import godotiq.license as lic
+    monkeypatch.setattr(lic, "_validate_polar", lambda key: False)
+
+
+@pytest.fixture
+def mock_polar_unreachable(monkeypatch):
+    """Mock _validate_polar to raise (API unreachable)."""
+    import godotiq.license as lic
+
+    def _unreachable(key):
+        raise ConnectionError("Polar API unreachable")
+
+    monkeypatch.setattr(lic, "_validate_polar", _unreachable)
+
+
+# ── is_pro() — dev override ──────────────────────────────────────────
+
+
+class TestIsProDevOverride:
+    def test_old_license_pro_bypass_removed(self, monkeypatch):
+        """GODOTIQ_LICENSE=pro no longer grants PRO access (regression test)."""
+        monkeypatch.setenv("GODOTIQ_LICENSE", "pro")
+        assert is_pro() is False
+
+    def test_old_license_pro_case_insensitive_removed(self, monkeypatch):
+        """GODOTIQ_LICENSE=PRO no longer grants PRO access."""
+        monkeypatch.setenv("GODOTIQ_LICENSE", "PRO")
+        assert is_pro() is False
+
+    def test_dev_override_ignores_invalid(self, monkeypatch):
+        monkeypatch.setenv("GODOTIQ_LICENSE", "community")
+        assert is_pro() is False
+
+    def test_community_when_no_env(self):
+        assert is_pro() is False
+
+
+# ── is_pro() — Polar key validation ──────────────────────────────────
+
+
+class TestIsProPolarValidation:
+    def test_valid_key_returns_true(self, monkeypatch, mock_polar_valid, cache_dir):
+        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "POLAR-XXXXX-XXXXX")
+        assert is_pro() is True
+
+    def test_invalid_key_returns_false(self, monkeypatch, mock_polar_invalid, cache_dir):
+        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "POLAR-INVALID-KEY")
+        assert is_pro() is False
+
+    def test_empty_key_returns_false(self, monkeypatch, cache_dir):
+        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "")
+        assert is_pro() is False
+
+    def test_whitespace_key_returns_false(self, monkeypatch, cache_dir):
+        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "   ")
+        assert is_pro() is False
+
+    def test_no_key_returns_false(self, cache_dir):
+        assert is_pro() is False
+
+    def test_dev_key_skips_polar(self, monkeypatch, mock_polar_unreachable, cache_dir):
+        """Dev key should work even if Polar API would fail."""
+        monkeypatch.setenv("GODOTIQ_DEV_KEY", _TEST_DEV_KEY)
+        assert is_pro() is True
+
+
+# ── Disk cache ────────────────────────────────────────────────────────
+
+
+class TestLicenseCache:
+    def test_valid_result_cached_to_disk(self, monkeypatch, mock_polar_valid, cache_dir):
+        _, cache_file = cache_dir
+        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "POLAR-VALID-KEY")
+        assert is_pro() is True
+        # Cache file should exist
+        assert cache_file.exists()
+        data = json.loads(cache_file.read_text())
+        assert data["valid"] is True
+        assert data["key_hash"] == _hash_key("POLAR-VALID-KEY")
+
+    def test_invalid_result_cached_to_disk(self, monkeypatch, mock_polar_invalid, cache_dir):
+        _, cache_file = cache_dir
+        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "POLAR-BAD-KEY")
+        assert is_pro() is False
+        assert cache_file.exists()
+        data = json.loads(cache_file.read_text())
+        assert data["valid"] is False
+
+    def test_fresh_cache_skips_api(self, monkeypatch, mock_polar_unreachable, cache_dir):
+        """Fresh cache (< 24h) should be used without hitting the API."""
+        _, cache_file = cache_dir
+        key = "POLAR-CACHED-KEY"
+        # Pre-populate fresh cache
+        cache = {
+            "key_hash": _hash_key(key),
+            "valid": True,
+            "validated_at": time.time(),  # now = fresh
+        }
+        cache_file.write_text(json.dumps(cache))
+        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", key)
+        # API is unreachable but cache is fresh — should still return True
+        assert is_pro() is True
+
+    def test_expired_cache_triggers_api(self, monkeypatch, mock_polar_valid, cache_dir):
+        """Expired cache should trigger API re-validation."""
+        _, cache_file = cache_dir
+        key = "POLAR-EXPIRED-KEY"
+        # Pre-populate expired cache (invalid result, 25h ago)
+        cache = {
+            "key_hash": _hash_key(key),
+            "valid": False,
+            "validated_at": time.time() - _CACHE_TTL_SECONDS - 3600,
+        }
+        cache_file.write_text(json.dumps(cache))
+        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", key)
+        # API says valid → should override expired cache
+        assert is_pro() is True
+
+    def test_wrong_key_hash_triggers_api(self, monkeypatch, mock_polar_valid, cache_dir):
+        """Cache for a different key should not be used."""
+        _, cache_file = cache_dir
+        # Cache for a different key
+        cache = {
+            "key_hash": _hash_key("POLAR-OTHER-KEY"),
+            "valid": True,
+            "validated_at": time.time(),
+        }
+        cache_file.write_text(json.dumps(cache))
+        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "POLAR-NEW-KEY")
+        # API is called for the new key
+        assert is_pro() is True
+
+    def test_missing_cache_file_ok(self, monkeypatch, mock_polar_valid, cache_dir):
+        _, cache_file = cache_dir
+        assert not cache_file.exists()
+        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "POLAR-FIRST-TIME")
+        assert is_pro() is True
+
+    def test_corrupt_cache_triggers_api(self, monkeypatch, mock_polar_valid, cache_dir):
+        _, cache_file = cache_dir
+        cache_file.write_text("not json!")
+        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "POLAR-CORRUPT")
+        assert is_pro() is True
+
+
+# ── Offline fallback ──────────────────────────────────────────────────
+
+
+class TestOfflineFallback:
+    def test_api_down_uses_expired_cache(self, monkeypatch, mock_polar_unreachable, cache_dir):
+        """When API is unreachable, expired cache should serve as fallback."""
+        _, cache_file = cache_dir
+        key = "POLAR-OFFLINE-KEY"
+        # Expired but valid cache
+        cache = {
+            "key_hash": _hash_key(key),
+            "valid": True,
+            "validated_at": time.time() - _CACHE_TTL_SECONDS - 7200,
+        }
+        cache_file.write_text(json.dumps(cache))
+        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", key)
+        assert is_pro() is True
+
+    def test_api_down_no_cache_returns_false(self, monkeypatch, mock_polar_unreachable, cache_dir):
+        """No cache + API unreachable = community."""
+        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "POLAR-NO-CACHE")
+        assert is_pro() is False
+
+    def test_api_down_wrong_cache_returns_false(self, monkeypatch, mock_polar_unreachable, cache_dir):
+        """Expired cache for different key + API down = community."""
+        _, cache_file = cache_dir
+        cache = {
+            "key_hash": _hash_key("POLAR-DIFFERENT"),
+            "valid": True,
+            "validated_at": time.time() - _CACHE_TTL_SECONDS - 3600,
+        }
+        cache_file.write_text(json.dumps(cache))
+        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "POLAR-MINE")
+        assert is_pro() is False
+
+
+# ── In-process cache ──────────────────────────────────────────────────
+
+
+class TestInProcessCache:
+    def test_result_cached_in_memory(self, monkeypatch, cache_dir):
+        """Second call should use in-process cache, not re-validate."""
+        import godotiq.license as lic
+        call_count = 0
+
+        def counting_validate(key):
+            nonlocal call_count
+            call_count += 1
+            return True
+
+        monkeypatch.setattr(lic, "_validate_polar", counting_validate)
+        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "POLAR-MEMORY-TEST")
+        assert is_pro() is True
+        assert call_count == 1
+        # Second call uses in-process cache — no additional validation
+        assert is_pro() is True
+        assert call_count == 1  # still 1, no re-validation
+
+    def test_reset_clears_cache(self, monkeypatch, mock_polar_valid, cache_dir):
+        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "POLAR-RESET-TEST")
+        assert is_pro() is True
+        _reset_license_cache()
+        # After clearing env + cache, should be community
+        monkeypatch.delenv("GODOTIQ_LICENSE_KEY")
+        assert is_pro() is False
+
+
+# ── Sandbox / org ID configuration ────────────────────────────────────
+
+
+class TestPolarSandboxConfig:
+    def test_default_url_is_production(self):
+        assert _polar_validate_url() == _POLAR_PROD_URL
+
+    def test_sandbox_true_uses_sandbox_url(self, monkeypatch):
+        monkeypatch.setenv("GODOTIQ_POLAR_SANDBOX", "true")
+        assert _polar_validate_url() == _POLAR_SANDBOX_URL
+
+    def test_sandbox_true_case_insensitive(self, monkeypatch):
+        monkeypatch.setenv("GODOTIQ_POLAR_SANDBOX", "True")
+        assert _polar_validate_url() == _POLAR_SANDBOX_URL
+
+    def test_sandbox_false_uses_prod_url(self, monkeypatch):
+        monkeypatch.setenv("GODOTIQ_POLAR_SANDBOX", "false")
+        assert _polar_validate_url() == _POLAR_PROD_URL
+
+    def test_default_org_id(self):
+        assert _polar_org_id() == _POLAR_DEFAULT_ORG_ID
+
+    def test_custom_org_id(self, monkeypatch):
+        monkeypatch.setenv("GODOTIQ_POLAR_ORG_ID", "my-sandbox-org")
+        assert _polar_org_id() == "my-sandbox-org"
+
+    def test_empty_org_id_falls_back_to_default(self, monkeypatch):
+        monkeypatch.setenv("GODOTIQ_POLAR_ORG_ID", "")
+        assert _polar_org_id() == _POLAR_DEFAULT_ORG_ID
+
+    def test_whitespace_org_id_falls_back_to_default(self, monkeypatch):
+        monkeypatch.setenv("GODOTIQ_POLAR_ORG_ID", "   ")
+        assert _polar_org_id() == _POLAR_DEFAULT_ORG_ID
+
+
+# ── PRO_TOOLS set ─────────────────────────────────────────────────────
+
+
+class TestProToolsSet:
+    def test_contains_13_tools(self):
+        assert len(PRO_TOOLS) == 13
+
+    def test_all_prefixed(self):
+        for name in PRO_TOOLS:
+            assert name.startswith("godotiq_")
+
+    def test_known_pro_tools(self):
+        expected = {
+            "godotiq_project_summary",
+            "godotiq_file_context",
+            "godotiq_dependency_graph",
+            "godotiq_validate",
+            "godotiq_signal_map",
+            "godotiq_trace_flow",
+            "godotiq_impact_check",
+            "godotiq_animation_audit",
+            "godotiq_asset_registry",
+            "godotiq_scene_map",
+            "godotiq_spatial_audit",
+            "godotiq_suggest_scale",
+            "godotiq_placement",
+        }
+        assert PRO_TOOLS == expected
+
+    def test_free_tools_not_in_set(self):
+        free = [
+            "godotiq_ping",
+            "godotiq_screenshot",
+            "godotiq_node_ops",
+            "godotiq_exec",
+            "godotiq_run",
+            "godotiq_animation_info",
+            "godotiq_verify_motion",
+        ]
+        for name in free:
+            assert name not in PRO_TOOLS
+
+
+# ── Teaser builders ───────────────────────────────────────────────────
+
+# Mock result dicts matching each tool's actual return shape.
+
+_MOCK_RESULTS: dict[str, dict] = {
+    "godotiq_project_summary": {
+        "counts": {"scripts": 42, "scenes": 18, "assets": 200},
+        "autoloads": ["GameManager", "AudioManager", "SaveManager"],
+    },
+    "godotiq_file_context": {
+        "public_api": {"functions": [{"name": "f1"}, {"name": "f2"}]},
+        "signals_defined": ["sig1", "sig2", "sig3"],
+        "depended_by": ["a.gd", "b.gd"],
+    },
+    "godotiq_dependency_graph": {
+        "emits_signals": [{"signal_name": "s1"}, {"signal_name": "s2"}],
+        "referenced_by": ["x.gd", "y.gd", "z.gd"],
+        "impact_rating": "high",
+    },
+    "godotiq_validate": {
+        "total_issues": 14,
+        "by_severity": {"error": 0, "warning": 6, "info": 8},
+    },
+    "godotiq_signal_map": {
+        "total_signals_defined": 23,
+        "total_connections": 47,
+        "orphans": [{"name": "orphan1"}, {"name": "orphan2"}, {"name": "orphan3"}],
+    },
+    "godotiq_trace_flow": {
+        "total_steps": 8,
+        "files_involved": 6,
+        "failure_points": [{"step": 2}, {"step": 5}],
+    },
+    "godotiq_impact_check": {
+        "risk": "high",
+        "total_files_affected": 5,
+        "callers": [{"file": "a.gd"}, {"file": "b.gd"}, {"file": "c.gd"}],
+    },
+    "godotiq_animation_audit": {
+        "total_animations": 36,
+        "total_issues": 5,
+        "by_severity": {"critical": 1, "warning": 3, "info": 1},
+    },
+    "godotiq_asset_registry": {
+        "total_assets": 184,
+        "unused_count": 23,
+        "by_category": {"models": 30, "textures": 80, "audio": 20, "scenes": 18, "scripts": 30, "other": 6},
+    },
+    "godotiq_scene_map": {
+        "total_nodes": 47,
+        "spatial_summary": {
+            "bounds": {"min": [-6, 0, -4], "max": [6, 4, 4]},
+        },
+    },
+    "godotiq_spatial_audit": {
+        "total_issues": 7,
+        "by_severity": {"warning": 4, "info": 3},
+        "checks_run": ["floating_objects", "scale_mismatch", "z_fighting",
+                       "empty_markers", "overlapping", "extreme_positions"],
+    },
+    "godotiq_suggest_scale": {
+        "reference_models": [{"name": "m1"}, {"name": "m2"}, {"name": "m3"}],
+        "match_tier": "same_directory",
+    },
+    "godotiq_placement": {
+        "candidates_evaluated": 48,
+        "suggestions": [{"pos": [1, 0, 1]}, {"pos": [2, 0, 2]}, {"pos": [3, 0, 3]}],
+        "excluded_zones": [{"reason": "door"}, {"reason": "wall"}],
+    },
+}
+
+
+class TestTeaserBuilders:
+    """Each teaser builder extracts real counts and produces a readable string."""
+
+    def test_all_pro_tools_have_builders(self):
+        for tool_name in PRO_TOOLS:
+            assert tool_name in _TEASER_BUILDERS, f"Missing teaser builder for {tool_name}"
+
+    @pytest.mark.parametrize("tool_name", sorted(PRO_TOOLS))
+    def test_teaser_is_string(self, tool_name):
+        builder = _TEASER_BUILDERS[tool_name]
+        result = builder(_MOCK_RESULTS[tool_name])
+        assert isinstance(result, str)
+        assert len(result) > 10  # not trivially empty
+
+    def test_project_summary_teaser(self):
+        t = _TEASER_BUILDERS["godotiq_project_summary"](_MOCK_RESULTS["godotiq_project_summary"])
+        assert "42 scripts" in t
+        assert "18 scenes" in t
+        assert "3 autoloads" in t
+
+    def test_file_context_teaser(self):
+        t = _TEASER_BUILDERS["godotiq_file_context"](_MOCK_RESULTS["godotiq_file_context"])
+        assert "2 public functions" in t
+        assert "3 signals" in t
+        assert "2 scripts" in t
+
+    def test_dependency_graph_teaser(self):
+        t = _TEASER_BUILDERS["godotiq_dependency_graph"](_MOCK_RESULTS["godotiq_dependency_graph"])
+        assert "2 signals emitted" in t
+        assert "3 reverse refs" in t
+        assert "high" in t
+
+    def test_validate_teaser(self):
+        t = _TEASER_BUILDERS["godotiq_validate"](_MOCK_RESULTS["godotiq_validate"])
+        assert "14 issues" in t
+        assert "0 errors" in t
+        assert "6 warnings" in t
+
+    def test_signal_map_teaser(self):
+        t = _TEASER_BUILDERS["godotiq_signal_map"](_MOCK_RESULTS["godotiq_signal_map"])
+        assert "23 signals" in t
+        assert "47 connections" in t
+        assert "3 orphans" in t
+
+    def test_trace_flow_teaser(self):
+        t = _TEASER_BUILDERS["godotiq_trace_flow"](_MOCK_RESULTS["godotiq_trace_flow"])
+        assert "8 steps" in t
+        assert "6 files" in t
+        assert "2 failure points" in t
+
+    def test_impact_check_teaser(self):
+        t = _TEASER_BUILDERS["godotiq_impact_check"](_MOCK_RESULTS["godotiq_impact_check"])
+        assert "risk: high" in t
+        assert "5 files" in t
+        assert "3 callers" in t
+
+    def test_animation_audit_teaser(self):
+        t = _TEASER_BUILDERS["godotiq_animation_audit"](_MOCK_RESULTS["godotiq_animation_audit"])
+        assert "36 animations" in t
+        assert "1 critical" in t
+        assert "3 warnings" in t
+
+    def test_asset_registry_teaser(self):
+        t = _TEASER_BUILDERS["godotiq_asset_registry"](_MOCK_RESULTS["godotiq_asset_registry"])
+        assert "184 assets" in t
+        assert "23 unused" in t
+        assert "6 categories" in t
+
+    def test_scene_map_teaser(self):
+        t = _TEASER_BUILDERS["godotiq_scene_map"](_MOCK_RESULTS["godotiq_scene_map"])
+        assert "47 nodes" in t
+        assert "12x4x8m" in t
+
+    def test_spatial_audit_teaser(self):
+        t = _TEASER_BUILDERS["godotiq_spatial_audit"](_MOCK_RESULTS["godotiq_spatial_audit"])
+        assert "7 issues" in t
+        assert "4 warnings" in t
+        assert "6 checks" in t
+
+    def test_suggest_scale_teaser(self):
+        t = _TEASER_BUILDERS["godotiq_suggest_scale"](_MOCK_RESULTS["godotiq_suggest_scale"])
+        assert "3 reference models" in t
+        assert "same_directory" in t
+
+    def test_placement_teaser(self):
+        t = _TEASER_BUILDERS["godotiq_placement"](_MOCK_RESULTS["godotiq_placement"])
+        assert "48 positions" in t
+        assert "3 valid" in t
+        assert "2 excluded" in t
+
+    def test_builder_handles_empty_result(self):
+        """All builders degrade gracefully on empty/missing keys."""
+        for tool_name, builder in _TEASER_BUILDERS.items():
+            result = builder({})
+            assert isinstance(result, str)
+
+
+# ── gate_pro() ────────────────────────────────────────────────────────
+
+
+class TestGatePro:
+    def test_pro_user_gets_full_result(self, monkeypatch):
+        monkeypatch.setenv("GODOTIQ_DEV_KEY", _TEST_DEV_KEY)
+        result = {"total_issues": 5, "issues": [1, 2, 3, 4, 5]}
+        assert gate_pro("godotiq_validate", result) is result
+
+    def test_community_user_gets_teaser(self):
+        result = _MOCK_RESULTS["godotiq_validate"]
+        gated = gate_pro("godotiq_validate", result)
+        assert gated["tier"] == "community"
+        assert gated["tool"] == "godotiq_validate"
+        assert "14 issues" in gated["teaser"]
+        assert "godotiq.com/pro" in gated["teaser"]
+
+    def test_community_error_passes_through(self):
+        error_result = {"error": "No Godot project loaded"}
+        assert gate_pro("godotiq_validate", error_result) is error_result
+
+    def test_free_tool_always_passes_through(self):
+        result = {"status": "ok"}
+        assert gate_pro("godotiq_ping", result) is result
+
+    def test_teaser_has_upgrade_suffix(self):
+        result = _MOCK_RESULTS["godotiq_trace_flow"]
+        gated = gate_pro("godotiq_trace_flow", result)
+        assert gated["teaser"].endswith("godotiq.com/pro")
+
+    @pytest.mark.parametrize("tool_name", sorted(PRO_TOOLS))
+    def test_all_pro_tools_gated_for_community(self, tool_name):
+        result = _MOCK_RESULTS[tool_name]
+        gated = gate_pro(tool_name, result)
+        assert gated["tier"] == "community"
+        assert "teaser" in gated
+
+    @pytest.mark.parametrize("tool_name", sorted(PRO_TOOLS))
+    def test_all_pro_tools_pass_for_pro(self, monkeypatch, tool_name):
+        monkeypatch.setenv("GODOTIQ_DEV_KEY", _TEST_DEV_KEY)
+        result = _MOCK_RESULTS[tool_name]
+        assert gate_pro(tool_name, result) is result
+
+    def test_polar_key_unlocks_pro_tools(self, monkeypatch, mock_polar_valid, cache_dir):
+        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "POLAR-REAL-KEY")
+        result = _MOCK_RESULTS["godotiq_validate"]
+        assert gate_pro("godotiq_validate", result) is result
+
+
+# ── Dev key hashing ──────────────────────────────────────────────────
+
+
+class TestDevKeyHash:
+    """Tests for SHA-256 hashed dev key replacing plaintext GODOTIQ_LICENSE=pro bypass."""
+
+    def test_dev_key_hash_is_valid_sha256(self):
+        """_DEV_KEY_HASH constant is a valid 64-char hex string."""
+        assert len(_DEV_KEY_HASH) == 64
+        int(_DEV_KEY_HASH, 16)  # raises if not valid hex
+
+    def test_correct_dev_key_grants_pro(self, monkeypatch):
+        """Correct GODOTIQ_DEV_KEY -> is_pro() returns True."""
+        monkeypatch.setenv("GODOTIQ_DEV_KEY", _TEST_DEV_KEY)
+        assert is_pro() is True
+
+    def test_wrong_dev_key_returns_false(self, monkeypatch):
+        """Wrong GODOTIQ_DEV_KEY -> is_pro() returns False."""
+        monkeypatch.setenv("GODOTIQ_DEV_KEY", "wrong-passphrase")
+        assert is_pro() is False
+
+    def test_old_license_pro_bypass_removed(self, monkeypatch):
+        """GODOTIQ_LICENSE=pro no longer works (critical regression test)."""
+        monkeypatch.setenv("GODOTIQ_LICENSE", "pro")
+        assert is_pro() is False
+
+    def test_no_env_vars_returns_false(self):
+        """No GODOTIQ_DEV_KEY, no GODOTIQ_LICENSE_KEY -> False."""
+        assert is_pro() is False
+
+    def test_empty_dev_key_returns_false(self, monkeypatch):
+        """Empty GODOTIQ_DEV_KEY -> is_pro() returns False."""
+        monkeypatch.setenv("GODOTIQ_DEV_KEY", "")
+        assert is_pro() is False
+
+    def test_dev_key_takes_priority_over_polar(self, monkeypatch, mock_polar_unreachable):
+        """GODOTIQ_DEV_KEY works without calling Polar API."""
+        monkeypatch.setenv("GODOTIQ_DEV_KEY", _TEST_DEV_KEY)
+        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "POLAR-SOME-KEY")
+        assert is_pro() is True
