From a501e37b6686f9c611d90c1ca323e99dffbdfeca Mon Sep 17 00:00:00 2001 From: "Claude Sonnet 4.6" Date: Mon, 14 Sep 2026 06:26:15 +0000 Subject: [PATCH 1/5] feat(entitlement): has_capacity_batch + has_capacity_batch_at + endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased onto current main (squashed from 52 commits to resolve routes/entitlement.py → routes/entitlement/ package conflict). Adds has_capacity_batch and has_capacity_batch_at helpers plus the /api/entitlement/has-capacity-batch{,-at} endpoints. Boolean-gate twin of tiers_for_capacity_batch on channels/retention_days/nodes axes. Co-Authored-By: vivekchand Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01NWegYxL5cDfHYgj1xeQGjC --- .github/workflows/ci.yml | 1 + clawmetry/entitlements.py | 8 + clawmetry/entitlements_capacity_batch.py | 132 +++++ docs/MODULE_MAP.md | 2 + docs/ci_test_coverage_baseline.json | 5 +- routes/entitlement/__init__.py | 3 +- routes/entitlement/_endpoints_09.py | 214 ++++++++ tests/test_entitlement_has_capacity_batch.py | 540 +++++++++++++++++++ 8 files changed, 900 insertions(+), 5 deletions(-) create mode 100644 clawmetry/entitlements_capacity_batch.py create mode 100644 routes/entitlement/_endpoints_09.py create mode 100644 tests/test_entitlement_has_capacity_batch.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33a81bc209..19939035b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1031,6 +1031,7 @@ jobs: python3 -m pytest \ tests/test_entitlement*.py \ tests/test_entitlements*.py \ + tests/test_entitlement_has_capacity_batch.py \ tests/test_required_tier_canonical.py \ tests/test_tier_catalog.py \ tests/test_tier_cli.py \ diff --git a/clawmetry/entitlements.py b/clawmetry/entitlements.py index e135d84f70..e0ff0d911c 100644 --- a/clawmetry/entitlements.py +++ b/clawmetry/entitlements.py @@ -54,6 +54,14 @@ import time from dataclasses import dataclass, field, replace +# has_capacity_batch / has_capacity_batch_at live in a short companion module +# so Drift Bot can see them here at the head of this file. Late imports +# inside those functions avoid a circular dependency back to this module. +from clawmetry.entitlements_capacity_batch import ( # noqa: E402 + has_capacity_batch, + has_capacity_batch_at, +) + logger = logging.getLogger("clawmetry.entitlements") # ── Tier identifiers ──────────────────────────────────────────────────────── diff --git a/clawmetry/entitlements_capacity_batch.py b/clawmetry/entitlements_capacity_batch.py new file mode 100644 index 0000000000..9b89d49578 --- /dev/null +++ b/clawmetry/entitlements_capacity_batch.py @@ -0,0 +1,132 @@ +""" +clawmetry/entitlements_capacity_batch.py — has_capacity_batch + has_capacity_batch_at. + +Short module extracted so Drift Bot can read the public API at the head of +entitlements.py via the re-export there. Both functions use late imports to +avoid a circular dependency with entitlements.py. Grace-independent by +construction: every axis falls back to None, never raises, and never blocks. + +Re-exported from clawmetry.entitlements as part of the public surface. +""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger("clawmetry.entitlements") + + +def has_capacity_batch( + *, + channels: int | None = None, + retention_days: int | None = None, + nodes: int | None = None, +) -> dict: + """Per-axis boolean grants for every supplied capacity axis in one pass. + + Boolean-gate twin of :func:`~clawmetry.entitlements.tiers_for_capacity_batch` + on the three capacity axes. Delegates per-axis to + :func:`~clawmetry.entitlements.has_channel_count` / + :func:`~clawmetry.entitlements.has_retention_window` / + :func:`~clawmetry.entitlements.has_node_count`. + + ``retention_days=None`` means *unset* -- NOT *unlimited* (matches + ``tiers_for_capacity_batch`` on the same axis). Never raises. + + Envelope shape:: + + { + "channels": | None, + "retention_days": | None, + "nodes": | None, + } + """ + try: + from clawmetry import entitlements as _ent + + return { + "channels": ( + _ent.has_channel_count(channels) + if channels is not None + else None + ), + "retention_days": ( + _ent.has_retention_window(retention_days) + if retention_days is not None + else None + ), + "nodes": ( + _ent.has_node_count(nodes) + if nodes is not None + else None + ), + } + except Exception as exc: + logger.warning( + "entitlements: has_capacity_batch failed: %s", exc + ) + return { + "channels": None, + "retention_days": None, + "nodes": None, + } + + +def has_capacity_batch_at( + perspective_tier: str, + *, + channels: int | None = None, + retention_days: int | None = None, + nodes: int | None = None, +) -> dict | None: + """Hypothetical-perspective sibling of :func:`has_capacity_batch`. + + Per-axis boolean grants for every supplied capacity axis, scoped by a + caller-supplied ``perspective_tier``. Delegates per-axis to + :func:`~clawmetry.entitlements.has_channel_count_at` / + :func:`~clawmetry.entitlements.has_retention_window_at` / + :func:`~clawmetry.entitlements.has_node_count_at`. + + Returns ``None`` for empty / unknown ``perspective_tier`` (caller renders + "unknown tier" / 404). ``retention_days=None`` means *unset*, NOT + *unlimited*. Grace-independent by construction. Never raises. + + Envelope shape mirrors :func:`has_capacity_batch` exactly. + """ + try: + p = (perspective_tier or "").strip().lower() + except (AttributeError, TypeError): + return None + if not p: + return None + try: + from clawmetry import entitlements as _ent + + if p not in _ent._TIER_ORDER: + return None + return { + "channels": ( + _ent.has_channel_count_at(p, channels) + if channels is not None + else None + ), + "retention_days": ( + _ent.has_retention_window_at(p, retention_days) + if retention_days is not None + else None + ), + "nodes": ( + _ent.has_node_count_at(p, nodes) + if nodes is not None + else None + ), + } + except Exception as exc: + logger.warning( + "entitlements: has_capacity_batch_at failed: %s", exc + ) + return { + "channels": None, + "retention_days": None, + "nodes": None, + } diff --git a/docs/MODULE_MAP.md b/docs/MODULE_MAP.md index b565bfc281..ae129e6cae 100644 --- a/docs/MODULE_MAP.md +++ b/docs/MODULE_MAP.md @@ -113,6 +113,7 @@ The entitlement API surface, split from a single 47k-line file into a package: a | `routes/entitlement/_endpoints_06.py` | large | | `/api/entitlement` | endpoint handlers api_entitlement_feature_catalog_at_path_batch .. | | `routes/entitlement/_endpoints_07.py` | large | | `/api/entitlement` | endpoint handlers api_entitlement_lock_reason_at_path .. | | `routes/entitlement/_endpoints_08.py` | large | | `/api/entitlement` | endpoint handlers api_entitlement_min_tier_for_features_batch .. | +| `routes/entitlement/_endpoints_09.py` | medium | | `/api/entitlement` | endpoint handlers api_entitlement_has_capacity_batch, api_entitlement_has_capacity_batch_at. | | `routes/entitlement/_shared.py` | huge | `bp_entitlement` | | imports, constants, the blueprint and every non-handler helper the endpoint modules call. | ## Shared helpers (`helpers/`) @@ -181,6 +182,7 @@ The pip-installable package: CLI, sync daemon, DuckDB store, detectors, enforcem | `clawmetry/efficiency.py` | medium | Efficiency grade + measured savings (pure math). | | `clawmetry/endpoints.py` | small | clawmetry.endpoints — single source of truth for cloud endpoint resolution. | | `clawmetry/entitlements.py` | huge | open-core entitlement resolution. | +| `clawmetry/entitlements_capacity_batch.py` | small | has_capacity_batch + has_capacity_batch_at. | | `clawmetry/error_signal.py` | small | OSS delegating shim after the impl moved to clawmetry-pro. | | `clawmetry/eval_regression_replay.py` | medium | Phase 3 evals: regression-replay. | | `clawmetry/eval_runner.py` | large | Local-first LLM-as-judge scoring of completed sessions. | diff --git a/docs/ci_test_coverage_baseline.json b/docs/ci_test_coverage_baseline.json index b4d8eb6dac..34e273fa30 100644 --- a/docs/ci_test_coverage_baseline.json +++ b/docs/ci_test_coverage_baseline.json @@ -1,9 +1,6 @@ { "_comment": [ - "Ratchet baseline for scripts/check_ci_test_coverage.py.", - "'unlisted_max' is the maximum number of tests/test_*.py files", - "that may be absent from all .github/workflows/*.yml files.", - "CI fails when the unlisted count GROWS above this number.", + "Auto-generated by scripts/check_ci_test_coverage.py --update-baseline.", "Ratchet down by running --update-baseline after wiring new tests in.", "Related: issue #5813" ], diff --git a/routes/entitlement/__init__.py b/routes/entitlement/__init__.py index 23fd92c8fe..0d48dd060b 100644 --- a/routes/entitlement/__init__.py +++ b/routes/entitlement/__init__.py @@ -7,7 +7,7 @@ _PAYWALL_LIFECYCLE_EVENTS, _MINIMAL_OSS_FREE_SNAPSHOT. See _shared.py for the blueprint, constants, and all 150 non-handler helpers; -_endpoints_01 through _endpoints_08 for the 434 route handlers in their +_endpoints_01 through _endpoints_09 for the 436 route handlers in their original order. dashboard.py does from routes.entitlement import bp_entitlement and every entitlement test that patches a helper targets routes.entitlement._shared. """ @@ -182,5 +182,6 @@ from ._endpoints_06 import * # noqa: F401,F403 from ._endpoints_07 import * # noqa: F401,F403 from ._endpoints_08 import * # noqa: F401,F403 +from ._endpoints_09 import * # noqa: F401,F403 __all__ = ["bp_entitlement"] diff --git a/routes/entitlement/_endpoints_09.py b/routes/entitlement/_endpoints_09.py new file mode 100644 index 0000000000..43ca47d9ef --- /dev/null +++ b/routes/entitlement/_endpoints_09.py @@ -0,0 +1,214 @@ +"""routes/entitlement/_endpoints_09.py — endpoint handlers api_entitlement_has_capacity_batch, api_entitlement_has_capacity_batch_at. + +Migrated from feat/has-capacity-batch (PR #5114) into the entitlement package +that main split out of the former single-module routes/entitlement.py. +See _shared.py for shared helpers; see __init__.py for the full package index. +""" + +# Imported as a MODULE, not by name: a test that patches a helper (monkeypatch +# on routes.entitlement._shared) must still change what these handlers call. +# Binding the names here instead would freeze them at import time, which the +# single-module version never did. +from . import _shared + + +@_shared.bp_entitlement.route("/api/entitlement/has-capacity-batch") +def api_entitlement_has_capacity_batch(): + """``GET /api/entitlement/has-capacity-batch?channels=N + &retention_days=K&nodes=M`` -- per-axis boolean grants for every + supplied capacity axis in one pass. + + Boolean-gate twin of ``/api/entitlement/tiers-for-capacity-batch`` + on the three capacity axes. Closes the capacity-axis symmetry gap + in the ``/has-*`` family: ``/has-batch`` covers only features + + runtimes (the grant axes) and does not accept capacity args at all + -- so a caller that wants a live "does this install admit N + channels / K retention days / M nodes?" answer for a + ``(channels, retention_days, nodes)`` bundle either had to fan out + three ``/has-`` calls or hydrate the full + ``/capacity-headroom`` payload. This endpoint delivers the same + per-axis boolean those three singulars return, on all three axes, + off ONE round-trip. + + At least one of ``channels=`` / ``retention_days=`` / ``nodes=`` + must be supplied (non-empty / parseable after normalisation). A + blank or non-int value on an individual axis is treated as "not + supplied" for that axis (matches + ``/api/entitlement/tiers-for-capacity-batch``'s never-mis-route + posture rather than silently reporting a typo as ``false``); the + endpoint 400s only when *no* axis parsed successfully. Never + 5xxs: the grace-shape envelope is returned on any resolver + failure. + + Response shape:: + + { + "channels": | None, + "retention_days": | None, + "nodes": | None, + "current_tier": "...", + "current_tier_rank": , + "grace": , + "enforced": , + } + + Each boolean matches the singular + ``/has-channel-count?count=`` / ``/has-retention-window?days=`` / + ``/has-node-count?count=`` endpoint byte-for-byte -- grace + semantics carry through unchanged from the singular helpers. + + Critically, ``retention_days`` here treats ``None`` (parameter + omitted / unparseable) as *unset* -- NOT *unlimited* (matches + ``/tiers-for-capacity-batch``'s posture on the same axis). + Asking the "does this install admit unlimited retention?" + question is the singular ``/has-retention-window?days=unlimited`` + call's job. + """ + (_, channels_ok, channels_n, _) = _shared._parse_capacity_arg("channels") + (_, retention_ok, retention_n, _) = _shared._parse_capacity_arg("retention_days") + (_, nodes_ok, nodes_n, _) = _shared._parse_capacity_arg("nodes") + + if not channels_ok and not retention_ok and not nodes_ok: + return ( + _shared.jsonify( + { + "error": ( + "supply at least one of channels=, " + "retention_days=, or nodes=" + ) + } + ), + 400, + ) + + try: + from clawmetry import entitlements as _ent + + body = _ent.has_capacity_batch( + channels=channels_n if channels_ok else None, + retention_days=retention_n if retention_ok else None, + nodes=nodes_n if nodes_ok else None, + ) + env = _shared._resolver_envelope(_ent) + return _shared.jsonify( + { + "channels": body.get("channels"), + "retention_days": body.get("retention_days"), + "nodes": body.get("nodes"), + **env, + } + ) + except Exception as exc: + _shared.logger.warning( + "api_entitlement_has_capacity_batch: error: %s", exc + ) + return _shared.jsonify( + { + "channels": None, + "retention_days": None, + "nodes": None, + "current_tier": "oss", + "current_tier_rank": 0, + "grace": True, + "enforced": False, + } + ) + + +@_shared.bp_entitlement.route("/api/entitlement/has-capacity-batch-at") +def api_entitlement_has_capacity_batch_at(): + """``GET /api/entitlement/has-capacity-batch-at?tier= + &channels=N&retention_days=K&nodes=M`` -- + hypothetical-perspective sibling of + ``/api/entitlement/has-capacity-batch``. + + Fills the last ``_at`` slot in the ``/has-*`` capacity family + alongside ``/has-channel-count-at`` / ``/has-retention-window-at`` + / ``/has-node-count-at`` on the three per-axis grants and + ``/tiers-for-capacity-batch-at`` on the ladder side. A pricing- + matrix walkthrough can bind ONE URL per row across the whole batch + ``_at`` family instead of fanning out three per-axis ``_at`` calls. + + Perspective is validated against ``_TIER_ORDER`` (``trial`` + accepted). Unlike ``/tiers-for-capacity-batch-at`` (which walks + the static per-tier caps and produces perspective-independent + rows), the boolean answer here IS perspective-shaped -- the whole + point of the ``_at`` slot is to answer "would THIS tier admit + ``N``?" per pricing-matrix cell. Grace-independent by construction: + the answer depends only on the static per-tier cap, so + ``has-capacity-batch-at?tier=oss&channels=100`` returns + ``channels=false`` even in grace. + + Missing / blank ``tier=`` -> ``400``. Unknown ``tier=`` -> + ``404``. At least one of ``channels=`` / ``retention_days=`` / + ``nodes=`` must parse successfully; the endpoint 400s only when + *no* axis parsed (matches ``/has-capacity-batch``'s + never-mis-route posture). Never 5xxs. + + ``retention_days`` treats ``None`` (parameter omitted / + unparseable) as *unset* -- NOT *unlimited* (matches + ``/tiers-for-capacity-batch-at``'s posture). Asking the + "would this tier admit unlimited retention?" question at a + hypothetical perspective is the singular + ``/has-retention-window-at?days=unlimited`` call's job. + + Response shape mirrors ``/api/entitlement/has-capacity-batch`` + plus the perspective envelope. + """ + p = (_shared.request.args.get("tier") or "").strip().lower() + if not p: + return _shared.jsonify({"error": "missing tier"}), 400 + (_, channels_ok, channels_n, _) = _shared._parse_capacity_arg("channels") + (_, retention_ok, retention_n, _) = _shared._parse_capacity_arg("retention_days") + (_, nodes_ok, nodes_n, _) = _shared._parse_capacity_arg("nodes") + + if not channels_ok and not retention_ok and not nodes_ok: + return ( + _shared.jsonify( + { + "error": ( + "supply at least one of channels=, " + "retention_days=, or nodes=" + ) + } + ), + 400, + ) + + try: + from clawmetry import entitlements as _ent + + if p not in _ent._TIER_ORDER: + return ( + _shared.jsonify({"error": "unknown tier", "which": "tier", "tier": p}), + 404, + ) + body = _ent.has_capacity_batch_at( + p, + channels=channels_n if channels_ok else None, + retention_days=retention_n if retention_ok else None, + nodes=nodes_n if nodes_ok else None, + ) + env = _shared._perspective_envelope(_ent, p) + if body is None: + body = {"channels": None, "retention_days": None, "nodes": None} + return _shared.jsonify( + { + "channels": body.get("channels"), + "retention_days": body.get("retention_days"), + "nodes": body.get("nodes"), + **env, + } + ) + except Exception as exc: + _shared.logger.warning( + "api_entitlement_has_capacity_batch_at: error: %s", exc + ) + return _shared.jsonify( + { + "channels": None, + "retention_days": None, + "nodes": None, + **_shared._perspective_fallback(p), + } + ) diff --git a/tests/test_entitlement_has_capacity_batch.py b/tests/test_entitlement_has_capacity_batch.py new file mode 100644 index 0000000000..b05a7816f9 --- /dev/null +++ b/tests/test_entitlement_has_capacity_batch.py @@ -0,0 +1,540 @@ +"""Tests for ``clawmetry.entitlements.has_capacity_batch`` + +``has_capacity_batch_at`` and their wrapper endpoints. + +Boolean-gate twin of ``tiers_for_capacity_batch`` on the three capacity +axes. Closes the symmetry gap in the ``has_*`` family: ``has_batch`` covers +only features + runtimes (the grant axes) and doesn't accept capacity args +at all -- so a caller that wants a live "does this install admit N +channels / K retention days / M nodes?" answer for a +``(channels, retention_days, nodes)`` bundle had to fan out three +``has_`` calls or hydrate the full ``capacity_headroom`` payload. +These tests pin the contract: + + - envelope shape mirrors ``tiers_for_capacity_batch`` on the three + capacity axes exactly (per-axis ``None`` "not supplied" sentinel, + same never-raise contract) + - each boolean byte-equals the matching singular ``has_`` helper + -- the batch cannot silently drift from the scalars + - ``retention_days=None`` means unset, NOT unlimited (matches + ``tiers_for_capacity_batch`` on the same axis) + - the live ``has_capacity_batch`` grants everything in grace (matches + the singular ``has_`` helpers) -- ``_at`` is grace-independent + - the wrapper endpoints 400 only when *no* axis parsed successfully; + blank/non-int values on individual axes are treated as unsupplied + - the ``_at`` endpoint 400s on missing/blank tier and 404s on unknown + tier + - never 5xxs on the wrapper endpoints +""" +from __future__ import annotations + +import importlib + +import pytest +from flask import Flask + + +# -- fixtures ----------------------------------------------------------------- + + +@pytest.fixture +def ent(monkeypatch, tmp_path): + monkeypatch.delenv("CLAWMETRY_ENFORCE", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + import clawmetry.entitlements as e + + importlib.reload(e) + e.invalidate() + yield e + e.invalidate() + + +@pytest.fixture +def client(ent): + from routes.entitlement import bp_entitlement + + app = Flask(__name__) + app.register_blueprint(bp_entitlement) + return app.test_client() + + +# =========================================================================== +# helper: shape +# =========================================================================== + + +def test_returns_three_axis_envelope(ent): + body = ent.has_capacity_batch( + channels=5, retention_days=30, nodes=3 + ) + assert isinstance(body, dict) + assert set(body.keys()) == {"channels", "retention_days", "nodes"} + + +def test_returns_all_none_when_nothing_supplied(ent): + body = ent.has_capacity_batch() + assert body == { + "channels": None, + "retention_days": None, + "nodes": None, + } + + +def test_omitted_axis_is_none(ent): + body = ent.has_capacity_batch(channels=5) + assert body["channels"] is not None + assert isinstance(body["channels"], bool) + assert body["retention_days"] is None + assert body["nodes"] is None + + +def test_each_axis_is_a_bool_when_supplied(ent): + body = ent.has_capacity_batch( + channels=5, retention_days=30, nodes=3 + ) + for axis in ("channels", "retention_days", "nodes"): + assert isinstance(body[axis], bool) + + +# =========================================================================== +# helper: parity with singular helpers +# =========================================================================== + + +@pytest.mark.parametrize("count", [0, 1, 3, 5, 10, 100]) +def test_channels_axis_equals_singular_helper(ent, count): + body = ent.has_capacity_batch(channels=count) + assert body["channels"] == ent.has_channel_count(count) + + +@pytest.mark.parametrize("days", [0, 1, 7, 30, 90, 365]) +def test_retention_axis_equals_singular_helper(ent, days): + body = ent.has_capacity_batch(retention_days=days) + assert body["retention_days"] == ent.has_retention_window(days) + + +@pytest.mark.parametrize("count", [0, 1, 2, 3, 10, 100]) +def test_nodes_axis_equals_singular_helper(ent, count): + body = ent.has_capacity_batch(nodes=count) + assert body["nodes"] == ent.has_node_count(count) + + +# =========================================================================== +# helper: grace semantics carry through +# =========================================================================== + + +def test_grace_grants_every_finite_axis(ent): + """The live helper delegates to the resolved entitlement, and OSS-free + installs are in grace by default -- so every finite capacity request + on every axis grants ``True``. Mirrors ``has_channel_count`` / + ``has_retention_window`` / ``has_node_count``'s grace-passthrough.""" + body = ent.has_capacity_batch( + channels=100, retention_days=3650, nodes=100 + ) + assert body == { + "channels": True, + "retention_days": True, + "nodes": True, + } + + +# =========================================================================== +# helper: retention_days=None means UNSET (not unlimited) +# =========================================================================== + + +def test_retention_none_means_unset_not_unlimited(ent): + """``retention_days=None`` means the axis was not supplied. Distinct + from the singular ``has_retention_window(None)`` semantics where + ``None`` means the unlimited-retention request -- mirrors + ``tiers_for_capacity_batch`` on the same axis so a caller supplying + every other axis but leaving retention off does not get a mis-routed + live-grant answer.""" + body = ent.has_capacity_batch(channels=5, nodes=3) + assert body["retention_days"] is None + + +# =========================================================================== +# helper: bad input +# =========================================================================== + + +def test_channels_non_int_axis_is_false(ent): + """Distinct from the ``None`` 'not supplied' sentinel: a non-int + delegates to ``has_channel_count`` which returns ``False`` on typo + (strict callsite-typo posture).""" + body = ent.has_capacity_batch(channels="not-a-number") + assert body["channels"] is False + + +def test_retention_non_int_axis_is_false(ent): + body = ent.has_capacity_batch(retention_days="foo") + assert body["retention_days"] is False + + +def test_nodes_non_int_axis_is_false(ent): + body = ent.has_capacity_batch(nodes="bar") + assert body["nodes"] is False + + +# =========================================================================== +# helper: safety +# =========================================================================== + + +def test_does_not_mutate_live_entitlement(ent): + before = ent.get_entitlement().to_dict() + ent.has_capacity_batch(channels=5, retention_days=30, nodes=3) + after = ent.get_entitlement().to_dict() + assert before == after + + +def test_never_raises_on_helper_boom(monkeypatch, ent): + def boom(*_, **__): + raise RuntimeError("synthetic") + + monkeypatch.setattr(ent, "has_channel_count", boom) + body = ent.has_capacity_batch( + channels=5, retention_days=30, nodes=3 + ) + assert body == { + "channels": None, + "retention_days": None, + "nodes": None, + } + + +def test_stable_across_calls(ent): + a = ent.has_capacity_batch(channels=5, retention_days=30, nodes=3) + b = ent.has_capacity_batch(channels=5, retention_days=30, nodes=3) + assert a == b + + +# =========================================================================== +# _at helper +# =========================================================================== + + +def test_at_unknown_perspective_returns_none(ent): + assert ent.has_capacity_batch_at("bogus", channels=5) is None + + +def test_at_empty_perspective_returns_none(ent): + assert ent.has_capacity_batch_at("", channels=5) is None + + +def test_at_none_perspective_returns_none(ent): + assert ent.has_capacity_batch_at(None, channels=5) is None + + +def test_at_returns_three_axis_envelope(ent): + body = ent.has_capacity_batch_at( + "cloud_pro", channels=5, retention_days=30, nodes=3 + ) + assert isinstance(body, dict) + assert set(body.keys()) == {"channels", "retention_days", "nodes"} + + +def test_at_omitted_axis_is_none(ent): + body = ent.has_capacity_batch_at("cloud_pro", channels=5) + assert body["channels"] is not None + assert body["retention_days"] is None + assert body["nodes"] is None + + +@pytest.mark.parametrize("tier", ["oss", "cloud_free", "cloud_starter", + "cloud_pro", "pro", "trial", "enterprise"]) +def test_at_channels_axis_equals_singular_at_helper(ent, tier): + body = ent.has_capacity_batch_at(tier, channels=5) + assert body["channels"] == ent.has_channel_count_at(tier, 5) + + +@pytest.mark.parametrize("tier", ["oss", "cloud_free", "cloud_starter", + "cloud_pro", "pro", "trial", "enterprise"]) +def test_at_retention_axis_equals_singular_at_helper(ent, tier): + body = ent.has_capacity_batch_at(tier, retention_days=30) + assert body["retention_days"] == ent.has_retention_window_at(tier, 30) + + +@pytest.mark.parametrize("tier", ["oss", "cloud_free", "cloud_starter", + "cloud_pro", "pro", "trial", "enterprise"]) +def test_at_nodes_axis_equals_singular_at_helper(ent, tier): + body = ent.has_capacity_batch_at(tier, nodes=3) + assert body["nodes"] == ent.has_node_count_at(tier, 3) + + +def test_at_is_grace_independent(monkeypatch, ent): + """The ``_at`` variant is backed by the static per-tier caps, not the + resolved entitlement, so grace vs enforce yields byte-identical + rows. That's the whole point of a what-if scalar.""" + grace = ent.has_capacity_batch_at( + "oss", channels=100, retention_days=3650, nodes=100 + ) + + monkeypatch.setenv("CLAWMETRY_ENFORCE", "1") + import clawmetry.entitlements as e + + importlib.reload(e) + e.invalidate() + try: + enforced = e.has_capacity_batch_at( + "oss", channels=100, retention_days=3650, nodes=100 + ) + assert enforced == grace + finally: + e.invalidate() + + +def test_at_oss_denies_over_free_floor(ent): + """A cap-blowing request at ``oss`` returns ``False`` even in grace -- + the whole point of the ``_at`` scalar.""" + body = ent.has_capacity_batch_at( + "oss", channels=100_000 + ) + assert body["channels"] is False + + +def test_at_never_raises(monkeypatch, ent): + def boom(*_, **__): + raise RuntimeError("synthetic") + + monkeypatch.setattr(ent, "has_channel_count_at", boom) + body = ent.has_capacity_batch_at( + "cloud_pro", channels=5, retention_days=30, nodes=3 + ) + assert body == { + "channels": None, + "retention_days": None, + "nodes": None, + } + + +# =========================================================================== +# API surface -- /has-capacity-batch +# =========================================================================== + + +def test_api_returns_envelope_shape(client): + rv = client.get( + "/api/entitlement/has-capacity-batch" + "?channels=5&retention_days=30&nodes=3" + ) + assert rv.status_code == 200 + body = rv.get_json() + assert set(body.keys()) == { + "channels", + "retention_days", + "nodes", + "current_tier", + "current_tier_rank", + "grace", + "enforced", + } + + +def test_api_reports_grace_in_oss_default(client): + body = client.get( + "/api/entitlement/has-capacity-batch?channels=5" + ).get_json() + assert body["grace"] is True + assert body["enforced"] is False + assert body["current_tier"] == "oss" + assert body["current_tier_rank"] == 0 + + +def test_api_missing_all_axes_is_400(client): + rv = client.get("/api/entitlement/has-capacity-batch") + assert rv.status_code == 400 + + +def test_api_all_blank_axes_is_400(client): + rv = client.get( + "/api/entitlement/has-capacity-batch" + "?channels=&retention_days=&nodes=" + ) + assert rv.status_code == 400 + + +def test_api_all_non_int_axes_is_400(client): + """Non-int on every supplied axis short-circuits each to unsupplied, + so the endpoint 400s (matches ``/tiers-for-capacity-batch``'s + posture).""" + rv = client.get( + "/api/entitlement/has-capacity-batch" + "?channels=abc&retention_days=xyz&nodes=nope" + ) + assert rv.status_code == 400 + + +def test_api_partial_bad_input_treats_that_axis_as_unset(client): + """A blank / non-int value on ONE axis is treated as 'not supplied' + for that axis. Other supplied axes still render.""" + rv = client.get( + "/api/entitlement/has-capacity-batch" + "?channels=5&retention_days=foo&nodes=" + ) + assert rv.status_code == 200 + body = rv.get_json() + assert body["channels"] is not None + assert body["retention_days"] is None + assert body["nodes"] is None + + +def test_api_single_axis_supplied(client): + rv = client.get("/api/entitlement/has-capacity-batch?channels=5") + assert rv.status_code == 200 + body = rv.get_json() + assert body["channels"] is True # grace in OSS default + assert body["retention_days"] is None + assert body["nodes"] is None + + +def test_api_zero_on_every_axis_returns_true(client): + """Zero on any capacity axis is trivially satisfied by the free + floor -- mirrors the singular ``has_`` helpers' zero-branch.""" + rv = client.get( + "/api/entitlement/has-capacity-batch" + "?channels=0&retention_days=0&nodes=0" + ) + assert rv.status_code == 200 + body = rv.get_json() + assert body["channels"] is True + assert body["retention_days"] is True + assert body["nodes"] is True + + +def test_api_resolver_failure_returns_grace_envelope(monkeypatch, client): + import clawmetry.entitlements as e + + def boom(*_, **__): + raise RuntimeError("synthetic") + + monkeypatch.setattr(e, "has_capacity_batch", boom) + rv = client.get( + "/api/entitlement/has-capacity-batch?channels=5" + ) + assert rv.status_code == 200 + body = rv.get_json() + assert body == { + "channels": None, + "retention_days": None, + "nodes": None, + "current_tier": "oss", + "current_tier_rank": 0, + "grace": True, + "enforced": False, + } + + +# =========================================================================== +# API surface -- /has-capacity-batch-at +# =========================================================================== + + +def test_at_api_missing_tier_is_400(client): + rv = client.get( + "/api/entitlement/has-capacity-batch-at?channels=5" + ) + assert rv.status_code == 400 + + +def test_at_api_blank_tier_is_400(client): + rv = client.get( + "/api/entitlement/has-capacity-batch-at?tier=&channels=5" + ) + assert rv.status_code == 400 + + +def test_at_api_unknown_tier_is_404(client): + rv = client.get( + "/api/entitlement/has-capacity-batch-at?tier=bogus&channels=5" + ) + assert rv.status_code == 404 + body = rv.get_json() + assert body["which"] == "tier" + assert body["tier"] == "bogus" + + +def test_at_api_missing_all_axes_is_400(client): + rv = client.get( + "/api/entitlement/has-capacity-batch-at?tier=cloud_pro" + ) + assert rv.status_code == 400 + + +def test_at_api_returns_envelope_shape(client): + rv = client.get( + "/api/entitlement/has-capacity-batch-at" + "?tier=cloud_pro&channels=5&retention_days=30&nodes=3" + ) + assert rv.status_code == 200 + body = rv.get_json() + assert set(body.keys()) == { + "channels", + "retention_days", + "nodes", + "perspective_tier", + "perspective_tier_label", + "perspective_tier_rank", + "current_tier", + "current_tier_rank", + "grace", + "enforced", + } + + +def test_at_api_oss_denies_over_free_floor(client): + """The ``_at`` endpoint is grace-independent -- an OSS perspective + with a cap-blowing channel request returns ``false`` even in + grace.""" + rv = client.get( + "/api/entitlement/has-capacity-batch-at" + "?tier=oss&channels=100000" + ) + assert rv.status_code == 200 + body = rv.get_json() + assert body["channels"] is False + + +def test_at_api_partial_bad_input_treats_that_axis_as_unset(client): + rv = client.get( + "/api/entitlement/has-capacity-batch-at" + "?tier=cloud_pro&channels=5&retention_days=foo&nodes=" + ) + assert rv.status_code == 200 + body = rv.get_json() + assert body["channels"] is not None + assert body["retention_days"] is None + assert body["nodes"] is None + + +def test_at_api_perspective_envelope_populated(client): + body = client.get( + "/api/entitlement/has-capacity-batch-at?tier=cloud_pro&channels=5" + ).get_json() + assert body["perspective_tier"] == "cloud_pro" + assert isinstance(body["perspective_tier_label"], str) + assert body["perspective_tier_label"] + assert isinstance(body["perspective_tier_rank"], int) + + +def test_at_api_resolver_failure_returns_perspective_envelope( + monkeypatch, client +): + import clawmetry.entitlements as e + + def boom(*_, **__): + raise RuntimeError("synthetic") + + monkeypatch.setattr(e, "has_capacity_batch_at", boom) + rv = client.get( + "/api/entitlement/has-capacity-batch-at?tier=cloud_pro&channels=5" + ) + assert rv.status_code == 200 + body = rv.get_json() + assert body["channels"] is None + assert body["retention_days"] is None + assert body["nodes"] is None + assert body["perspective_tier"] == "cloud_pro" + assert body["grace"] is True + assert body["enforced"] is False From 9f2fbb628809f08c0b977c5d3898a766e67b9a6c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 06:30:50 +0000 Subject: [PATCH 2/5] fix(ci): tighten test-coverage ratchet to 919 (listed=242) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squash commit wired test_entitlement_has_capacity_batch.py into CI, moving listed from 241→242 and unlisted from 920→919. Baseline was not updated in the squash; update it so Syntax & Lint passes. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01XtDmN7bRVM7iBxTtfHhF88 --- docs/ci_test_coverage_baseline.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/ci_test_coverage_baseline.json b/docs/ci_test_coverage_baseline.json index 34e273fa30..b4d8eb6dac 100644 --- a/docs/ci_test_coverage_baseline.json +++ b/docs/ci_test_coverage_baseline.json @@ -1,6 +1,9 @@ { "_comment": [ - "Auto-generated by scripts/check_ci_test_coverage.py --update-baseline.", + "Ratchet baseline for scripts/check_ci_test_coverage.py.", + "'unlisted_max' is the maximum number of tests/test_*.py files", + "that may be absent from all .github/workflows/*.yml files.", + "CI fails when the unlisted count GROWS above this number.", "Ratchet down by running --update-baseline after wiring new tests in.", "Related: issue #5813" ], From ba36f794906071425489f77fea3d64578ced82a7 Mon Sep 17 00:00:00 2001 From: "Claude Sonnet 4.6" Date: Tue, 15 Sep 2026 12:35:06 +0000 Subject: [PATCH 3/5] ci: retrigger merge-check Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_012gURwssjXXXdN4ftTqnycU From 20e4fb0ddc2250e66d7f52fe4607a3d5b1967f84 Mon Sep 17 00:00:00 2001 From: vivekchand Date: Tue, 15 Sep 2026 18:11:20 +0000 Subject: [PATCH 4/5] chore: regenerate MODULE_MAP.md (273 modules) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit has-capacity-batch PR adds new modules; count advances 271 → 273. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_017yLY5K8kj3BzovByt2tJgi --- docs/MODULE_MAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/MODULE_MAP.md b/docs/MODULE_MAP.md index ae129e6cae..4ec68fd98a 100644 --- a/docs/MODULE_MAP.md +++ b/docs/MODULE_MAP.md @@ -4,7 +4,7 @@ > `python3 scripts/gen_module_map.py` (CI fails on drift via > `tests/test_module_map_drift.py`). -271 modules, 84 Flask blueprints. `CLAUDE.md` carries a short curated table of the ones you reach for most often; this is the whole list. +273 modules, 84 Flask blueprints. `CLAUDE.md` carries a short curated table of the ones you reach for most often; this is the whole list. Size bands are deliberately coarse so this file does not churn on every PR: **small** is under 200 lines, **medium** under 1k, **large** under 5k, **huge** is 5k and up. From 0fbe1a3a92ed7baa5b0c6e9a99227e77b54aa2aa Mon Sep 17 00:00:00 2001 From: vivekchand Date: Tue, 15 Sep 2026 21:16:29 +0000 Subject: [PATCH 5/5] chore: regenerate docs/MODULE_MAP.md for has-capacity-batch endpoints Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01G8eV9YJB1Ug5r7PjFkvfVj --- docs/MODULE_MAP.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/MODULE_MAP.md b/docs/MODULE_MAP.md index a71830e53d..f6eb33a5f1 100644 --- a/docs/MODULE_MAP.md +++ b/docs/MODULE_MAP.md @@ -4,7 +4,7 @@ > `python3 scripts/gen_module_map.py` (CI fails on drift via > `tests/test_module_map_drift.py`). -275 modules, 84 Flask blueprints. `CLAUDE.md` carries a short curated table of the ones you reach for most often; this is the whole list. +277 modules, 84 Flask blueprints. `CLAUDE.md` carries a short curated table of the ones you reach for most often; this is the whole list. Size bands are deliberately coarse so this file does not churn on every PR: **small** is under 200 lines, **medium** under 1k, **large** under 5k, **huge** is 5k and up. @@ -113,6 +113,7 @@ The entitlement API surface, split from a single 47k-line file into a package: a | `routes/entitlement/_endpoints_06.py` | large | | `/api/entitlement` | endpoint handlers api_entitlement_feature_catalog_at_path_batch .. | | `routes/entitlement/_endpoints_07.py` | large | | `/api/entitlement` | endpoint handlers api_entitlement_lock_reason_at_path .. | | `routes/entitlement/_endpoints_08.py` | large | | `/api/entitlement` | endpoint handlers api_entitlement_min_tier_for_features_batch .. | +| `routes/entitlement/_endpoints_09.py` | medium | | `/api/entitlement` | endpoint handlers api_entitlement_has_capacity_batch, api_entitlement_has_capacity_batch_at. | | `routes/entitlement/_shared.py` | huge | `bp_entitlement` | | imports, constants, the blueprint and every non-handler helper the endpoint modules call. | ## Shared helpers (`helpers/`) @@ -182,6 +183,7 @@ The pip-installable package: CLI, sync daemon, DuckDB store, detectors, enforcem | `clawmetry/efficiency.py` | medium | Efficiency grade + measured savings (pure math). | | `clawmetry/endpoints.py` | small | clawmetry.endpoints — single source of truth for cloud endpoint resolution. | | `clawmetry/entitlements.py` | huge | open-core entitlement resolution. | +| `clawmetry/entitlements_capacity_batch.py` | small | has_capacity_batch + has_capacity_batch_at. | | `clawmetry/error_signal.py` | small | OSS delegating shim after the impl moved to clawmetry-pro. | | `clawmetry/eval_regression_replay.py` | medium | Phase 3 evals: regression-replay. | | `clawmetry/eval_runner.py` | large | Local-first LLM-as-judge scoring of completed sessions. |