Skip to content
Back to skills

Entitlements

ASecurity

The OwlMeans entitlement model across @owlmeans/payment, @owlmeans/server-payment and @owlmeans/web-payment — ranked plans with a real free plan, capabilities versus counted limits, the three limit kinds, promos and grandfathering, the admission-first usage ledger and its may-over-count-never-over-admit invariant, the entitlement view the server gate and the browser both read, the two gate services, and the refusal errors. Use when deciding what a plan grants, gating a route on a feature or a...

  • 3 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 22, 2026
businessperformance

Works with

  • terminal

Security analysis

A100/100

Scanned October 6, 2026

npx -y skills add owlmeans/common --skill entitlements --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Entitlements?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Entitlements
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/owlmeans-entitlements/badge)](https://www.skillsdirectory.com/skills/owlmeans-entitlements)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: entitlements
description: "The OwlMeans entitlement model across @owlmeans/payment, @owlmeans/server-payment and @owlmeans/web-payment — ranked plans with a real free plan, capabilities versus counted limits, the three limit kinds, promos and grandfathering, the admission-first usage ledger and its may-over-count-never-over-admit invariant, the entitlement view the server gate and the browser both read, the two gate services, and the refusal errors. Use when deciding what a plan grants, gating a route on a feature or a quota, consuming or releasing a limit, or rendering plan state in a UI."
user-invocable: false
---

# Entitlements — what a plan lets an entity do

An entity (the organization) always holds exactly one **effective plan**. The plan grants
**capabilities** (may it do X?) and **limits** (how many X may it do?). A route declares what it
needs; a gate refuses before the handler runs; the handler consumes. The browser renders the same
answer from the same pure functions, so a disabled button and a 403 cannot disagree.

## Where each half lives

| Layer | Package | Owns |
|---|---|---|
| Contracts | `@owlmeans/payment` | Plan declarations, `LimitKind`/`LimitWindow`, promos, the param grammars, the view builders, the refusal errors, `ENTITLEMENT_GATE`/`LIMIT_GATE` |
| Server | `@owlmeans/server-payment` | The subscription store, plan resolution, the usage ledger and its counters, the two gate services, reconciliation, the paygate lifecycle |
| Browser | `@owlmeans/web-payment` | Hooks over a served entitlement view (`useEntitlementView`, `useCapability`, `useLimit`, `usePortal`) and presentational pieces |

## Plans

- **Rank orders a product's plans.** A higher `rank` is an upgrade; a plan change is classified
  as upgrade/downgrade by rank. No two paid plans of one product share a rank.
- **The free plan is a real plan** — `free: true`, `price: 0`, `gateways: []`, lowest rank. The
  application grants it as an internal subscription row (`paygate: INTERNAL_PAYGATE`) when an
  entity is created, and plan resolution falls back to it when no entitling row exists. With no
  free plan declared and no entitling row, resolution throws `PlanRequired` — a setup fault, not a
  refusal.
- **The effective plan** is the highest-ranked subscription in `ENTITLING_STATUSES`
  (`Active`, `Trial`, `PastDue`). `PastDue` still entitles and is flagged; `Suspended` revokes
  until resumed; terminal statuses fall back to the free plan.
- **One subscription may hold other ceilings than its plan declares**: the row's `overrides.limits`
  replace the plan's ceilings for that subscription only, promo or not (the `server-payment` skill,
  § The subscription store). Read a plan's limits from the effective plan or the entitlement view,
  never from the catalogue by sku, or an override is missed.
- A plan names its keys, never a product's copy: every limit key an application gates on appears
  on every plan (with `limit: 0` where not included), so "not included" is an answer, not a
  missing row.

## Capabilities versus limits

| | Capability | Limit |
|---|---|---|
| Declared as | `PlanCapability` (a permission set) | `LimitDeclaration` under a key in `plan.limits` |
| Asked as | `[scope:]permission[>=n]` | `limit:<key>[>=n]` |
| Gate alias | `ENTITLEMENT_GATE` | `LIMIT_GATE` |
| Answer | granted or not | room left in the current window |
| Changes when | the plan changes | something is consumed or released |

- `limit` is a reserved capability scope: no capability can answer a limit parameter.
- The two gates are different aliases because an entrypoint's gates are collected per gate
  service; one alias would let one requirement hide the other.
- Several parameters on one gate are OR'd.

## The three limit kinds

| Kind | Counter window | Renews |
|---|---|---|
| `window` (`day` / `month`) | `YYYY-MM-DD` / `YYYY-MM`, calendar UTC | at the UTC boundary; `resetsAt` is the exclusive start of the next window |
| `lifetime` | `lifetime` | never — the count belongs to the entity and survives plan changes, so an upgrade reads "1 of 4 used" |
| `occupancy` | `occupancy` | never — a held count, `+1` on acquire and `-1` on release, reconciled against what actually exists |

Windows are calendar UTC, never rolling and never local.

## Promos and grandfathering

`promo: { until, grandfather? }` on a capability set or a limit is plan metadata resolved per
subscription:

- in force while `now < until` (`now === until` is over);
- with `grandfather`, also for a subscription created before `until`, for as long as it lasts;
- lapsed: the capability is listed `granted: false`, the limit reads `limit: 0`, and the promo
  view says `active: false` — the UI says the promotion ended.

The server must put the subscription's creation date on the plan view (`subscribedAt`); without it
nothing is grandfathered.

## The usage ledger — admission first

Limits are event-sourced with synchronous admission:

- **The ledger of usage events is the source of truth; the counter per (entity, key, window) is a
  projection.**
- **Consume** increments the counter FIRST with an atomic conditional update (only while
  `used <= limit - amount`), then appends the event keyed by an idempotency `eventKey`. No update
  ⇒ `LimitExhausted`. A duplicate `eventKey` undoes the increment and replays the earlier outcome.
- **Release** appends first, then decrements (never below zero), and is idempotent.
- **Invariant: the counter may over-count, never over-admit.** A crash between the increment and
  the event leaves a unit counted that nothing spent — the periodic reconciliation recomputes
  counters from the ledger (and occupancy from reality). The opposite order would let two
  concurrent requests both pass a read-then-write check.
- **Key a consumption by the record it pays for** (`eventKey = <purpose>:<recordId>`,
  `ref = recordId`). A retry, a resumed run or a second attempt on the same record replays the same
  event instead of spending a second unit, and "was a unit already spent on this record?" is a
  ledger read rather than a marker written onto the record.
- Occupancy is reconciled against the live count; an entity over its ceiling is flagged
  (`overSince`) and the application decides what to do after a grace period — a cron never
  silently deletes customer work.

## The gates — the gate refuses, the handler consumes

- The **capability gate** passes when the effective plan grants any parameter. The subscription
  is the authority: a token's explicit `false` for a permission denies, a token's own grant never
  allows, and requiring the token permission as well is an opt-in gate option. Store errors
  refuse.
- The **limit gate** passes when any declared parameter has `remaining >= atLeast`. It **never
  consumes** — checking room and spending it are different moments, and only the handler knows
  whether the work actually started. Unparseable parameters are skipped; none left ⇒ refuse.
- The **handler consumes** with a record-keyed `eventKey` right before the paid work starts, and
  releases (or relies on the key's replay) when the work is undone.
- Declare the requirement on the protocol, never inside a handler, so the route table states what
  a feature costs.

## The entitlement view

One read of an entity's position, built by
`entitlementViewHelper.entitlementViewOf(plan, planView, usage, at)`:

- `plan` — sku, rank, free, status, paygate, period, trial, cancel-at-period-end, past-due,
  fallback plan;
- `capabilities` — one row per granted permission, `param` exactly as the gate takes it;
- `limits` — one row per declared key with `limit`, `used`, `remaining` (floored at 0), and
  `windowStart`/`resetsAt` for window limits;
- `at` — when it was computed.

The server serves it; the browser reads it with the same `planLimitHelper.capabilityOf` /
`limitOf` / `hasLimitRoom` the gate logic uses. On the wire every date is an ISO string — revive
(`entitlementViewHelper.reviveEntitlementView`) before using a date. In the browser `null` means
"not known yet": render paid controls disabled rather than enabled-then-refused, and an application
with its own store reads the view through the pure selectors rather than polling it a second time.

## Refusals

| Error | Means | HTTP | Fields (rebuilt after a hop) |
|---|---|---|---|
| `CapabilityRequired` | none of the capability parameters is granted | 403 | `params` |
| `LimitExhausted` | no room in the current window | 403 | `limitKey`, `used`, `limit`, `resetsAt?` |
| `LimitUnknown` · `LimitMisdeclared` · `PlanRequired` · `PlanRankConflict` | configuration faults | 500 | — |

- Both refusals extend `AuthForbidden` through `EntitlementRefusal` — that is what turns them into
  a 403 at the HTTP boundary.
- Only `type` and `message` cross a service hop; the refusals pack their fields into the message
  and rebuild them on unmarshal. Catch the class after `ResilientError.ensure`.
- A UI phrases a refusal from `errors.<type>` and its fields (the reset date of an exhausted
  window), never from the message text.
- Not entitlement refusals: the consumer-rights refusals (`PerformanceConsentRequired`,
  `SubscriptionStartRequired` 428; `BillingCountryLocked`, `WithdrawalUnavailable`,
  `CancellationUnavailable` 409) and `CheckoutLimitExceeded` (409) declare their status and never
  extend `AuthForbidden` — the `payment` skill, § Consumer rights.

## Related

- [[payment]] — the contracts in detail: grammars, window algebra, schemas
- [[server-payment]] — the Stripe lifecycle, the subscription store, the ledger and gates
- [[web-payment]] — the hooks and pieces
- [[error]] — marshalling and the error registry

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…