Editions & licensing
probectl is open-core: the core platform is source-available and free, and a commercial tier (Enterprise, plus a Provider/MSP tier) is gated. This document is the engineering contract for how that split is enforced in the codebase.
The one-sentence version: it is one repo with one binary lineage and no edition branches — the commercial boundary is a license file plus a directory fence, never a fork. Everything below is an elaboration of that sentence.
The model in one paragraph
Commercial source lives in the top-level ee/ tree under a commercial license
header. When the repo goes public, ee/ is publicly readable — the fence is the
license and trademark, not source secrecy (the same model GitLab and CockroachDB
use). Imports are strictly one-way: ee/ may import core, but core may never
import ee/. CI enforces that, and a "core-only" build must compile and pass its
tests with ee/ absent from the link. At runtime, a commercial feature activates
only when an offline-verifiable, Ed25519-signed license file grants it.
Verification is local math against public keys baked in at build time — it
never phones home.
Why this shape? Three goals at once: keep the platform genuinely open and auditable; let a single binary serve both the free and paid cases without a separate "enterprise edition" download; and make "we don't phone home" a claim you can check by reading the open code, not just trust.
Tiers and the feature→tier table
There is exactly one feature→tier table in the whole codebase:
tierFeatures in internal/license/license.go. Tier knowledge is never
duplicated anywhere else, so there is a single source of truth.
| Tier | Gated features |
|---|---|
community |
None — everything not listed below is core and free forever. |
enterprise |
fips (a build artifact, see below), byok, governance, remediation, ha_support |
provider |
provider_plane, siloed_isolation, metering, white_label |
Read the tiers as independent feature sets, not a strict superset. A
provider license grants the four provider features — it does not
automatically include the enterprise features. In the code, each feature is
checked on its own (granted() looks only at tierFeatures[the license's tier]
plus any explicit extras), and the license test asserts exactly this: a provider
license has provider_plane but not remediation unless remediation is listed
as an explicit extra. A real-world "provider that also wants BYOK" deal is
expressed by issuing a provider license with byok in its features extras
list (see the license file below) — not by an implied inheritance.
Some capabilities are deliberately core (free), even though they sound commercial:
- Per-tenant data export and verifiable deletion — a compliance right, not a product to sell.
- Fairness enforcement — it protects the shared pooled platform, so everyone
gets it. (The provider-console views of fairness live in
ee/.) - Support-bundle generation — the tool is core; the support SLA is a contract, not a code gate.
And "Starter/Pro" pricing tiers need no code gating at all: they are entitlement (support/SLA) tiers riding the same core binary.
fips is the one exception to runtime gating. The FIPS 140-3 build is gated by
the artifact, not by a lic.Has(fips) check — there is no runtime license
gate for FIPS anywhere in the binary. The validated distribution is what you build
with make build-fips (which sets GOFIPS140 and the probectl_fips tag); that
build embeds the FIPS 140-3-validated Go Cryptographic Module, and being that
build is the entitlement. The fips row in the table simply documents which tier
that distribution belongs to. A running binary reports its FIPS posture on
/v1/editions (build tag, live module state, self-test result) purely as a status
indicator. The exact validation claim boundary is in hardening.md.
The license file
A license is a small JSON envelope: base64 of the exact signed payload bytes plus a detached Ed25519 signature (no JSON canonicalization games — the bytes that were signed are the bytes that are verified).
{
"payload": "<base64 of the claims JSON>",
"signature": "<base64 Ed25519 signature over those exact bytes>"
}
The claims inside:
{
"v": 1,
"id": "lic_2026_0001",
"customer": "Reseller GmbH",
"tier": "provider",
"features": ["byok"],
"tenant_band": 25,
"issued_at": "2026-06-05T00:00:00Z",
"expires_at": "2027-06-05T23:59:59Z"
}
tierimplies its feature set from the one table;featureslists explicit extras on top (the mechanism for a one-off grant, like the "provider + byok" deal above).tenant_bandis the licensed tenant-count band (0or absent = unlimited). It is enforced at tenant provisioning time (the provider plane refuses to create a tenant past the band, withtenant_band_exhausted), and is never a kill-switch on already-running telemetry.- Verification rejects: an unknown payload version, an unknown or
communitytier (community needs no license at all), a signature that fails against every trusted key, and an inverted validity window (expiry before issue). An expired license still loads — expiry is a state, not a parse error (see the ladder below).
Trust anchor: build-time only
Trusted public keys are baked at build time via ldflags into
internal/license.builtinPubKeysB64 (comma-separated base64 PEMs, so keys can
rotate by baking two). The trust anchor is never an env var, config key,
or file — otherwise anyone could point a build at their own key.
go build -ldflags "-X github.com/imfeelingtheagi/probectl/internal/license.builtinPubKeysB64=<base64 PEM>[,<base64 PEM>]" ./cmd/probectl-control
Dev builds bake no keys: unconfigured deployments run Community; a configured license file against a keyless build fails startup loudly (fail closed — a license you cannot verify is a misconfiguration, not a shrug).
Signing CLI (cmd/probectl-license)
Vendor-side tooling; never shipped in customer images.
# 1) Generate the signing pair (private key 0600; prints the ldflags bake line)
probectl-license gen-key -out-priv signing.key -out-pub signing.pub
# 2) Sign a license (expiry = end-of-day UTC)
probectl-license sign -key signing.key -customer "Reseller GmbH" \
-tier provider -tenant-band 25 -expires 2027-06-05 -out license.json
# 3) Verify against a public key (what the control plane does at startup)
probectl-license verify -file license.json -pub signing.pub
# 4) Inspect WITHOUT verifying (clearly labeled as unverified)
probectl-license inspect -file license.json
Runtime states: the expiry ladder
The guiding principle here: an expired license must never break your observability. A monitoring tool that goes dark the day a contract lapses is a liability during exactly the kind of incident you bought it for. So expiry degrades commercial write paths gradually and leaves the telemetry pipeline untouched.
PROBECTL_LICENSE_FILE points the control plane at the license (see
configuration.md). The states, in order:
| State | When | Behavior |
|---|---|---|
community |
No license configured | Default-open core; commercial features hidden. |
active |
Within validity | Granted features enabled. |
grace |
0–30 days past expiry | Features stay enabled; the UI banners the deadline. |
read_only |
>30 days past expiry | Granted features degrade to read_only: existing views still render, but no new tenants or config; branding persists; telemetry pipelines never break. Expired is not the same as broken observability. |
In code, this is why there are two methods: Manager.Has(f) stays true in
read_only (so read paths still construct and serve), while Manager.Mode(f)
distinguishes enabled / read_only / off for write gating.
Gating pattern (the only sanctioned shape)
Tier checks are wired only at the main.go Build* seams — never inside
handlers, engines, or stores. The concrete seam is one file:
cmd/probectl-control/ee_attach.go (the only file the editions guard
allowlists for importing ee/), which must carry the //go:build !probectl_core tag:
// ee_attach.go (build !probectl_core) — the ONE place core meets ee/.
func attachEE(srv *control.Server, ..., lic *license.Manager, ...) error {
if lic.Has(license.FeatureProviderPlane) { // one Has() per feature
h, err := provider.Build(cfg, provider.Deps{...})
if err != nil { return err }
srv.WithProviderPlane(h) // core sees an opaque http.Handler
}
// ... one more `if lic.Has(...)` block per commercial feature ...
return nil
}
The trick that makes "core stands alone" literally true: there is a no-op twin,
ee_attach_core.go, tagged //go:build probectl_core, whose attachEE does
nothing. The core-only build (-tags probectl_core, which is what make editions-gate compiles) links that twin and therefore pulls in zero ee/
packages — you can verify it directly with go list -tags probectl_core -deps ./cmd/probectl-control | grep /ee (the output is empty). One binary lineage, two
link sets; in the default build, activation stays license-gated.
Scattering if licensed checks through business logic is a review-blocking
defect, because every such check is one more place a bug could become a licensing
bypass or — worse — a core regression. Keeping all the checks at one seam
keeps that surface tiny. (A licensed feature may still consult Mode(feature)
internally to implement its own read-only degrade — that is the feature's
behavior, not gating.)
Unlicensed UX
Commercial features are hidden when unlicensed — no lockware, no upsell
chrome. The single exception is Admin → Editions (/v1/editions, the
EditionsCard): it renders the license state and the full feature→tier map
so an operator can see what exists and what their file grants.
CI: the editions gate
make editions-gate is a standing CI job. It does two things:
scripts/check_editions_imports.sh— greps for any core import of…/probectl/ee/…, allowing ONLY theee_attach.goseam (and only when it carries//go:build !probectl_core); runs its ownSELFTEST=1(plants violations, asserts detection) so the guard can never silently rot.- Builds and tests the core-only package set with
-tags probectl_core(everything exceptee/..., linking the no-op attach twin) — proving core stands alone withee/truly absent from the link.
Auditability
internal/license is core (not ee/) on purpose: "verification is
offline, there is no phone-home" is a checkable claim only if the code that
makes it is in the open part of the tree. The verify path does file reads and
Ed25519 math — no sockets.
What this is not
- Not DRM: a determined fork can delete the checks. The fence is the commercial license + trademark; the gate is for honest customers.
- Not a kill-switch: no state in the ladder ever stops ingestion, probing, alerting, or dashboards that already exist.
- Not finalized legal text: the repository
LICENSEis a placeholder andee/files carry a placeholder commercial header until counsel delivers the final texts. The enforcement mechanics above are complete either way.