Bridge Training

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).

LayerImport path
Domain@workspace/training/domain/entitlements
Application@workspace/training/application/entitlements
ErrorsLimitReachedError, 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.

ExportDefinition
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_KEYSdeals_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_KEYScollections_per_user_max, groups_per_user_max, students_per_user_max: counted across the rows the subject owns
STOCK_LIMIT_KEYSParent-scoped then subject-scoped keys
FLOW_LIMIT_KEYS["diffusions_per_period"]: ad-free serves debited per period
LIMIT_KEYSStock then flow keys
DIFFUSIONS_PER_PERIODThe FlowLimitKey "diffusions_per_period"
SERVE_DEBIT_AMOUNT1: one serve, one unit, charged to a grant of the owner or to a campaign
LIMIT_KEYS_FOR_CAPABILITIESThe 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.

entitlements.ts
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 subject

Entitlements, 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:

VariantFieldsRule
use_featurefeature: FeatureKeyAllowed when the feature is in the set
create_resource (parent-scoped)limit: ParentScopedStockLimitKey, parentId: string, amount?: numberAllowed when current + amount <= max, amount defaulting to 1; current is counted under parentId
create_resource (subject-scoped)limit: SubjectScopedStockLimitKey, amount?: number, no parentIdSame rule, current counted on the subject
set_resource_countlimit: StockLimitKey, count: numberReplaces 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:

ConstructorAction
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.

LimitDefault
deals_per_collection_max200
collections_per_session_max5
collections_per_user_max20
members_per_sharing_group_max20
students_per_group_max20
groups_per_user_max20
students_per_user_max400 (the pool ceiling, students per group times groups per user; a safe upper bound since a student may belong to several groups)
diffusions_per_period0: 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.

ExportDefinition
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"
entitlements.ts
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.

FunctionSignatureDescription
applyGrant(base: Entitlements, grant: GrantContribution) => EntitlementsOne grant merged into a base value. GrantContribution is Pick<EntitlementGrant, "limits" | "features">
resolveEntitlementsAt(grants: readonly (GrantContribution & GrantWindow)[], at: Date) => EntitlementsThe defaults merged with every grant active at at, each counted once
entitlements.ts
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; // => 20

Periods

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.

FunctionSignature
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 }
entitlements.ts
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.

entitlements.ts
// 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 stand

Grant 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 ?? validFrom to validUntil. 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.
TypeDefinition
QuotaGrantPick<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
GrantQuotaBalanceGrantCounter & { cap: number; used: number }; cap is Infinity when unlimited
FlowQuota{ cap: number; used: number }: the subject's standing summed over its active grants
FunctionSignatureDescription
grantQuotaWindow(grant, at: Date) => QuotaWindowThe 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[]) => FlowQuotaCaps 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) => booleanWhether some grant can absorb amount whole: what the debit will find
usagePeriodAt(grants: readonly (QuotaGrant & { gridId })[], gridId: string, at: Date) => QuotaWindowDisplay 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 | nullThe 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
entitlements.ts
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); // => true

Quota 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.

entitlements.ts
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 rule

Stock overflow

Pure decisions of the degraded mode.

ExportDefinition
STOCK_OVERFLOW_PUBLIC_GRACE_MS14 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
FunctionSignatureDescription
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 | nullThe 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? }) => StockAvailabilityAn overflowing resource is in grace until ceilingSince + grace, degraded after; without a known ceiling change it is degraded at once
entitlements.ts
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 milliseconds

Application

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.

MethodSignatureDescription
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:

FunctionSignatureDescription
decideCheck(entitlements: Entitlements, action: Action, ctx?: { stockCurrent?: number }) => CheckResultThe 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) => voidThrows 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)
entitlements.ts
// 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" }
entitlements.ts
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.

entitlements.ts
// 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.

FunctionSignatureDescription
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"> }.

entitlements.ts
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).

MethodSignatureDescription
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.

MethodSignatureDescription
debitForServe({ serveId: string; sessionId: string }) => Promise<DiffusionDebitOutcome>Runs inside uow.run; a thrown increment rolls the marks back

DIFFUSION_DEBIT_OUTCOMES lists the outcomes:

OutcomeMeaning
debitedOne unit charged to an owner grant
exhaustedThe owner holds no grant with credit left. Soft: the consumption mark stays, a budget raised later must not re-debit the serve
already_consumedThe serve had its billable landing already, or this caller lost the mark race
undiffusedNo diffusion was decided for the serve: marked displayed, never consumed nor debited
unowned_serveA diffused serve without an owner: nothing is charged
unknown_serveNo such serve; nothing is touched
campaign_deliveredOne delivered unit counted on the sponsoring campaign
campaign_exhaustedThe 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.

entitlements.ts
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.

MethodSignatureDescription
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
entitlements.ts
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

MethodSignatureDescription
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

MethodSignatureDescription
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

MethodSignatureDescription
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:

MethodSignatureDescription
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.

MethodSignatureDescription
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 createdAt ascending, then id; 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 is STOCK_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 a StockAvailability by id.
  • A service serves Governed<T> = T & { availability: StockAvailability } (exported by @workspace/training/domain/collections) on every read and throws ResourceDegradedError (RESOURCE_DEGRADED, 403 on the wire, carrying resource and resourceId) 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

  1. Give its read repository a creationOrder() projection: every owned row as OwnedResourceRef, the same population the stock limit counts.
  2. Wrap the service reads with the resolver and guard the mutations with ResourceDegradedError, deletion excepted. Keep every export path open.
  3. Extend the DTO with availability, the app ViewModel flags, and the public read if the kind has one.
  4. 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

ErrorCodeThrown by
ValidationErrorVALIDATION_ERRORvalidateNewGrant, through previewGrant and grant; context.reason is must_be_after_valid_from, empty_grant or invalid_limit
LimitReachedErrorLIMIT_REACHEDThe write sites of the consuming modules after a denied stock check; carries limitKey, current, max
FeatureDisabledErrorFEATURE_DISABLEDThe consuming modules after a denied feature check; carries featureKey
ResourceDegradedErrorRESOURCE_DEGRADEDA 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.

entitlements.ts
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 };
}