Plans & pricing
Declare a plan's kind and its five economic controls; grant access and bounds beside them.
fs.plan(id, options) declares one sellable plan. Every plan states its
kind — fs.plan.kind.free | flat | usage | prepaid | hybrid | trial | custom
— and only the economic controls that kind allows. The compiler validates the
controls against the declared kind; a mismatch is a build error
(PLAN_KIND_CONTROL_MISMATCH), never a silent reclassification.
import * as fs from "@farthershore/business";
const requests = fs.requests();
const units = fs.measure("units");
const usage = fs.meter("api_usage", { measures: [units] });
const usagePricing = fs.pricing("api_usage", {
meter: usage,
catalog: [fs.rate.perUnit(fs.money.usd(0.01))],
});
const api = fs.backend("api", {
transport: { mode: "direct" },
default: true,
});
const search = fs.route("/v1/search", {
get: { backend: api, costs: [requests.fixed(1)], reports: [usage] },
});
fs.meterRoutes("search-usage", search, { reports: [usage] });
fs.plan("pro", {
kind: fs.plan.kind.hybrid,
price: fs.money.usd(30).monthly(),
usagePricing: usagePricing.current(),
funding: { buckets: [fs.included(fs.money.usd(10))] },
spendPolicy: {
onExhaustion: fs.exhaustion.overage(usagePricing.current()),
},
grants: [search],
limits: [requests.perMinute(600)],
});
export default fs.business();
That plan is $30/month, includes $10 of rated usage every period, prices
everything past the allowance at the current api_usage catalog, and lets
subscribers keep calling after the allowance is spent (overage). Access
(grants) and structural bounds (limits) sit beside the economics but are
independent mechanisms — see
Entitlements vs economics.
The five controls
| Control | Type | What it decides |
|---|---|---|
price | fs.money.usd(n).monthly() / .yearly() | Recurring economics — the fee a subscription pins when it is created. |
usagePricing | pricing.current() / .withContractTerms() / .fixedVersion(n) | Which pricing catalog rates this plan's measurements, and how it is bound. |
funding | { buckets: [fs.included(…), fs.prepaid(…), fs.promo(…), fs.referral(…)] } | Value that pays for rated usage before anything is owed — see Funding & allowances. |
lifecycle | { trialDays: n } | Trial gating; during the trial no obligation accrues. |
spendPolicy | { onExhaustion, disclosure?, rail? } | What happens when funding is exhausted, and whether surfaces disclose rates. |
Every value is an SDK constructor or ref: fs.plan.kind.*, fs.exhaustion.block
/ fs.exhaustion.overage(binding), fs.disclosure.opaque | transparent,
fs.display.multiplier({ factor }), fs.rail.x402. Hand-written objects and
bare strings are rejected at build time.
Kinds and their controls
| Kind | Requires | Allows | Typical shape |
|---|---|---|---|
free | nothing | nothing economic | fs.plan("free", { kind: fs.plan.kind.free }) — bound it with limits. |
flat | price | price | A $30/month subscription with no metered charges. |
usage | usagePricing | spendPolicy only for rail.x402 | Postpaid pay-as-you-go against a catalog. |
prepaid | usagePricing, funding (only fs.prepaid buckets), spendPolicy with fs.exhaustion.block | those three | A wallet: usage draws down purchased value and stops at zero. |
hybrid | price, usagePricing, funding (≥1 bucket), spendPolicy with fs.exhaustion.overage(...) | those four | Subscription plus an included allowance plus overage. |
trial | price, lifecycle | usagePricing | A trial that converts to the recurring price; usage pricing rates post-trial use. |
custom | usagePricing, funding, spendPolicy (block or overage) | all five | fs.plan.kind.custom — bespoke shapes that still select exactly one economic mode. |
The overage binding on a hybrid plan must name the same pricing family as
usagePricing; the compiler rejects a mismatch.
Archetypes
The fragments below illustrate economic controls, not complete buildable
programs. Declare their referenced pricing first and add route grants and a
rate-limit rule to every plan, then seal the program once. The complete example
above shows those required pieces together. A plan that bills usage must bound
it somehow — a limits rule, maxMonthlySpendCents, or
spendPolicy: { onExhaustion: fs.exhaustion.block } — or the build fails with
PLAN_UNBOUNDED_SPEND.
fs.plan("free", { kind: fs.plan.kind.free });
fs.plan("flat", {
kind: fs.plan.kind.flat,
price: fs.money.usd(30).monthly(),
});
fs.plan("usage", {
kind: fs.plan.kind.usage,
usagePricing: usagePricing.current(),
});
fs.plan("prepaid", {
kind: fs.plan.kind.prepaid,
usagePricing: usagePricing.current(),
funding: { buckets: [fs.prepaid(fs.money.usd(25), { topUp: true })] },
spendPolicy: { onExhaustion: fs.exhaustion.block },
});
fs.plan("trial", {
kind: fs.plan.kind.trial,
price: fs.money.usd(30).monthly(),
lifecycle: { trialDays: 14 },
});
An opaque-allowance plan (the "5x / 20x" shape) keeps rates hidden and shows subscribers only allowance remaining:
fs.plan("max-5x", {
kind: fs.plan.kind.hybrid,
price: fs.money.usd(100).monthly(),
usagePricing: usagePricing.current(),
funding: {
buckets: [
fs.included(fs.money.usd(25), {
display: fs.display.multiplier({ factor: 5 }),
}),
],
},
spendPolicy: {
disclosure: fs.disclosure.opaque,
onExhaustion: fs.exhaustion.overage(usagePricing.current()),
},
});
The multiplier's base is always amount / factor ($5 here) — an inconsistent
display cannot be authored. Two plans on the same catalog with $25 and $100
allowances are exactly 5x and 20x of the same $5 base, so the marketing label
is honest by construction. See the
enterprise LLM archetype for multi-measure,
multi-dimension pricing.
Price values
Create prices with the SDK so money is represented exactly:
fs.money.usd(29).monthly();
fs.money.usd(290).yearly();
Amounts passed to fs.money.usd() are human major units with at most two
decimal places. Sub-cent per-unit rates are authored on the catalog with
fs.rate.per(1000, fs.money.usd(5)) (half a cent per unit) or
fs.rate.perMillion(...), never as fractional dollars on the plan.
Grants
Plans accept route refs, group refs, and frontend-integration refs under
grants. Grants are access declarations only; monetary values are rejected. A
declaration is not customer-accessible merely because it exists — access comes
from the subscriber's compiled plan grants, except for explicitly public
routes.
Limits and capacity
Every plan needs at least one complete rate-limit rule at build time, including paid plans. A resource count cap alone does not satisfy that requirement.
Plan limits accept typed structural bounds:
limits: [requests.perMinute(600), projects.max(25)];
fs.requests() provides per-window request bounds; fs.resource() provides
inventory caps. capacity bounds one request (input tokens or payload bytes).
Structural bounds are admission policy; they never change what a measurement
costs. Prepaid and hybrid plans are additionally bounded by their funding: the
gateway reserves each request's economic maximum against the subscriber's
buckets before forwarding it (see
Monetary admission).
Window strategies and compiler constraints
Temporal limits default to fixed_window. The SDK also accepts
sliding_window and, for sub-day limits, token_bucket. Choose the algorithm
explicitly when burst behavior matters; do not confuse a billing allowance with
a request-rate limit.
| Authored rule | Compiler behavior or rejection |
|---|---|
| Window shorter than 86,400 seconds | Emits a rate-limit constraint |
| Window of 86,400 seconds or longer | Emits a quota constraint, including custom windows |
Quota-length window with token_bucket | Rejected; use fixed/sliding window or a sub-day token bucket |
Token bucket without both bucketCapacity and refillRatePerSecond | Rejected by the SDK |
| Token bucket burst capacity greater than the limit capacity | Rejected; the burst must not widen the authored limit |
| Bucket fields on a non-token-bucket rule | Rejected by the SDK |
MAX, LATEST, or UNIQUE_COUNT with non-fixed accounting | Rejected for nonzero capacity; these aggregations are not additive over a rolling horizon |
Bucket capacity and refill rate must be positive finite numbers. Zero limit capacity is a deny-all policy, not an unlimited sentinel; Core normalizes its algorithm to fixed-window accounting. This does not excuse invalid bucket fields or a token bucket on a quota-length window.
The SDK's named day/week/month durations are 86,400 / 604,800 / 2,592,000 seconds. A named month is a 30-day duration; do not assume it means a calendar month or the subscription's billing period. Keep structural windows and billing cadence distinct when explaining the product to subscribers.
Availability and retirement
selfServeEnabled: false removes a plan from customer self-service while
preserving existing assignments. archive can schedule retirement and point at
a successor plan ref.
Do not delete a plan declaration while subscribers still depend on it. Existing subscriptions stay pinned to the release they bought; see Commercial releases.
Product bounds
The active contract supports at most six plans. Keep the plan set legible; prefer clear plans over a matrix of tiny variations.
Before releasing a change
farthershore build
farthershore commercial-release diff <from-release.json> <to-release.json>
git push
farthershore apply-timeline inspect <business> <commit-sha> \
--env production \
--format json
A published release affects new subscriptions. Existing subscriptions keep
their recurring-price pin; subscribers bound to pricing.current() pick up the
usage rates of each newly activated release, forward only — "current" means the
catalog version in the release being served, not a rate that moves before you
publish, and work already admitted is never rerated. For an active
repository-managed
business, production publication happens through a GitHub Release for the
reviewed commit. farthershore business publish is only for first activation
while the business is still DRAFT.