Plans & pricing
@Plan — price, limits, grants, overage pricing, trials, and spend caps, all as structured value types.
@Plan — price, limits, grants, overage pricing, trials, and spend caps, all as structured value types.
A @Plan is a sellable tier: its recurring price, the rate limits and resource caps it
enforces, the capabilities it grants, optional per-unit overage
pricing, trials, and spend caps. Plan values are structured value types — price is
integer cents, limits are { rate, interval }, overage is { micros } — that the SDK
normalizes into the IR. Money is integer-only and taken verbatim: no ×100.
import { Product, Requests, Plan, capabilityGrant } from "@farthershore/product";
@Product({ name: "croncloud", origin: "https://api.example.com" })
export default class CronCloud {
@Requests()
requests!: unknown;
@Plan("starter", {
name: "Starter",
price: { amount: 2900, currency: "usd", interval: "month" }, // $29.00 / month
grants: [capabilityGrant("managed-cron", { limits: { cron_jobs: 10 } })],
limits: { requests: { rate: 600, interval: "minute", enforcement: "enforce" } },
})
starter!: unknown;
@Plan("pro", {
name: "Pro",
price: { amount: 19900, currency: "usd", interval: "month" }, // $199.00 / month
grants: [capabilityGrant("managed-cron", { limits: { cron_jobs: 100 } })],
limits: { requests: { rate: 6000, interval: "minute", enforcement: "enforce" } },
})
pro!: unknown;
}
Plans are sorted by key in the emitted IR (pro before starter), so declaration order does
not affect the IR hash.
price is a Price value: either a paid recurring fee or { free: true }.
price: { amount: 2900, currency: "usd", interval: "month" } // $29.00/mo — amount is CENTS
price: { amount: 19900, currency: "usd", interval: "year" } // $199.00/yr
price: { free: true } // free plan
amount is integer cents, taken verbatim into recurring_fee_cents. 2900 is $29.00,
not $2,900. A non-integer or negative amount throws.currency must be "usd".interval is "month" or "year".price entirely for a zero-fee plan, or pass { free: true } to mark it free.amount is cents, but meter overage micros is micro-dollars — two different money
units that must not be crossed. $29.00 is amount: 2900; a $0.002/unit overage is
micros: 2000. See value-types.ts for the exact conventions.
limits is a per-dimension record. Each entry is either a rate limit ({ rate, interval })
or a resource count cap ({ count }). The SDK splits them: rate limits become the IR
limits[]; counts become capability_limits.
limits: {
requests: { rate: 600, interval: "minute", enforcement: "enforce" }, // → limits[]
cron_jobs: { count: 10 }, // → capability_limits
}
A rate limit (RateLimit):
| Field | Type | Meaning |
|---|---|---|
rate | number (positive integer) | Capacity per window. |
interval | "second" | "minute" | "hour" | "day" | "week" | "month" | The window. (No year — yearly is a price interval, not a window.) |
enforcement | "enforce" | "track" (optional) | enforce blocks over-limit; track only records. Omitted ⇒ no enforcement key in the IR. |
A count cap is { count: number }. The caps field is a count-only alias for the same
capability_limits destination, accepting { count: 10 } or a bare 10:
caps: { cron_jobs: 10 } // equivalent to limits: { cron_jobs: { count: 10 } }
Every plan must carry at least one rate-limit rule. A limitless plan fails the build with
PLAN_RATE_LIMIT_REQUIRED — the hint shows the minimal shape:
limits: { requests: { rate: 600, interval: "minute" } }. This is checked at
farthershore-manifest-build time, identical to the publish gate.
Grant capabilities (optionally with per-capability resource limits)
via grants and capabilityGrant, or grant limit-less capabilities via capabilities.
Credit grants also ride grants.
grants: [capabilityGrant("managed-cron", { limits: { cron_jobs: 10 } })],
capabilities: ["premium_tools"], // limit-less capability grants
grants also accepts credit grants in the IR GrantJson shape
({ kind: "credit" | "top_up", … }) for prepaid credit and purchasable packs.
meter prices metered usage above the included allotment. Each entry is a MeterOverage:
micros is the flat per-unit rate in micro-dollars, and includedUnits is an optional
free allotment. Entries become the plan's meters[] in declaration order.
meter: {
tokens_used: { micros: 2000, includedUnits: 100000 }, // $0.002/token after 100k free
}
| Field | Type | Meaning |
|---|---|---|
micros | number (non-negative integer) | Per-unit price in micro-dollars (2000 = $0.002). Taken verbatim into price_per_unit_micros. |
includedUnits | number (positive integer, optional) | Free allotment before overage applies. |
If a meter entry needs platform-native keys the structured MeterOverage does not model, use
the raw meters passthrough (the platform 5-knob meters[] shape) instead — but meter and
meters are mutually exclusive on one plan.
| Option | Type | Meaning |
|---|---|---|
trialDays | number | Free-trial length in days → trial_days. |
maxMonthlySpendCents | number | Hard spend cap (cents) → max_monthly_spend_cents. |
minMonthlySpendCents | number | Minimum monthly spend (cents) → min_monthly_spend_cents. |
overageBehavior | "block" | "allow_and_bill" | What happens past included usage. |
featureGates | Record<string, boolean> | Per-plan feature-gate flags. |
details | string[] | Marketing bullet points. |
selfServeEnabled | boolean | Whether subscribers can self-select this plan. |
legacy | boolean | Marks a grandfathered plan. |
archive | { at?, transitionTo?, strategy? } | Archive schedule and migration target. |
raw | Record<string, unknown> | Escape hatch merged onto the emitted plan spec last (A/B variants, future fields). |
@Plan("pro", {
name: "Pro",
price: { amount: 19900, currency: "usd", interval: "month" },
limits: { requests: { rate: 6000, interval: "minute", enforcement: "enforce" } },
meter: { tokens_used: { micros: 1500, includedUnits: 1_000_000 } },
trialDays: 14,
maxMonthlySpendCents: 500_00, // $500.00 hard cap
overageBehavior: "allow_and_bill",
})
pro!: unknown;
A @Product class is a snapshot. Repricing or changing a plan and pushing does not
move existing subscribers — they are grandfathered by default. Actively migrating subscribers
to a new plan version is an operate action, not a declaration:
# Move every croncloud "pro" subscriber from version 1 to the latest at their next renewal.
# Policies: grandfather | next_renewal | immediate | by_date | opt_in
farthershore plan migrate croncloud pro --from 1 --to head --policy next_renewal
So: edit the @Plan, push to apply the new contract; run plan migrate only when you want
existing subscribers moved. See migrations for the full operate flow
(also available over MCP).
The starter plan above folds into one PlanSpecJson:
{
"key": "starter",
"name": "Starter",
"recurring_fee_cents": 2900,
"billing_interval": "month",
"limits": [{ "dimension": "requests", "window": { "type": "named", "name": "minute" }, "capacity": 600, "enforcement": "enforce" }],
"capabilities": ["managed-cron"],
"capability_limits": { "cron_jobs": 10 }
}
See the Manifest IR for the full envelope and the determinism gate.