# Feature Flag

> Decision guide and implementation patterns for Gram's two feature-gating systems: `productfeatures` (org-level, admin-configurable, entitlement-tied) vs PostHog feature flags (per-user/per-group rollout, engineering-controlled). Activate whenever the task involves gating a feature behind a flag, dogfooding a change, deciding which system to use, or adding a new product feature or PostHog flag.

- Skill: `speakeasy-api/feature-flag` (Agent Skill)
- Install (CLI): `npx skillmds@latest add speakeasy-api/feature-flag`
- Raw SKILL.md: https://api.skillmd.com/api/skills/speakeasy-api/feature-flag/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Productivity
- Author: speakeasy-api (https://skillmd.com/u/speakeasy-api)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/speakeasy-api/feature-flag

---


## 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 `productfeatures`** when 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.go` as typed string constants (`Feature` type).
- State is stored in `organization_features` (PostgreSQL). Soft-deletes track removal.
- `productfeatures.Client` checks Redis first (15 min TTL), falls back to Postgres, and returns errors on lookup failures — use `PlatformFeatureCheck` when you want silent false-on-error degradation.
- The management API (`/rpc/productFeatures.get` and `/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](../../../server/internal/productfeatures/features.go):

```go
const (
    FeatureMyNewFeature Feature = "my_new_feature"
)
```

**2. Add it to the Goa design** in [server/design/productfeatures/design.go](../../../server/design/productfeatures/design.go):

```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](../../../server/internal/productfeatures/impl.go):

```go
MyNewFeatureEnabled: isEnabled(FeatureMyNewFeature),
```

**4. Regenerate** with `mise generate` (Goa codegen updates `gen/`).

### Checking a feature at runtime (Go)

```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

```tsx
const { data: features } = useProductFeatures();
if (features?.myNewFeatureEnabled) { ... }
```

---

## PostHog feature flags

### Concepts

- Flag constants live in `server/internal/feature/flags.go` as a typed `Flag` string.
- The `feature.Provider` interface (`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.
- `distinctID` is 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](../../../server/internal/feature/flags.go):

```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`:

```go
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`:

```tsx
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 `productfeatures` for a temporary dogfood gate.** It creates API surface (`getProductFeatures` result field, Goa enum) that must be maintained indefinitely.
- **Don't use PostHog for entitlements.** PostHog is engineer-controlled and ephemeral; entitlement checks belong in `productfeatures` so 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.Provider` interface so tests can inject a noop.

