Skip to content

Receiving and entitlements

Cratis.Showrunner.BoxOffice is the part of Box Office a managed product hosts. The operator decides what a tenant may use; this library receives the decision, keeps it in the tenant’s own namespace and answers what the tenant may do. It runs inside the product’s Arc and Chronicle process, the same way Usher does.

Reference the Cratis.Showrunner.BoxOffice package and call AddBoxOffice next to AddCratis:

var builder = WebApplication.CreateBuilder(args);
builder.AddCratis();
builder.AddBoxOffice(boxOffice => boxOffice.OperatorStores = ["Direct"]);
OptionDefaultMeaning
OperatorStores["Direct"]The event stores of the operators whose decisions the product honors.
EventStorethe application’s own event storeThe event store Box Office reads and records in. In an AddCratis or AddCratisChronicle host it is the application’s own, without naming it; set it only to use another one. It also sets OfferingOptions.EventStore, which the offering, its reporting and metering use.
PollingIntervalone minuteHow often Box Office looks for tenant namespaces it does not serve yet.
EnforceModulestrueWhether commands, queries and reactors of a module the tenant does not have are refused; see Module gating.
MaximumWorkDuration12 hoursHow long admitted work holds a concurrency slot when nobody ends it; see Compute admission.
CustomerPortalUrl, CustomerPortalSigningKeynoneWhere the operator’s customer portal is and the key links to it are signed with; see The customer portal.
CustomerPortalLinkLifetime15 minutesHow long a link to the customer portal works.

The host fails to start when OperatorStores is empty, the interval is not more than zero or MaximumWorkDuration is not more than zero and at most seven days, or only one of CustomerPortalUrl and CustomerPortalSigningKey is set. Arc discovers the commands, queries and command filter, and Chronicle the events, read models and reactors, from the referenced assembly; there is nothing else to register.

The operator publishes its decisions as the public events in Contracts, under the organization the tenant belongs to. Box Office subscribes the application’s event store to each operator store’s outbox, so the events arrive in the application’s inbox for that operator. A reactor on the inbox translates each decision about a tenant into a private fact and records it in the tenant’s namespace, in one stream every tenant-wide fact shares.

The tenant is named by TenantId, and a tenant’s id is the exact name of its Chronicle namespace, compared exactly: Fjord-Logistics is not fjord-logistics. Box Office never creates a namespace. A decision that names no tenant, a tenant with no namespace, or a name that two namespaces share is logged and recorded nowhere, so a decision can never land in another tenant’s namespace.

Every decision is recorded with the time the operator decided it, and the newest decision wins whatever order decisions arrive in: a replay that delivers a revoke before the grant it followed still ends in the revoked state. A decision that the forwarder delivers twice is recorded once, because the record is keyed on the decision itself. Two separate decisions to the same effect, made at different times, are two decisions.

A product may also decide for itself. When it starts a tenant on its default offering, it records TenantStartedOnDefaultOffering in the tenant’s stream, and the tenant has that offer’s seats and modules at once: the seats as granted seats without an end, the modules in full without an end. It is stamped with the time it happened like an operator decision, so a decision the operator makes after it wins, and one made before it does not. The catalog does not publish a trial length, so the tenant starts without a trial.

The decisions about the product as a whole, OfferPublished and OfferWithdrawn, are not about a tenant. They are recorded where the operator published them, and the OnSale query (the Offer read model) returns the offers that are not withdrawn.

The operator’s OrganizationId is the event source of its events. Box Office records which organization a tenant is, so the product can answer under it, when the first decision for the tenant arrives.

Inject IEntitlements where a command or a query needs to know. It answers for the tenant of the current request:

QuestionMethod
May the tenant change anything?IsActive(), WhyReadOnly()
Does the tenant have a module?Has(module), AccessTo(module)
What may the tenant use of a meter?AllowanceFor(meter)
How many seats may the tenant assign?SeatCapacity()
Does a person hold a seat the tenant’s seats cover?HasSeat(holder)

A module is None, Preview, Trial or Full. Has is true for a trial and for a full module, and not for a preview, which only lets the tenant look at a module it does not have. A meter the operator set no allowance for allows nothing.

Nothing that ends needs an event to end it. A trial, a module, a preview or seats granted until a day count until that day, and the answer changes the day after, computed from the clock when the question is asked. A trial’s seats replace nothing the tenant bought: seats granted or bought replace the trial’s, they do not add to them.

IEntitlements is for deciding whether to admit a command and for showing what the tenant has. The read is advisory: it can be as stale as the read model, so a command that must hold a rule under concurrency reads the facts it decides on itself, as AssignSeat does.

Box Office needs the operator’s decisions to have arrived before it can answer; a tenant the operator has not decided anything about has no modules, no seats and no allowances, and is not read-only.