Two systems, two purposes
Gram has two distinct feature-gating mechanisms. They are not interchangeable — pick based on the semantics of the flag, not on convenience.
productfeatures |
PostHog feature flags | |
|---|---|---|
| Scope | Per-organization | Per-user or per-group |
| Who controls it | Org admins (dashboard) or Speakeasy ops | Engineering (PostHog console) |
| Persistence | PostgreSQL (organization_features table) |
PostHog platform |
| Direction | Permanent capability toggle; maps to entitlements | Temporary rollout gate; removed once GA |
| Example use | "This org has SSO" / "This org was sold Risk" | "Dogfood this UI change with our org" |
| Frontend | useProductFeatures() hook |
posthog.isFeatureEnabled() |
Decision rule
Use
productfeatureswhen the toggle represents a durable, org-level capability that admins control or that maps to what the customer purchased (entitlement). Think: GCP's per-project API enablement.Use PostHog flags when you're rolling something out gradually, dogfooding a change internally, or need per-user granularity. The flag is expected to disappear once the feature ships to everyone.
If you're gating a dev-phase UI change that you only want your dogfooding org to see → PostHog.
If you're shipping a capability that some customers pay for and others don't → productfeatures.
productfeatures
Concepts
- Feature constants live in
server/internal/productfeatures/features.goas typed string constants (Featuretype). - State is stored in
organization_features(PostgreSQL). Soft-deletes track removal. productfeatures.Clientchecks Redis first (15 min TTL), falls back to Postgres, and returns errors on lookup failures — usePlatformFeatureCheckwhen you want silent false-on-error degradation.- The management API (
/rpc/productFeatures.getand/rpc/productFeatures.set) exposes the state to the dashboard and Speakeasy ops.
Adding a new product feature
1. Declare the constant in server/internal/productfeatures/features.go:
const (
FeatureMyNewFeature Feature = "my_new_feature"
)
2. Add it to the Goa design in server/design/productfeatures/design.go:
// In the setProductFeature method's Enum constraint:
Enum("logs", "tool_io_logs", ..., "my_new_feature")
// In the getProductFeatures Result:
Attribute("my_new_feature_enabled", Boolean, "Whether my new feature is enabled")
3. Wire the result in server/internal/productfeatures/impl.go:
MyNewFeatureEnabled: isEnabled(FeatureMyNewFeature),
4. Regenerate with mise generate (Goa codegen updates gen/).
Checking a feature at runtime (Go)
// inject *productfeatures.Client — it's already wired in cmd/gram/start.go
enabled, err := pf.IsFeatureEnabled(ctx, organizationID, productfeatures.FeatureMyNewFeature)
if err != nil {
return fmt.Errorf("check my_new_feature: %w", err)
}
Use PlatformFeatureCheck (returns bool, logs errors, degrades to false) only in non-critical paths where you want silent degradation.
Checking in the React dashboard
const { data: features } = useProductFeatures();
if (features?.myNewFeatureEnabled) { ... }
PostHog feature flags
Concepts
- Flag constants live in
server/internal/feature/flags.goas a typedFlagstring. - The
feature.Providerinterface (IsFlagEnabled(ctx, flag, distinctID)) is the only server-side API — inject it, don't call PostHog directly. - The PostHog implementation polls the PostHog platform; a noop stub is used in tests.
distinctIDis typically the user's ID or email. For org-level targeting you can use the org ID and configure matching groups in PostHog.
Adding a new PostHog flag
1. Declare the constant in server/internal/feature/flags.go:
const (
FlagMyNewFeature Flag = "my-new-feature" // must match the key in PostHog
)
2. Create the flag in PostHog — set release conditions (e.g., "users in group X") via the PostHog console. The string key must match exactly.
3. Check at runtime by injecting feature.Provider:
enabled, err := featureProvider.IsFlagEnabled(ctx, feature.FlagMyNewFeature, userDistinctID)
if err != nil || !enabled {
// flag off or unavailable
}
Checking in the React dashboard
New frontend code should use the typed useFeatureFlag() hook from
client/dashboard/src/hooks/useFeatureFlag.ts:
const assistants = useFeatureFlag(FEATURE_FLAGS.assistants);
if (assistants.status === "loading") return null;
if (assistants.status === "missing" || assistants.status === "error") {
return <FeatureUnavailable />;
}
const assistantsEnabled = assistants.status === "enabled";
Add new frontend keys to the FEATURE_FLAGS registry in
client/dashboard/src/lib/featureFlags.ts; its values generate the hook's
FeatureFlag type. The hook distinguishes loading, enabled, disabled, missing,
and error states and stays reactive as PostHog reloads flags. On localhost,
the development telemetry provider reports every flag as enabled.
PostHog flags control rollout UI only. Never use them for authorization or entitlement enforcement, and never import PostHog hooks directly.
Common mistakes
- Don't use
productfeaturesfor a temporary dogfood gate. It creates API surface (getProductFeaturesresult field, Goa enum) that must be maintained indefinitely. - Don't use PostHog for entitlements. PostHog is engineer-controlled and ephemeral; entitlement checks belong in
productfeaturesso org admins and billing can manage them. - Don't hardcode PostHog flag strings in multiple places. Always declare them as constants in
feature/flags.go. - Don't call the PostHog SDK directly from Go handlers. Always go through the
feature.Providerinterface so tests can inject a noop.