Skip to content

feat(entitlement): has_all_breakdown + endpoint — per-axis blocking breakdown - #5134

Merged
vivekchand merged 3 commits into
mainfrom
feat/has-all-breakdown
Aug 24, 2026
Merged

feat(entitlement): has_all_breakdown + endpoint — per-axis blocking breakdown#5134
vivekchand merged 3 commits into
mainfrom
feat/has-all-breakdown

Conversation

@vivekchand

Copy link
Copy Markdown
Owner

Summary

Boolean-fold twin of min_tier_for_all_breakdown. Closes the last per-axis-breakdown gap in the has_/min_tier_for_ pair: the reverse-lookup family already surfaces which axis is binding the aggregate required-tier floor; the boolean-fold family had no counterpart for which axis is blocking the LIVE aggregate grant. A UI wanting the pair had to fan out five singular has_* scalars plus a client-side which-axis-is-false walk.

What lands

One helper in clawmetry/entitlements.py, sitting immediately after min_tier_for_all_breakdown so the pair reads side by side:

has_all_breakdown(*, features=None, runtimes=None, channels=None, retention_days=None, nodes=None) -> {
  "has_all":       bool,                    # matches has_all(**kwargs) byte-for-byte
  "axes": {
    "features":       <axis_row> | None,    # None iff axis unsupplied
    "runtimes":       <axis_row> | None,
    "channels":       <axis_row> | None,
    "retention_days": <axis_row> | None,
    "nodes":          <axis_row> | None,
  },
  "blocking_axes": ["channels"] | [],       # axis ids whose per-axis has is False
                                            # when the aggregate is False;
                                            # empty on has_all=True and on the
                                            # nothing-supplied edge
}

Grant-axis row: {kind, supplied, items, unknown, has, blocking}items holds normalised known ids the fold saw, unknown holds typo tokens the singular has_features/has_runtimes scalars would collapse the axis on (so a tooltip can render typo: *Fleeet* instead of a bare denial). Capacity-axis row: {kind, supplied, value, has, blocking}value echoes the parsed int, or the raw input if it did not parse, mirroring min_tier_for_all_breakdown.

Endpoint in routes/entitlement.py, mirroring /api/entitlement/required-tier-breakdown's posture:

  • GET /api/entitlement/has-all-breakdown?features=…&runtimes=…&channels=N&retention_days=K&nodes=M

400 when nothing is supplied; 200 with the per-axis envelope otherwise. A blank or non-int capacity value still surfaces the axis in the response (with has=false and the raw input in value) instead of 400-walling — matches the never-crash posture of the singular /has-* endpoints. retention_days= is unset, NOT unlimited (matches has_all); asking about the unlimited-retention live grant stays the singular /api/entitlement/has-retention-window call's job.

Envelope adds current_tier{,_rank}, grace, enforced alongside the breakdown fields so a caller wiring this next to the singular /has-* endpoints sees the same shape and can adopt the breakdown without reshaping its diagnostics payload. Never 5xxs — resolver failure returns the OSS-fallback envelope (has_all=false, empty blocking_axes, every axis null).

Grace / open-core posture

Ships in GRACE like every other entitlement helper. Zero current behaviour change: while ent.grace is True every fully-known bundle folds to has_all=True with empty blocking_axes (matches has_all byte-for-byte). Unknown grant tokens, empty grant axes, and non-int capacity axes still collapse the fold to False even in grace (matches the singular scalars' strict-False typo posture), and the corresponding axis is surfaced in blocking_axes with the typo detail so a tooltip can flag it. No version bump, no release tag.

Pairing invariants

axes echoes match min_tier_for_all_breakdown on kind, supplied, items (grants), and value (capacities) so a paywall tooltip that pairs the two envelopes renders coherently: "denied here BECAUSE of axis Y" (this helper's blocking_axes) alongside "cheapest tier that would grant it is Z BECAUSE of axis W" (reverse-lookup breakdown's binding_axes). One test pins that invariant so a future breakdown-shape change on either side breaks loudly.

Tests

New tests/test_entitlement_has_all_breakdown.py (35 tests):

  • envelope shape (has_all/axes/blocking_axes only; axes keys are the five axes)
  • has_all-parity across the five axes plus the mixed bundle
  • empty grant axis / unknown grant token / non-int capacity: all collapse to has_all=False with the axis surfaced as blocking (grace-independent typo posture)
  • blocking_axes identification: empty on has_all=True, single-axis denial, multi-axis denial in envelope order, empty on the nothing-supplied edge
  • per-axis row shape (kind/supplied/has/blocking + items+unknown for grants, value for capacities)
  • grace still denies unknowns / empties / non-int on the corresponding axis
  • axes-echo parity with min_tier_for_all_breakdown for pair-invariant paywall rendering
  • never-raise contract: garbage inputs + a delegate raising mid-fold
  • HTTP envelope: 400 on no args, 200 shape, mixed-bundle body matches the helper, bad-capacity 200 (not 400), unknown-feature blocking, grace/enforced/current_tier defaults
$ python -m pytest tests/test_entitlement_has_all_breakdown.py -q
35 passed in 0.98s

$ python -m pytest tests/ -q -k "entitlement"
10967 passed, 9 skipped (3 pre-existing env errors in test_license/test_license_audit/test_local_trial from a cryptography package conflict in this sandbox, unrelated to this diff)

$ ruff check clawmetry/entitlements.py routes/entitlement.py tests/test_entitlement_has_all_breakdown.py
All checks passed!

$ python3 scripts/check_py39_annotations.py
OK — no py3.9-fatal unions (197 files scanned)

$ python3 scripts/check_ac_coverage.py --check
AC traceability gate OK: 9/77 criteria covered, 68 uncovered (ratchet holding)

Local operator verification checklist

The autonomous fire can't touch the local daemon, the browser, or the live cloud snapshot. Once CI is green, please verify locally:

  1. Sync the working tree into the installed daemon site-packages (adjust for your Python version):
    PYVER=$(python3 -c 'import sys; print("%d.%d" % sys.version_info[:2])')
    DEST=~/.clawmetry/lib/python$PYVER/site-packages
    cp clawmetry/entitlements.py "$DEST/clawmetry/entitlements.py"
    cp routes/entitlement.py     "$DEST/routes/entitlement.py"
    find "$DEST/clawmetry" "$DEST/routes" -name __pycache__ -type d -exec rm -rf {} +
  2. Restart the sync daemon:
    launchctl kickstart -k gui/$(id -u)/com.clawmetry.sync
  3. Hit the new endpoint against the local dashboard (assumes default port 8900 and env token):
    # fully-known bundle in grace → has_all=true, no blockers
    curl -s "$CLAWMETRY_URL/api/entitlement/has-all-breakdown?features=fleet&runtimes=openclaw&channels=1&retention_days=7&nodes=1" \
      -H "Authorization: Bearer $CLAWMETRY_TOKEN" | jq .
    
    # unknown feature → has_all=false, features axis blocking, typo surfaced in axes.features.unknown
    curl -s "$CLAWMETRY_URL/api/entitlement/has-all-breakdown?features=bogus-feature" \
      -H "Authorization: Bearer $CLAWMETRY_TOKEN" | jq .
    
    # non-int capacity → 200 with axes.channels.value="five" and channels in blocking_axes
    curl -s "$CLAWMETRY_URL/api/entitlement/has-all-breakdown?features=fleet&channels=five" \
      -H "Authorization: Bearer $CLAWMETRY_TOKEN" | jq .
    
    # no args → 400 error envelope
    curl -si "$CLAWMETRY_URL/api/entitlement/has-all-breakdown" \
      -H "Authorization: Bearer $CLAWMETRY_TOKEN" | head -20
    Expected: the first call returns has_all=true, grace=true, enforced=false, blocking_axes=[], and each of the five axes surfaces with has=true, blocking=false. The second returns has_all=false with blocking_axes=["features"] and axes.features.unknown=["bogus-feature"]. The third returns has_all=false with blocking_axes containing channels and axes.channels.value="five". The fourth returns 400 with an error field naming the five accepted axes.
  4. Decrypt the live cloud snapshot in the browser and confirm nothing that used to render still renders — the helper and endpoint are additive, so no existing UI should change.
  5. Confirm no behaviour drift on /api/entitlement, /api/entitlement/required-tier-breakdown, or any singular /api/entitlement/has-* endpoint (none of them were touched).

Draft only — leaving merge, [RELEASE] PR, tag, and cloud rollout to the operator.


Generated by Claude Code

…reakdown

Boolean-fold twin of min_tier_for_all_breakdown. Where the reverse-lookup
breakdown identifies which axis is *binding* the aggregate min-tier floor,
this identifies which axis (or axes) is *blocking* the LIVE aggregate
grant — so a paywall diagnostics tile can render "denied here BECAUSE of
channels (you asked for 8)" off ONE round-trip alongside "cheapest tier
that would grant it is Pro BECAUSE of channels" from the reverse-lookup
companion.

Closes the boolean-fold breakdown gap: has_all folds five axes to ONE
bool, min_tier_for_all_breakdown surfaces the per-axis binding story for
the reverse-lookup family, but no per-axis blocking story existed for
has_all. A UI wanting the pair had to fan out five singular has_*
scalars plus a client-side which-axis-is-false walk.

Ships in GRACE: while ent.grace is True every fully-known bundle folds
to has_all=True with empty blocking_axes (matches has_all byte-for-byte),
so wiring this into a paywall diagnostics tile today changes NO current
behaviour. Unknown grant tokens, empty grant axes, and non-int capacity
axes still collapse the fold to False even in grace (matches the
singular scalars' strict-False typo posture), and the corresponding axis
is surfaced in blocking_axes with the typo detail (unknown[] on grant
axes / raw value on capacity axes) so a tooltip can flag it.

Endpoint: GET /api/entitlement/has-all-breakdown?features=…&runtimes=…
&channels=N&retention_days=K&nodes=M — mirrors the required-tier-
breakdown 400/200 posture, never 5xxs (OSS-fallback envelope on
resolver failure), preserves bad capacity input in axis rows instead
of dropping it (so the tooltip can flag typos).

Tests: tests/test_entitlement_has_all_breakdown.py (35 tests) — envelope
shape, has_all-parity across the five axes, blocking-axis identification
(single-axis, multi-axis-ordered, empty-on-True, empty-on-nothing-supplied),
per-axis row shape (kind/supplied/has/blocking + items+unknown for grants,
value for capacities), grace-still-denies posture on unknowns / empties /
non-int, axes-echo parity with min_tier_for_all_breakdown for
pair-invariant paywall rendering, never-raise contract, full HTTP
envelope + 400/200 matrix.

- 35/35 passing
- Broader entitlement suite: 10967 passed, no regressions
- ruff, py39-annotation guard, and AC ratchet all green

Draft PR — merge, [RELEASE] tag, and cloud rollout are the operator's.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Visual diff

Comparing 08b89d7c39da (head) against the PR base branch.

22 of 66 comparison(s) flagged (>1% pixel diff).

View Before After Diff
desktop overview ⚠️ before after diff · 100.00%
desktop flow before after diff · 0.07%
desktop brain before after diff · 0.00%
desktop usage before after diff · 0.00%
desktop crons ⚠️ before after diff · 3.52%
desktop memory ⚠️ before after diff · 3.08%
desktop security ⚠️ before after diff · 2.49%
desktop subagents before after diff · 0.02%
desktop transcripts before after diff · 0.32%
desktop logs ⚠️ before after diff · 5.89%
desktop skills before after diff · 0.20%
desktop models before after diff · 0.01%
desktop approvals ⚠️ before after diff · 3.14%
desktop alerts ⚠️ before after diff · 2.80%
desktop notifications before after diff · 0.27%
desktop limits before after diff · 0.18%
desktop clusters before after diff · 0.26%
desktop history before after diff · 0.01%
desktop channels before after diff · 0.02%
desktop dives before after diff · 0.26%
desktop harness before after diff · 0.00%
desktop inventory before after diff · 0.00%
desktop nemoclaw before after diff · 0.27%
desktop policy before after diff · 0.27%
desktop selfevolve before after diff · 0.00%
desktop swimlane before after diff · 0.27%
desktop tool-catalog before after diff · 0.01%
desktop tracing before after diff · 0.27%
desktop turn-anatomy before after diff · 0.27%
desktop version-impact before after diff · 0.26%
desktop context-economics before after diff · 0.12%
desktop agents before after diff · 0.26%
desktop evals before after diff · 0.24%
mobile overview ⚠️ before after diff · 100.00%
mobile flow before after diff · 0.00%
mobile brain before after diff · 0.01%
mobile usage ⚠️ before after diff · 100.00%
mobile crons before after diff · 0.00%
mobile memory ⚠️ before after diff · 100.00%
mobile security ⚠️ before after diff · 100.00%
mobile subagents before after diff · 0.01%
mobile transcripts before after diff · 0.01%
mobile logs ⚠️ before after diff · 100.00%
mobile skills before after diff · 0.01%
mobile models before after diff · 0.73%
mobile approvals ⚠️ before after diff · 1.11%
mobile alerts ⚠️ before after diff · 100.00%
mobile notifications ⚠️ before after diff · 100.00%
mobile limits before after diff · 0.02%
mobile clusters ⚠️ before after diff · 100.00%
mobile history ⚠️ before after diff · 1.06%
mobile channels ⚠️ before after diff · 1.91%
mobile dives before after diff · 0.02%
mobile harness ⚠️ before after diff · 100.00%
mobile inventory before after diff · 0.01%
mobile nemoclaw ⚠️ before after diff · 100.00%
mobile policy ⚠️ before after diff · 100.00%
mobile selfevolve before after diff · 0.02%
mobile swimlane before after diff · 0.00%
mobile tool-catalog before after diff · 0.01%
mobile tracing ⚠️ before after diff · 100.00%
mobile turn-anatomy before after diff · 0.01%
mobile version-impact before after diff · 0.02%
mobile context-economics before after diff · 0.02%
mobile agents before after diff · 0.01%
mobile evals before after diff · 0.00%

Folder: 08b89d7c39da. Full PNGs also attached as a workflow artefact.

Generated by visual-diff bot. Pixel diffs >1% flagged; eyeball the table before merging. This check is non-blocking — fail = bot bug, not a code problem.

Copy link
Copy Markdown
Owner Author

Stale E2E Gate cancellation — infra, not a code problem

CI on 5bc70cad: 29 real checks green (Syntax & Lint, PR build, API Tests × Ubuntu/macOS/Windows, Entitlement API tests, MOAT Verifier, MOAT Keystone, Store invariants, OSS golden path, E2E Browser Tests, Live OpenClaw E2E, pip install matrix on 3.9 + 3.11 across OSes, wheel install & asset presence, C6 required-status-checks gate, dep audit, no-third-party-assets, install+boot health-check, and more) — OpenSSF Scorecard skipped as usual on a branch.

The one red mark is E2E Gate (required), conclusion cancelled. Job log shows ##[error]The operation was canceled. at 16:25:10Z with no test-body failure — the gate poller had been running silently for 18 min (well inside its 30 min MAX_WAIT) with all the underlying checks it aggregates already green (last one, Entitlement API tests, finished 16:20:24Z). The runner infra terminated the poll job before it could report success. Same "runner shutdown / infra cancel" pattern that hit PR #5114's visual-diff job earlier today; no push has landed since, so this isn't a concurrency replacement.

Effect: the sole context branch protection names is stale-cancelled, so this PR can't merge as-is until the gate re-runs green — but the actual tests pass, so a re-run alone should clear it.

Autonomous fire cannot re-trigger the workflow itself (403 Resource not accessible by integration on rerun_workflow_run), so this is a heads-up for the operator: alongside the local verification checklist in the PR body, please gh run rerun 32650667645 (or the Actions UI equivalent) once ready to merge — the underlying test surface is already green, the gate just needs to observe it in a fresh poll.


Generated by Claude Code

github-actions Bot pushed a commit that referenced this pull request Aug 23, 2026

Copy link
Copy Markdown
Owner Author

E2E Gate cancelled again on the merge-main head — same 18-min pattern, not a one-off

Updating my earlier note. The gate was cancelled a second time on a56a72491bed:

Head Gate started Gate cancelled Duration
5bc70cad 16:06:57Z 16:25:10Z 18m 13s
a56a72491bed 18:39:26Z 18:57:42Z 18m 16s

Identical duration to the second, on two different head shas. All the checks the gate polls for landed green well before the cancellation on both runs (on this one, Entitlement API tests — the longest — completed at 18:52:52Z, 5 min before the gate died). That's reproducible, not a one-off runner-shutdown flake, and it's inside the gate's own 30-min MAX_WAIT, so the poller is being terminated by something above it — probably a workflow / concurrency / GitHub-side cancellation cap the gate script isn't accounting for.

Not proposing a fix here since this touches .github/workflows/ (well outside this PR's diff, which is entitlements-only), and the autonomous fire can't rerun_workflow_run on its own (403 Resource not accessible by integration). Merge is blocked by branch protection until this gate is green — since the real test surface is confirmed green (all 29 other checks pass on this commit), a manual re-run should clear it, but a fix to the gate workflow itself would stop the pattern from reappearing on every push to this branch.

Ideas worth checking:

  • workflow-level timeout-minutes on the gate job set to ~18
  • concurrency group set to ${{ github.workflow }}-${{ github.ref }} that cancels the gate when the visual-diff completes on the same ref (visual-diff finished at 18:49:22Z on this run, ~8 min before the gate cancel — not exact but suggestive)
  • app-level cancellation cap on same-ref long-runners

Generated by Claude Code

@vivekchand
vivekchand marked this pull request as ready for review August 23, 2026 21:46
@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

Copy link
Copy Markdown
Owner Author

3-hour open-core fire tick: no new draft PR opened this cycle.

Roadmap state on the public repo:

  • P1 (entitlements.py + FREE_RUNTIMES + Entitlement dataclass + get_entitlement() + /api/entitlement + extensions.load_plugins() wired from dashboard.py): landed in prior fires — clawmetry/entitlements.py:91 (FREE_RUNTIMES = frozenset({"openclaw", "nemoclaw", "goose"})), dashboard.py:337 (plugin load), routes/entitlement.py shipping the /api/entitlement* surface.
  • P2 (license.py Ed25519 verify + clawmetry activate + clawmetry license): landedclawmetry/license.py (226K), cli.py:5213 _cmd_activate, cli.py:5288 _cmd_license, subparsers at cli.py:7779 / cli.py:7814.
  • Elaboration PRs still awaiting operator: this PR (feat(entitlement): has_all_breakdown + endpoint — per-axis blocking breakdown #5134, has_all_breakdown — CI 100% green, drift-bot clean) and feat(entitlement): has_capacity_batch + has_capacity_batch_at + endpoints #5114 (has_capacity_batch{,_at} — CI 100% green, only drift-bot's "not in the private-repo blueprint" non-blocking flag).
  • P3 / P4 / P6 (cloud plan lookup, closed-source clawmetry-pro wheel, enterprise): unreachable from this repo — they need the private clawmetry-cloud or the not-yet-existing clawmetry-pro repo, which the autonomous fire has no access to.

Nothing new I can usefully author here without duplicating the two open PRs already sitting in your queue. Leaving those to the local operator verification checklist in each PR body.


Generated by Claude Code

@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

Copy link
Copy Markdown
Owner Author

CI block (transient): E2E Gate (required) was cancelled after ~18 min (run 32658723046) — the e2e_gate.py polling process was orphaned and killed, likely by a concurrency-group cancellation. All substantive checks (Syntax & Lint, API Tests, MOAT, Entitlement, pip install matrix, E2E Browser Tests, etc.) passed on this commit.

Action needed: manually rerun the cancelled E2E Gate job on that run to unblock the PR.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

✨ auto-fixed: branch was behind main (base 990e720e197935) — merged main in via update_pull_request_branch to unblock required up-to-date check.


Generated by Claude Code

github-actions Bot pushed a commit that referenced this pull request Aug 24, 2026
@vivekchand
vivekchand merged commit 3dd3e8f into main Aug 24, 2026
32 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants