Entitlements
What a subject may do and how much, computed from defaults and dated grants; flow quotas counted per grant, the diffusion debit, and the degraded mode of resources above a stock ceiling.
The entitlements module answers "may this subject do this action, now?". A subject (an account) starts from the defaults; every paid or hand-written allowance is an EntitlementGrant, a dated contribution that widens the defaults while its window is open. Features are booleans, limits are numbers. Stock limits cap what exists (counted live, never stored); flow limits are credits consumed over a window and counted on per-grant counters. Nothing is ever stored as an "effective entitlements" row: the value is resolved at an instant from the grants active then.
Consumers: the API (the /me endpoint, write-site enforcement, the serve resolution and the telemetry landing), the admin (grants, previews, usage), the teacher app (capabilities and the stock overflow banner), the jobs that reconcile subscriptions (Subscriptions) and the public player (campaign-paid serves).
| Layer | Import path |
|---|---|
| Domain | @workspace/training/domain/entitlements |
| Application | @workspace/training/application/entitlements |
| Errors | LimitReachedError, FeatureDisabledError, ResourceDegradedError from @workspace/training |
None of the services of this module take a ServiceContext: the subject is always an explicit subjectId argument, because the callers (webhooks, telemetry batches, the serve resolver) often act on behalf of an account that is not the caller.
Domain
Keys
A closed catalogue. Features are flags; limits split by nature (stock or flow) and, for stock, by scope (subject or parent). The scope is encoded in the key partition so that Action can require a parentId at compile time.
| Export | Definition |
|---|---|
FEATURE_KEYS | ["custom_branding", "usage_analytics", "ad_free", "author_catalog"]. author_catalog is the provisional flag showing the catalog in the app, see Catalog. custom_branding is the owner's visuals on public pages (not consumed yet); usage_analytics opens the detail section of the usage page; ad_free means the subject never sees ads on the player, whoever owns the deal: the table shows the house visuals and the subject pays the serve |
PARENT_SCOPED_STOCK_LIMIT_KEYS | deals_per_collection_max, collections_per_session_max, members_per_sharing_group_max, students_per_group_max: counted under a parent entity |
SUBJECT_SCOPED_STOCK_LIMIT_KEYS | collections_per_user_max, groups_per_user_max, students_per_user_max: counted across the rows the subject owns |
STOCK_LIMIT_KEYS | Parent-scoped then subject-scoped keys |
FLOW_LIMIT_KEYS | ["diffusions_per_period"]: ad-free serves debited per period |
LIMIT_KEYS | Stock then flow keys |
DIFFUSIONS_PER_PERIOD | The FlowLimitKey "diffusions_per_period" |
SERVE_DEBIT_AMOUNT | 1: one serve, one unit, charged to a grant of the owner or to a campaign |
LIMIT_KEYS_FOR_CAPABILITIES | The subject-scoped stock keys for which the server emits a "can add one more" flag on /me/entitlements; parent-scoped capabilities live on the parent's DTO |
isFeatureKey(key: string) | Type guard to FeatureKey |
isLimitKey(key: string) | Type guard to LimitKey |
isStockLimit(key: LimitKey) | Type guard to StockLimitKey |
isFlowLimit(key: LimitKey) | Type guard to FlowLimitKey |
isParentScopedStockLimit(key: StockLimitKey) | Type guard to ParentScopedStockLimitKey |
Types: FeatureKey, LimitKey, StockLimitKey, FlowLimitKey, ParentScopedStockLimitKey, SubjectScopedStockLimitKey.
LIMIT_KEYS.length; // => 8: seven stock limits and one flow limit
isLimitKey("groups_per_user_max"); // => true
isLimitKey("custom_branding"); // => false: a feature, not a limit
isStockLimit("diffusions_per_period"); // => false: a flow limit
isParentScopedStockLimit("deals_per_collection_max"); // => true: counted under a collection
isParentScopedStockLimit("collections_per_user_max"); // => false: counted on the subjectEntitlements, Usage, Inventory
The runtime shape used by the decision logic. It differs from the stored JSON: features is a Set, and unlimited is Number.POSITIVE_INFINITY (the JSON sentinel is null), so current < limit needs no special case.
Prop
Type
Usage is Record<LimitKey, number>: the current usage per limit for the read-only /me view. Inventory holds what the subject owns that no limit caps, shown as a stock figure without a ceiling.
Prop
Type
Action and CheckResult
An Action is what a subject wants to do:
| Variant | Fields | Rule |
|---|---|---|
use_feature | feature: FeatureKey | Allowed when the feature is in the set |
create_resource (parent-scoped) | limit: ParentScopedStockLimitKey, parentId: string, amount?: number | Allowed when current + amount <= max, amount defaulting to 1; current is counted under parentId |
create_resource (subject-scoped) | limit: SubjectScopedStockLimitKey, amount?: number, no parentId | Same rule, current counted on the subject |
set_resource_count | limit: StockLimitKey, count: number | Replaces a parent's children with exactly count rows; allowed when count <= max, no measurement needed |
CheckResult is { allowed: true } or { allowed: false, reason, limitKey?, current?, max?, featureKey? }. CheckDenyReason is "feature_disabled" | "limit_reached" | "not_authenticated", semantic only: no HTTP, no i18n.
EntitlementActions gives one operation-named constructor per gated business operation, so each operation maps to its key and scope in exactly one place:
| Constructor | Action |
|---|---|
createGroup() | create_resource on groups_per_user_max |
createCollection() | create_resource on collections_per_user_max |
createStudent() | create_resource on students_per_user_max |
addStudentToGroup(groupId) | create_resource on students_per_group_max under the group |
setGroupStudentCount(count) | set_resource_count on students_per_group_max |
linkCollectionToSession(sessionId) | create_resource on collections_per_session_max under the session |
addDealsToCollection(collectionId, amount) | create_resource on deals_per_collection_max under the collection, with amount |
addSharingGroupMember(sharingGroupId) | create_resource on members_per_sharing_group_max under the sharing group |
Defaults
DEFAULT_ENTITLEMENTS is what a subject without a grant has. cloneDefaultEntitlements() returns a fresh copy so callers cannot mutate the shared value.
| Limit | Default |
|---|---|
deals_per_collection_max | 200 |
collections_per_session_max | 5 |
collections_per_user_max | 20 |
members_per_sharing_group_max | 20 |
students_per_group_max | 20 |
groups_per_user_max | 20 |
students_per_user_max | 400 (the pool ceiling, students per group times groups per user; a safe upper bound since a student may belong to several groups) |
diffusions_per_period | 0: flow credit only ever comes from a grant, which counts it |
No feature is granted by default.
Grants
An EntitlementGrant is a dated allowance for one subject: sparse limit raises (GrantLimits = Partial<Record<LimitKey, number>>) and feature flags.
Prop
Type
NewEntitlementGrant is the write shape: subjectId, limits, features, validFrom, validUntil, grantedBy, note, plus the optional GrantProvenance fields. A provenance left out means "admin", the stored default.
Prop
Type
EntitlementGrantWithGrid adds gridId: string | null: the grid of plans the grant was sold in, null for a grant no subscription wrote. GrantWindow is the validFrom, validUntil, revokedAt triple that decides whether a grant counts at an instant.
| Export | Definition |
|---|---|
GRANT_SOURCES | ["admin", "subscription", "carry_over"] (GrantSource) |
ENTITLEMENT_GRANT_STATUSES | ["scheduled", "active", "expired", "revoked"] (EntitlementGrantStatus) |
getGrantStatus(grant: GrantWindow, at: Date) | revoked whenever revokedAt is set, else scheduled before validFrom, expired once validUntil is reached (end exclusive), else active |
isGrantActive(grant: GrantWindow, at: Date) | getGrantStatus(grant, at) === "active" |
const window = { validFrom: MARCH_1, validUntil: APRIL_1, revokedAt: null };
getGrantStatus(window, new Date("2026-02-20T00:00:00Z")); // => "scheduled"
getGrantStatus(window, MARCH_10); // => "active"
getGrantStatus(window, APRIL_1); // => "expired": the end is exclusive
getGrantStatus({ ...window, revokedAt: MARCH_10 }, MARCH_1); // => "revoked"Merge
The merge rule follows the nature of the key, not the grant. Flow limits are credits, so they sum. Stock limits are ceilings, so they take the max: a grant can never lower a cap below the default. Features are unioned. Inputs are never mutated.
| Function | Signature | Description |
|---|---|---|
applyGrant | (base: Entitlements, grant: GrantContribution) => Entitlements | One grant merged into a base value. GrantContribution is Pick<EntitlementGrant, "limits" | "features"> |
resolveEntitlementsAt | (grants: readonly (GrantContribution & GrantWindow)[], at: Date) => Entitlements | The defaults merged with every grant active at at, each counted once |
const capRaise = {
limits: { groups_per_user_max: 60 },
features: new Set<"usage_analytics">(["usage_analytics"]),
validFrom: MARCH_1,
validUntil: null,
revokedAt: null,
};
const credit = {
limits: { diffusions_per_period: 350 },
features: new Set<never>(),
validFrom: MARCH_1,
validUntil: APRIL_1,
revokedAt: null,
};
const secondCredit = { ...credit, limits: { diffusions_per_period: 1000 } };
const resolved: Entitlements = resolveEntitlementsAt(
[capRaise, credit, secondCredit],
MARCH_10
);
resolved.limits.groups_per_user_max; // => 60: a cap takes the max
resolved.limits.diffusions_per_period; // => 1350: credits sum
resolved.features.has("usage_analytics"); // => true: features are unioned
// A grant never lowers a cap below the base value.
applyGrant(DEFAULT_ENTITLEMENTS, {
limits: { groups_per_user_max: 5 },
features: new Set(),
}).limits.groups_per_user_max; // => 20Periods
UsagePeriod is { start: Date; end: Date }. getCurrentPeriod(now = new Date()) returns the UTC calendar month containing now, half-open. It is the window an open-ended grant renews its flow credit in, and the usage period of a subject holding no grant carrying one.
Presenters
Pure formatting of an Entitlements value for inspection or diff display. Unlimited prints as "unlimited"; comparison to the defaults flags a key as "default" when its effective value equals DEFAULT_ENTITLEMENTS.
| Function | Signature |
|---|---|
formatLimit | (value: number) => string |
describeLimits | (value: Entitlements) => LimitRow[], LimitRow = { key, value: string, source: "default" | "grant" } |
diffLimits | (before: Entitlements, after: Entitlements) => LimitChange[], LimitChange = { key, from: string, to: string } |
formatLimit(200); // => "200"
formatLimit(Number.POSITIVE_INFINITY); // => "unlimited"
describeLimits(resolved).find((row) => row.key === "groups_per_user_max");
// => { key: "groups_per_user_max", value: "60", source: "grant" }
diffLimits(DEFAULT_ENTITLEMENTS, resolved);
// => [{ key: "groups_per_user_max", from: "20", to: "60" }, { key: "diffusions_per_period", from: "0", to: "1350" }]Proration
prorateLimitsForSwitch(held: GrantLimits, bought: GrantLimits, window: TimeWindow, switchAt: Date): GrantLimits computes the credits of a plan change taking effect inside a billing window. The provider prorates the price, so flow credits follow the same time-weighted mix: held + (bought - held) × remainingShare, rounded. A switch on the eve of renewal buys one day of the difference, never the bought plan's full allowance. Stock limits are ceilings, not consumables: the bought value stands. A credit the held plan did not sell counts from zero; an unlimited credit on either side is left alone.
// Switching from 350 to 1000 diffusions one third into the month buys 350 + 650 × 2/3.
prorateLimitsForSwitch(
{ diffusions_per_period: 350, collections_per_user_max: 50 },
{ diffusions_per_period: 1000, collections_per_user_max: 100 },
{ start: MARCH_1, end: new Date("2026-03-31T00:00:00Z") },
new Date("2026-03-11T00:00:00Z")
);
// => { diffusions_per_period: 783, collections_per_user_max: 100 }: credits are mixed, caps standGrant quotas
A flow credit belongs to the grant that carries it. Each active grant holds its own counter over its own window, and the subject's headroom is what each grant has left, added up. The quota read (diffusion eligibility) and the quota write (the debit) both go through these functions, so they address the same counters by construction.
- A bounded grant counts its credit once, over
quotaPeriodStart ?? validFromtovalidUntil. A yearly subscription writes one grant per month, so its quota still rolls over monthly. - An open-ended grant renews its credit every UTC calendar month.
- A debit charges one grant, never split across two: the one with credit left whose window closes first, ties broken on id, so credit about to lapse goes first.
- A grant past its cap (a downgrade, a backfill) counts as spent, never as negative credit eating another grant's.
| Type | Definition |
|---|---|
QuotaGrant | Pick<EntitlementGrant, "id" | "limits" | "validFrom" | "validUntil" | "revokedAt" | "quotaPeriodStart" | "source">: the fields that decide a grant's flow counters |
GrantCounter | { grantId: string; window: QuotaWindow }: addresses one counter |
GrantQuotaBalance | GrantCounter & { cap: number; used: number }; cap is Infinity when unlimited |
FlowQuota | { cap: number; used: number }: the subject's standing summed over its active grants |
| Function | Signature | Description |
|---|---|---|
grantQuotaWindow | (grant, at: Date) => QuotaWindow | The window the grant counts its credit in at at |
grantCounters | (grants: readonly QuotaGrant[], limit: FlowLimitKey, at: Date) => GrantCounter[] | The counters of the grants active at at that carry limit |
grantQuotaBalances | (grants, limit, at, used: ReadonlyMap<string, number>) => GrantQuotaBalance[] | Each active grant carrying limit, with its cap and what its counter holds (an unwritten one is 0) |
summarizeFlowQuota | (balances: readonly GrantQuotaBalance[]) => FlowQuota | Caps summed, used summed with each grant clamped to its cap |
debitOrder | (balances, amount: number) => GrantQuotaBalance[] | The grants able to absorb amount whole, closing first |
hasFlowCredit | (balances, amount: number) => boolean | Whether some grant can absorb amount whole: what the debit will find |
usagePeriodAt | (grants: readonly (QuotaGrant & { gridId })[], gridId: string, at: Date) => QuotaWindow | Display only: the quota month of that grid's subscription grant active at at, else the UTC calendar month. Another grid's subscription never moves it |
findReplacedGrant | <T extends QuotaGrant>(opened: QuotaGrant, candidates: readonly T[]) => T | null | The grant opened replaces within its quota month, whose count it inherits: the unrevoked grant of the same quota month opened last. Null when opened starts a quota month of its own |
const monthlyGrant: QuotaGrant = {
id: "grant-march",
limits: { diffusions_per_period: 350 },
validFrom: MARCH_1,
validUntil: APRIL_1,
revokedAt: null,
quotaPeriodStart: MARCH_1,
source: "subscription",
};
const adminCredit: QuotaGrant = {
id: "grant-admin",
limits: { diffusions_per_period: 100 },
validFrom: MARCH_1,
validUntil: null, // open-ended: renews every UTC calendar month
revokedAt: null,
quotaPeriodStart: null,
source: "admin",
};
// `used` comes from UsageCountersRepository.getUsed, keyed by grant id.
const balances: GrantQuotaBalance[] = grantQuotaBalances(
[monthlyGrant, adminCredit],
"diffusions_per_period",
MARCH_10,
new Map([["grant-march", 350]])
);
summarizeFlowQuota(balances); // => { cap: 450, used: 350 }
debitOrder(balances, 1).map((balance) => balance.grantId); // => ["grant-admin"]: the full one is skipped
hasFlowCredit(balances, 1); // => trueQuota months of a billing window
splitBillingWindowIntoQuotaMonths(window: QuotaWindow): QuotaWindow[] tiles a billing window into back-to-back, half-open monthly windows anchored on its start: a monthly window yields itself, a yearly one yields twelve. Month ends follow the provider's billing-cycle rule (an anchor on the 31st lands on the 28th of February then returns to the 31st of March); each boundary is computed from the window start so a clamp never drifts. A window of 32 days or less is returned as is, a trailing remainder shorter than a day is merged into the last month, and an empty window yields nothing.
splitBillingWindowIntoQuotaMonths({
start: new Date("2026-01-31T00:00:00Z"),
end: new Date("2027-01-31T00:00:00Z"),
}).map((month) => month.end.toISOString().slice(0, 10));
// => ["2026-02-28", "2026-03-31", "2026-04-30", …, "2027-01-31"]: twelve months, end-of-month ruleStock overflow
Pure decisions of the degraded mode.
| Export | Definition |
|---|---|
STOCK_OVERFLOW_PUBLIC_GRACE_MS | 14 days: how long the public side of a degraded resource stays served, from the instant the ceiling dropped |
StockAvailabilityStatus | "active" | "grace" | "degraded" |
StockAvailability | { status: StockAvailabilityStatus; publicUntil: Date | null }; publicUntil is when the public side closes, null within the ceiling |
ACTIVE_AVAILABILITY | { status: "active", publicUntil: null } |
OwnedResourceRef | { id: string; createdAt: Date }: what ranking needs from an owned resource |
| Function | Signature | Description |
|---|---|---|
rankOverflow | (resources: readonly OwnedResourceRef[], ceiling: number) => Set<string> | The ids beyond ceiling once ranked oldest first, ties on id; empty for an infinite ceiling |
resolveCeilingSince | (grants, limit: StockLimitKey, at: Date) => Date | null | The last instant at or before at when a grant carrying limit opened, closed or was revoked. Grants that do not sell this ceiling never move it. Null when no such boundary exists |
resolveStockAvailability | ({ overflowing, ceilingSince, at, graceMs? }) => StockAvailability | An overflowing resource is in grace until ceilingSince + grace, degraded after; without a known ceiling change it is degraded at once |
const collections = [
{ id: "oldest", createdAt: new Date("2025-01-01T00:00:00Z") },
{ id: "middle", createdAt: new Date("2025-06-01T00:00:00Z") },
{ id: "newest", createdAt: new Date("2026-01-01T00:00:00Z") },
];
rankOverflow(collections, 2); // => Set { "newest" }: the most recent rows overflow
rankOverflow(collections, Number.POSITIVE_INFINITY); // => Set {}
const ceilingSince = resolveCeilingSince(
[{ ...capRaise, limits: { collections_per_user_max: 50 }, validUntil: MARCH_1 }],
"collections_per_user_max",
MARCH_10
); // => 2026-03-01: the grant carrying this ceiling closed then
resolveStockAvailability({ overflowing: true, ceilingSince, at: MARCH_10 });
// => { status: "grace", publicUntil: 2026-03-15 } (ceilingSince + STOCK_OVERFLOW_PUBLIC_GRACE_MS)
resolveStockAvailability({ overflowing: true, ceilingSince: null, at: MARCH_10 });
// => { status: "degraded", publicUntil: null }: no ceiling change on record
STOCK_OVERFLOW_PUBLIC_GRACE_MS; // => 14 days in millisecondsApplication
EntitlementsService
createEntitlementsService(deps: EntitlementsServiceDeps): EntitlementsService, with deps = { entitlements: EntitlementsRepository; stock: StockUsageReader; flow: Pick<UsageCountersRepository, "getUsed">; clock? }. The single entry point for reads, enforcement previews and grant writes.
| Method | Signature | Description |
|---|---|---|
can | (subjectId: string, action: Action) => Promise<CheckResult> | Read-only check for /me and non-critical UI gating. Not the enforcement path for stock: it measures then decides, so two concurrent calls can both pass. Enforce at the write site. Throws a plain Error on a parent-scoped limit with no parentId rather than counting 0 |
getEntitlementsOverview | (subjectId: string) => Promise<EntitlementsOverview> | Entitlements, subject-scoped usage, capabilities (Record<SubjectScopedStockLimitKey, boolean>, "can add one more") and inventory, from one resolution. Parent-scoped keys are omitted from usage |
resolveEntitlements | (subjectId: string) => Promise<Entitlements> | Effective entitlements right now: defaults plus active grants |
listGrants | (subjectId: string) => Promise<EntitlementGrant[]> | Every grant of the subject, whatever its status, newest first |
previewGrant | (input: NewEntitlementGrant) => Promise<GrantPreview> | What the grant would change, without writing it. Throws ValidationError (see validateNewGrant). The dry run of pnpm cli grant-entitlements, whose --apply calls grant |
grant | (input: NewEntitlementGrant) => Promise<GrantResult> | Validates, persists, returns the grant with its preview. Throws ValidationError; nothing is persisted then |
revokeGrant | (subjectId: string, grantId: string) => Promise<RevokeGrantResult> | Stamps revokedAt at the clock's instant; revoked: false when the grant is unknown, belongs to another subject or was already revoked |
GrantPreview is { effectiveAt: Date; before: Entitlements; after: Entitlements }, both values taken at effectiveAt: now, or the grant's start when it is scheduled later. GrantResult adds grant. RevokeGrantResult is { revoked: boolean; after: Entitlements }.
Two pure helpers are exported beside the factory:
| Function | Signature | Description |
|---|---|---|
decideCheck | (entitlements: Entitlements, action: Action, ctx?: { stockCurrent?: number }) => CheckResult | The pure decision on a feature or a stock limit; stockCurrent is the count before the new row, missing means 0. Flow limits are not decided here: the debit enforces them per grant |
validateNewGrant | (input: NewEntitlementGrant) => void | Throws ValidationError with VALIDATION_ERROR when validUntil is not after validFrom (must_be_after_valid_from), when the grant widens nothing (empty_grant), or when a limit is negative or NaN (invalid_limit) |
// The defaults cap groups at 20: the twenty-first is refused.
decideCheck(DEFAULT_ENTITLEMENTS, EntitlementActions.createGroup(), {
stockCurrent: 20,
});
// => { allowed: false, reason: "limit_reached", limitKey: "groups_per_user_max", current: 20, max: 20 }
// A bulk add checks current + amount against the ceiling.
decideCheck(
DEFAULT_ENTITLEMENTS,
EntitlementActions.addDealsToCollection("collection-1", 50),
{ stockCurrent: 160 }
);
// => { allowed: false, reason: "limit_reached", limitKey: "deals_per_collection_max", current: 160, max: 200 }
// Replacing a group's students compares the target count alone.
decideCheck(DEFAULT_ENTITLEMENTS, EntitlementActions.setGroupStudentCount(12));
// => { allowed: true }
decideCheck(DEFAULT_ENTITLEMENTS, {
type: "use_feature",
feature: "usage_analytics",
});
// => { allowed: false, reason: "feature_disabled", featureKey: "usage_analytics" }export async function enforceAndGrant(
stock: StockUsageReader,
subjectId: string,
input: NewEntitlementGrant
) {
// The in-memory fake stands for the database adapters of the two ports.
const fake = createInMemoryEntitlements();
const entitlements = createEntitlementsService({
entitlements: fake.entitlements,
stock,
flow: fake.counters,
});
const check = await entitlements.can(subjectId, EntitlementActions.createGroup());
if (!check.allowed) {
check.reason; // => "limit_reached"
}
const { before, after, grant } = await entitlements.grant(input);
diffLimits(before, after); // => what the grant changed, at its effective instant
const overview = await entitlements.getEntitlementsOverview(subjectId);
overview.capabilities.groups_per_user_max; // => true while one more group fits
overview.inventory.dealsCreated; // => owned deals, never capped
return entitlements.revokeGrant(subjectId, grant.id); // => { revoked: true, after }
}EntitlementsGate
A service that creates rows under a stock limit takes an EntitlementsGate, the can of EntitlementsService, as its last dependency and asks it before any write: groups.create, groups.addStudent, students.create, dealCollections.create, dealCollections.addBoards, groupSessions.linkCollection and sharingGroups.addMember throw LimitReachedError (or FeatureDisabledError) and leave the repository untouched. The api hands them the request's EntitlementsService, built on the same owner pool. allowAllEntitlements is the explicit permissive gate of an operator context: operators are not subject to user plan limits, by design. createFakeEntitlementsGate from /testing records every check and refuses the limits and features it is told to.
// A writing service takes the gate last and asks it before any write; an
// operator context passes `allowAllEntitlements` instead.
export function buildGroups(
ports: Omit<GroupsServiceDeps, "entitlements">,
gate: EntitlementsGate
): GroupsService {
return createGroupsService({ ...ports, entitlements: gate });
}
export async function refusedPastTheCeiling(
ports: Omit<GroupsServiceDeps, "entitlements">
) {
const gate = createFakeEntitlementsGate();
gate.deny("groups_per_user_max", { current: 3, max: 3 });
const groups = createGroupsService({ ...ports, entitlements: gate });
try {
await groups.create(
{ name: "Mardi soir", startDate: "2026-01-01" },
{ userId: "user-1" }
);
} catch (error) {
gate.checks; // => [{ type: "create_resource", limit: "groups_per_user_max" }]
return error instanceof LimitReachedError; // => true, nothing written
}
return false;
}Flow quota reads and debits
Every reader of a flow quota (eligibility, the usage page, /me) and its single writer (the debit) go through two functions with the grants active at one instant, so they address the same counters.
| Function | Signature | Description |
|---|---|---|
readFlowBalances | (counters: Pick<UsageCountersRepository, "getUsed">, grants: readonly EntitlementGrant[], limit: FlowLimitKey, at: Date) => Promise<GrantQuotaBalance[]> | Each active grant carrying limit at at, with what its counter holds. Skips the counter read when no grant carries the limit |
debitFlowQuota | (ports: FlowDebitPorts, debit: FlowDebit) => Promise<boolean> | Charges amount to one grant whole: the first of debitOrder whose bounded increment lands. A grant a concurrent debit filled meanwhile refuses its increment and the next one is tried. When a concurrent plan change ended a grant between the read and the charge, the grants are read once more so the debit lands on its replacement. False once every grant is exhausted |
FlowDebit is { subjectId; limit: FlowLimitKey; amount: number; at: Date }. FlowDebitPorts is { grants: Pick<EntitlementsRepository, "listActiveGrants">; counters: Pick<UsageCountersRepository, "getUsed" | "tryIncrement"> }.
export function chargeOneDiffusion(
grants: Pick<EntitlementsRepository, "listActiveGrants">,
counters: Pick<UsageCountersRepository, "getUsed" | "tryIncrement">,
subjectId: string
): Promise<boolean> {
return debitFlowQuota(
{ grants, counters },
{ subjectId, limit: "diffusions_per_period", amount: 1, at: new Date() }
);
}ServeDiffusionService
createServeDiffusionService(deps: ServeDiffusionServiceDeps): ServeDiffusionService, with deps = { entitlements: Pick<EntitlementsRepository, "listActiveGrants">; quota: Pick<UsageCountersRepository, "getUsed">; clock? }. The resolve-time decision: is this serve a diffusion of the deal owner's plan (ad-free, debited at its first event)? Eligible while one of the owner's grants can still absorb the debit, nothing else: the custom_branding feature alone never grants a diffusion, and a grant the debit would skip is never counted as headroom. The read-at-resolve versus debit-at-event race is accepted (slight overshoot tolerated).
| Method | Signature | Description |
|---|---|---|
decideDiffusion | (ownerAccountId: string | null) => Promise<Date | null> | The decision timestamp for an eligible owner; null otherwise, including ownerless serves, which touch no port |
DiffusionDebitService
createDiffusionDebitService(deps: DiffusionDebitServiceDeps): DiffusionDebitService, with deps = { uow: DiffusionDebitUnitOfWork; clock? }. The first-event rule: any telemetry event referencing a serve proves the player displayed the deal, so the serve is marked landed whatever its payer. When the serve has a payer and is not yet consumed, the same event debits that payer once: one delivered unit on the campaign, or one diffusions_per_period unit on one of the owner's grants. The guarded consumption mark picks the single winner, and landing mark, consumption mark and debit share one transaction.
| Method | Signature | Description |
|---|---|---|
debitForServe | ({ serveId: string; sessionId: string }) => Promise<DiffusionDebitOutcome> | Runs inside uow.run; a thrown increment rolls the marks back |
DIFFUSION_DEBIT_OUTCOMES lists the outcomes:
| Outcome | Meaning |
|---|---|
debited | One unit charged to an owner grant |
exhausted | The owner holds no grant with credit left. Soft: the consumption mark stays, a budget raised later must not re-debit the serve |
already_consumed | The serve had its billable landing already, or this caller lost the mark race |
undiffused | No diffusion was decided for the serve: marked displayed, never consumed nor debited |
unowned_serve | A diffused serve without an owner: nothing is charged |
unknown_serve | No such serve; nothing is touched |
campaign_delivered | One delivered unit counted on the sponsoring campaign |
campaign_exhausted | The campaign budget would be exceeded. Soft, as exhausted |
A campaign-paid serve never reads the owner's entitlements; a plan-paid serve never touches the campaign deliveries.
export function serveDiffusionServices(
entitlements: EntitlementsRepository,
quota: UsageCountersRepository,
uow: DiffusionDebitUnitOfWork
) {
// At resolve time: is this serve ad-free, on the owner's plan?
const diffusion = createServeDiffusionService({ entitlements, quota });
// At the first telemetry event: charge the payer once.
const debit = createDiffusionDebitService({ uow });
return {
decide: (ownerAccountId: string | null) =>
diffusion.decideDiffusion(ownerAccountId), // => Date while a grant has credit, else null
land: (serveId: string, sessionId: string) =>
debit.debitForServe({ serveId, sessionId }), // => "debited" | "exhausted" | "campaign_delivered" | …
};
}StockOverflowResolver
createStockOverflowResolver(deps: StockOverflowResolverDeps): StockOverflowResolver, with deps = { entitlements: Pick<EntitlementsRepository, "resolve" | "listGrants">; clock? }. Decides, for the resources a subject owns under one stock ceiling, which ones are degraded and until when their public side stays served. The caller hands in the resources, since each kind lives in its own repository.
| Method | Signature | Description |
|---|---|---|
resolve | (subjectId: string, limit: SubjectScopedStockLimitKey, resources: readonly OwnedResourceRef[], at?: Date) => Promise<Map<string, StockAvailability>> | The availability of every resource given, by id. Reads the ceiling, then the grant history only when something overflows |
export function availabilityOfCollections(
entitlements: Pick<EntitlementsRepository, "resolve" | "listGrants">,
subjectId: string
) {
const resolver = createStockOverflowResolver({ entitlements });
// `collections` is what a findMany with the creationOrder() projection returns.
return resolver
.resolve(subjectId, "collections_per_user_max", collections)
.then((availability) => availability.get("newest")); // => { status: "grace" | "degraded" | "active", publicUntil }
}Ports
EntitlementsRepository
| Method | Signature | Description |
|---|---|---|
resolve | (subjectId: string, at: Date) => Promise<Entitlements> | DEFAULT_ENTITLEMENTS widened by the subject's grants active at at |
listActiveGrants | (subjectId: string, at: Date) => Promise<EntitlementGrant[]> | The grants active at at: what resolve merges and what flow counters belong to |
listActiveGrantsWithGrid | (subjectId: string, at: Date) => Promise<EntitlementGrantWithGrid[]> | The same grants, each with the grid its subscription was sold in, in one read |
listGrants | (subjectId: string) => Promise<EntitlementGrant[]> | Every grant of the subject, whatever its status, newest first |
createGrant | (input: NewEntitlementGrant) => Promise<EntitlementGrant> | |
revokeGrant | (subjectId: string, grantId: string, at: Date) => Promise<EntitlementGrant | null> | Stamps revokedAt on a not-yet-revoked grant of the subject; null when no such grant exists |
endGrant | (subjectId: string, grantId: string, at: Date) => Promise<EntitlementGrant | null> | Closes the window of a not-revoked grant at at, provided it opened before at and was still open then; null otherwise |
StockUsageReader
| Method | Signature | Description |
|---|---|---|
count | (subjectId: string, limit: StockLimitKey, parentId?: string) => Promise<number> | Live COUNT(*) for a stock limit. For subject-scoped limits parentId is the subject id; for parent-scoped limits it is the parent entity id. Returns 0 when parentId is undefined |
countDeals | (subjectId: string) => Promise<number> | Live count of the deals the subject owns, deleted ones excluded: an inventory figure, never capped |
UsageCountersRepository
Flow counters, one per grant and window. GrantCounterIncrement describes one bounded increment:
Prop
Type
| Method | Signature | Description |
|---|---|---|
getUsed | (limit: FlowLimitKey, counters: readonly GrantCounter[]) => Promise<ReadonlyMap<string, number>> | Units held by each counter, keyed by grant id; a counter never written is absent |
tryIncrement | (increment: GrantCounterIncrement) => Promise<{ ok: boolean; value: number; grantActive: boolean }> | Atomic bounded increment. ok: false when the cap would be exceeded, or with grantActive: false when the grant is no longer active at at (a concurrent plan change ended it) |
carry | (subjectId: string, fromGrantId: string, to: GrantCounter) => Promise<void> | Opens to's counters at what fromGrantId's hold for the window to starts: the count a replacement grant inherits. Counters to already has are left as they are, so a replay changes nothing |
Diffusion debit unit of work
Marker and bounded increment must commit or roll back together, so the database layer exposes a scoped unit of work. DiffusionDebitUnitOfWork.run<T>(step: (tx: DiffusionDebitTx) => Promise<T>): Promise<T> executes one step inside a single transaction. DiffusionDebitTx is { serves: ServeConsumptionRepository; entitlements: Pick<EntitlementsRepository, "listActiveGrants">; quota: Pick<UsageCountersRepository, "getUsed" | "tryIncrement">; campaignDeliveries: Pick<CampaignDeliveriesRepository, "tryIncrement"> }.
ConsumableServe holds the serve fields the decision reads; at most one of the two payer marks is set:
Prop
Type
ServeConsumptionRepository:
| Method | Signature | Description |
|---|---|---|
findById | (serveId: string) => Promise<ConsumableServe | null> | |
markFirstEvent | (serveId: string, firstEventAt: Date, sessionId: string) => Promise<boolean> | Guarded landing mark: sets firstEventAt and stamps sessionId only when still unset; true when this call set it |
markConsumed | (serveId: string, consumedAt: Date) => Promise<boolean> | Guarded consumption mark: true when this caller won the mark and may debit |
CampaignDeliveriesRepository
The campaign-side counterpart of the owner's quota counter: one delivered unit per diffusion a campaign pays, bounded by its servesBudget. Like UsageCountersRepository.tryIncrement, it is bespoke by design: an increment that refuses past a cap is decided in one SQL statement, which no canonical primitive expresses.
| Method | Signature | Description |
|---|---|---|
tryIncrement | (sponsorshipId: string, amount: number) => Promise<{ ok: boolean; delivered: number }> | Atomic bounded increment. ok: false means the budget would be exceeded; delivered is the count after the call |
Degraded mode
When a subject owns more rows of a kind than its stock ceiling allows (after a downgrade, an expired grant), nothing is deleted or modified. The rows beyond the ceiling are degraded: no edit, no creation from them, and their public side closes after a grace period. The state is computed from the count and the ceiling, never stored, so a higher ceiling or a deletion restores everything at once. The pure decisions are in domain/entitlements/stock-overflow.ts, the resolver in application/entitlements/stock-overflow.ts. First consumers: the deal collections service and the public collections service.
Rules
- Which rows overflow: the most recently created ones. Rank by
createdAtascending, thenid; everything past the ceiling overflows. The oldest rows have been there longest and matter most, the rule is stable (deleting a degraded row changes nothing for the others, deleting a kept row frees the oldest degraded one), and it needs no usage measure. - What degraded removes: every mutation but deletion, at once. Deletion stays because it is one of the two ways out, the other being a higher plan. There is no manual choice of what to keep.
- Public side: served during a grace period counted from the instant the ceiling dropped (
resolveCeilingSince: the last boundary, at or before now, of a grant carrying this ceiling; a grant selling something else never restarts it), with a notice carrying the closing date. The period is one per account and ceiling, never per resource: every overflowing row closes on the same date. After that, an explicit unavailability page, never a 404 and never the content. The grace length isSTOCK_OVERFLOW_PUBLIC_GRACE_MS. - No known ceiling change: a surplus with no grant boundary on record never had a right to lose; it is degraded at once.
- Reminder: one persistent banner across the app while any ceiling is exceeded. It never dismisses.
Shape
- The resolver takes the subject, the limit key and the owned resources as
OwnedResourceRef, reads the ceiling and (only when something overflows) the grant history, and returns aStockAvailabilityby id. - A service serves
Governed<T> = T & { availability: StockAvailability }(exported by@workspace/training/domain/collections) on every read and throwsResourceDegradedError(RESOURCE_DEGRADED, 403 on the wire, carryingresourceandresourceId) on a refused mutation. - DTOs carry
availability: { status, publicUntil: string | null }; the public read withholds boards and content once degraded.
Reads and exports remain open
Degradation only closes the write surface. Reads, lists and every export path (PBN, LIN, PDF) stay available on a degraded resource: the user must be able to leave with their data before the only remaining mutation (deletion) is used. The deal collections detail page therefore surfaces a one-click PBN download from its degraded notice as long as the collection carries boards; the dropdown export menu keeps working too. Archive counts as a mutation and stays refused; delete is the one exception carved out above.
Adding a resource kind
- Give its read repository a
creationOrder()projection: every owned row asOwnedResourceRef, the same population the stock limit counts. - Wrap the service reads with the resolver and guard the mutations with
ResourceDegradedError, deletion excepted. Keep every export path open. - Extend the DTO with
availability, the app ViewModel flags, and the public read if the kind has one. - Widen the app banner from collections to the new key.
What it is not
Not a stored flag, not a job, not an archive. Do not add a column or a scheduled task to hold this state: the instant it is persisted, restoration needs a write path and the history a migration.
Errors
| Error | Code | Thrown by |
|---|---|---|
ValidationError | VALIDATION_ERROR | validateNewGrant, through previewGrant and grant; context.reason is must_be_after_valid_from, empty_grant or invalid_limit |
LimitReachedError | LIMIT_REACHED | The write sites of the consuming modules after a denied stock check; carries limitKey, current, max |
FeatureDisabledError | FEATURE_DISABLED | The consuming modules after a denied feature check; carries featureKey |
ResourceDegradedError | RESOURCE_DEGRADED | A refused mutation on a degraded resource; carries resource and resourceId |
The three last classes are exported by the package root, @workspace/training. The entitlements services themselves never throw them: can and decideCheck return a CheckResult, and the debit returns a boolean or an outcome.
Fixtures
@workspace/training/factories exports makeEntitlementGrant(faker, overrides) and makeNewEntitlementGrant(faker, overrides): an admin grant raising one stock limit, open since recently and closing soon. @workspace/training/testing exports createInMemoryEntitlements(seed?, options?), an in-memory EntitlementsRepository and UsageCountersRepository pair with the database's semantics: the bounded increment refuses past the cap or on a grant no longer active, and a carry stays within the subject and never overwrites a counter already written. It also exposes grants(), usedOf(grantId, limit, at) and seedUsed(grantId, limit, at, value); options.subscriptionGrids maps subscription ids to grid ids for listActiveGrantsWithGrid.
export function fixtures(faker: Faker) {
const grant: EntitlementGrant = makeEntitlementGrant(faker, {
limits: { diffusions_per_period: 350 },
});
const input: NewEntitlementGrant = makeNewEntitlementGrant(faker, {
subjectId: grant.subjectId,
});
const fake = createInMemoryEntitlements([grant]);
fake.seedUsed(grant.id, "diffusions_per_period", grant.validFrom, 10);
fake.usedOf(grant.id, "diffusions_per_period", grant.validFrom); // => 10
return { grant, input, fake };
}Public
The deal view served to the public player and the services that read deals, shared sessions and shared collections without a ServiceContext.
Plans
The catalogue of plans: grids, tiers and their entitlement preset, prices per billing interval, and the services that read and change it on both sides of the payment provider.