Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 56 additions & 23 deletions graqle/activation/tier_gate.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
"""pre-reason-activation design — License-tier detection for the pre-reason activation layer.
"""pre-reason-activation design — governance-MODE detection for the activation layer.

Rule (plain English):
Free tier → ADVISORY mode (scores visible, upgrade chip shown on block-worthy turns, turn continues)
Pro / Enterprise → ENFORCED mode (turn halts on block-worthy safety verdicts)
This resolves ONLY the activation *governance mode* (whether block-worthy safety
verdicts halt the turn), NOT a paid entitlement:

ADVISORY mode → scores visible, upgrade chip on block-worthy turns, turn CONTINUES
ENFORCED mode → turn HALTS on a block-worthy safety verdict

⚠️ CR-LIC-03b (ADR-245) SECURITY INVARIANT — this function MUST NEVER be used to grant a
paid feature, raise a cap, or confer any entitlement. It only makes governance STRICTER
(ENFORCED blocks your own unsafe turns). Because of that, an UNVERIFIED signal (env var,
config file) is allowed to opt INTO stricter governance, but it can never buy anything.
Any code that gates a PAID capability MUST consult ``manager.current_tier`` (a
cryptographically verified signed licence) — never this function, never GRAQLE_LICENSE_TIER.

Detection order (first match wins):
1. Environment variable GRAQLE_LICENSE_TIER (explicit override)
2. Environment variable GRAQLE_LICENSE_KEY (presence = Pro at minimum)
3. Config file graqle.yaml -> license.tier
4. Default: ADVISORY (Free)
1. VERIFIED licence tier (manager.current_tier) — the trustworthy source; a real
paid licence → ENFORCED. Checked FIRST so a genuine entitlement always wins.
2. GRAQLE_LICENSE_TIER env — governance-mode dev toggle ONLY (unverified; can raise
to ENFORCED but confers NO entitlement — see invariant above).
3. GRAQLE_LICENSE_KEY presence — a key is present (its validity is enforced elsewhere).
4. Config file graqle.yaml -> license.tier (unverified hint; mode only).
5. Default: ADVISORY (Free).

Never raises: unknown tier strings fall back to ADVISORY.
Never raises: unknown/error → ADVISORY (fail-safe = the least-strict, non-blocking mode).
"""
from __future__ import annotations

Expand All @@ -27,20 +39,41 @@
_FREE_TIERS = frozenset({"free", "community", ""})


def resolve_tier_mode(config: dict[str, Any] | None = None) -> TierMode:
"""Resolve the current tier's activation mode.
def _verified_tier_mode() -> TierMode | None:
"""Governance mode from the VERIFIED licence, or None if no valid paid licence.

Parameters
----------
config:
Optional parsed graqle.yaml content. If omitted, only env vars are
consulted.
Consults manager.current_tier, which returns a paid tier ONLY from a signed licence
that passed verification (signature + CRL + nonce). This is the trustworthy source;
it is checked first so a real entitlement is never shadowed by an unverified hint.
Fail-safe: any error → None (fall through to the mode-only hints).
"""
try:
# Lazy import: graqle.licensing may import graqle.activation transitively, so a
# module-level import here risks a circular dependency. Import inside the call.
from graqle.licensing.manager import LicenseTier, _get_manager

Returns
-------
TierMode.ENFORCED if Pro/Enterprise/Team key is present; else ADVISORY.
tier = _get_manager().current_tier
if tier in (LicenseTier.PRO, LicenseTier.TEAM, LicenseTier.ENTERPRISE):
return TierMode.ENFORCED
return TierMode.ADVISORY
except Exception: # noqa: BLE001 — mode detection must never raise
return None


def resolve_tier_mode(config: dict[str, Any] | None = None) -> TierMode:
"""Resolve the activation GOVERNANCE MODE (not an entitlement — see module docstring).

Returns TierMode.ENFORCED (halt on block-worthy verdicts) or ADVISORY (never halt).
NEVER call this to decide a paid feature/cap — use manager.current_tier for that.
"""
# 1. Explicit override
# 1. VERIFIED licence wins — a real paid entitlement always resolves ENFORCED.
verified = _verified_tier_mode()
if verified is TierMode.ENFORCED:
return verified
# (A verified FREE tier does NOT short-circuit: an unverified env/config hint may
# still opt a free user INTO stricter governance — that grants nothing, only rigor.)

# 2. GRAQLE_LICENSE_TIER — governance-mode dev toggle ONLY (unverified; no entitlement).
explicit = os.environ.get("GRAQLE_LICENSE_TIER", "").strip().lower()
if explicit:
if explicit in _PRO_TIERS:
Expand All @@ -50,11 +83,11 @@ def resolve_tier_mode(config: dict[str, Any] | None = None) -> TierMode:
logger.warning("unknown GRAQLE_LICENSE_TIER value %r; falling back to ADVISORY", explicit)
return TierMode.ADVISORY

# 2. License key presence
# 3. License key presence (validity enforced elsewhere; mode signal only).
if os.environ.get("GRAQLE_LICENSE_KEY", "").strip():
return TierMode.ENFORCED

# 3. Config file
# 4. Config file (unverified hint; mode only).
if isinstance(config, dict):
lic = config.get("license")
if isinstance(lic, dict):
Expand All @@ -64,5 +97,5 @@ def resolve_tier_mode(config: dict[str, Any] | None = None) -> TierMode:
if tier_cfg in _FREE_TIERS:
return TierMode.ADVISORY

# 4. Default
# 5. Default
return TierMode.ADVISORY
81 changes: 81 additions & 0 deletions tests/test_activation/test_activation_layer.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,3 +223,84 @@ def test_default_factory_always_returns_layer(monkeypatch):
# Should be runnable end-to-end without raising
v = asyncio.run(layer.run("test msg", {}))
assert isinstance(v, ActivationVerdict)


# ── CR-LIC-03b (ADR-245) — tier-trust security invariants ────────────────────
# resolve_tier_mode resolves the governance MODE only; it must NEVER confer a paid
# entitlement, and a VERIFIED paid licence must win over an unverified env/config hint.

def test_verified_paid_licence_wins_over_env(monkeypatch):
"""A real verified PRO licence resolves ENFORCED even if the env says 'free'."""
from types import SimpleNamespace

import graqle.activation.tier_gate as tg
from graqle.licensing.manager import LicenseTier

monkeypatch.setenv("GRAQLE_LICENSE_TIER", "free") # unverified hint says free
fake_mgr = SimpleNamespace(current_tier=LicenseTier.PRO) # verified = PRO
monkeypatch.setattr(
"graqle.licensing.manager._get_manager", lambda: fake_mgr
)
assert tg.resolve_tier_mode() is TierMode.ENFORCED


def test_env_tier_confers_mode_not_entitlement(monkeypatch):
"""GRAQLE_LICENSE_TIER=enterprise (unverified) yields ENFORCED *mode* only — it
does not touch manager.current_tier, which stays FREE (the real entitlement)."""
from types import SimpleNamespace

import graqle.activation.tier_gate as tg
from graqle.licensing.manager import LicenseTier

monkeypatch.setenv("GRAQLE_LICENSE_TIER", "enterprise") # unverified
fake_mgr = SimpleNamespace(current_tier=LicenseTier.FREE) # verified stays FREE
monkeypatch.setattr(
"graqle.licensing.manager._get_manager", lambda: fake_mgr
)
# Mode is ENFORCED (stricter governance — grants nothing)...
assert tg.resolve_tier_mode() is TierMode.ENFORCED
# ...but the ENTITLEMENT (what paid features consult) is unchanged: still FREE.
assert fake_mgr.current_tier is LicenseTier.FREE


def test_verified_free_still_advisory_without_hints(monkeypatch):
"""No env/config hints + verified FREE → ADVISORY (no false enforcement)."""
from types import SimpleNamespace

import graqle.activation.tier_gate as tg
from graqle.licensing.manager import LicenseTier

monkeypatch.delenv("GRAQLE_LICENSE_TIER", raising=False)
monkeypatch.delenv("GRAQLE_LICENSE_KEY", raising=False)
fake_mgr = SimpleNamespace(current_tier=LicenseTier.FREE)
monkeypatch.setattr(
"graqle.licensing.manager._get_manager", lambda: fake_mgr
)
assert tg.resolve_tier_mode() is TierMode.ADVISORY


def test_manager_error_falls_through_safely(monkeypatch):
"""If the verified-tier lookup raises, resolve_tier_mode must not crash — it falls
through to the mode-only hints (fail-safe), never blocking mode detection."""
import graqle.activation.tier_gate as tg

def _boom():
raise RuntimeError("licence subsystem unavailable")

monkeypatch.setattr("graqle.licensing.manager._get_manager", _boom)
monkeypatch.delenv("GRAQLE_LICENSE_TIER", raising=False)
monkeypatch.delenv("GRAQLE_LICENSE_KEY", raising=False)
# No hints + manager error → default ADVISORY (fail-safe, least-strict).
assert tg.resolve_tier_mode() is TierMode.ADVISORY


def test_no_import_cycle_tier_gate_and_manager():
"""CR-LIC-03b MINOR: tier_gate lazy-imports manager to avoid a cycle. Importing
both in either order must not deadlock or ImportError (guards the cycle boundary)."""
import importlib

importlib.import_module("graqle.activation.tier_gate")
importlib.import_module("graqle.licensing.manager")
# Reverse order too.
importlib.reload(importlib.import_module("graqle.licensing.manager"))
importlib.reload(importlib.import_module("graqle.activation.tier_gate"))
Loading