Appearance
0030 — Progressive Feature Enablement ​
Status: Accepted (2026-06-22)
Date: 2026-06-22
ADO work item: AB#3692 (Feature — Progressive feature enablement / feature registry); Epic AB#3074
Deciders: Kristopher Turner (platform owner)
Context ​
ADR 0008 (platform composition) establishes that features are built as API + data + UI slices added in delivery order — Platform first, then iOS, then Sermons & Music Hub, Calendar, Messaging, Android, and so on. The delivery roadmap means that at any point in time a large proportion of the planned feature set has not yet been built. The question this ADR answers is: what does the UI look like before a feature is ready?
Without an explicit gating strategy, three bad alternatives emerge:
- Ship placeholders. Navigation items that route to "Coming soon" pages are noise and erode trust.
- Feature-branch the entire app. A separate build per "what's ready" breaks the single-codebase principle and multiplies release work.
- Hardcode nav per release. Every delivery requires manual edits to navigation files across web, iOS, and Android — error-prone and untestable.
The platform is also web-app-first (ADR 0027, ADR 0031): the web client is the primary verified delivery surface. iOS and Android are clients of the same backend and consume the same gating information. The gating mechanism must work consistently across all three surfaces without per-client drift.
Decision ​
Feature visibility on all surfaces (web, iOS, Android) is driven by a feature registry. A feature's icons, navigation entries, routes, and settings panels appear in the UI only after that feature has been built and onboarded into the registry. Un-onboarded features show nothing — no placeholder, no "coming soon" label. The core navigation shell (bottom tab bar on mobile, primary nav on web) persists regardless of how many features are active.
Feature registry contract ​
Each registered feature is described by a record conforming to the following shape. The registry is the source of truth for both the web config-driven nav/route engine and the mobile tab/screen registration:
ts
// packages/shared-config/src/featureRegistry.ts
// UNVALIDATED — verify shape compiles and is imported correctly before shipping.
export interface FeatureRegistryEntry {
/** Stable key used in RBAC checks, analytics, and ADO cross-references */
featureKey: string;
/** Controls whether the feature is visible at runtime */
enabled: boolean;
/** Human-readable display label (used in nav, headers, settings) */
label: string;
/** Icon identifier resolved by the design system (ADR 0027) */
icon: string;
/** Web route path, e.g. '/sermons'. Mobile uses this as a navigation key. */
route: string;
/** Whether a settings panel entry is shown for this feature */
settings: boolean;
/** Minimum Plane-2 RBAC role required to see this feature (ADR 0006) */
minRole: 'visitor' | 'member' | 'small_group_leader' | 'ministry_leader' | 'community_leader' | 'comms_author';
}Fields are stable; the shape must not be changed without a new ADR or an amendment to this one.
Runtime source of truth ​
The registry is served by the backend API (GET /config/features) so the web and mobile clients receive the same computed list — already filtered by the caller's RBAC role. The shape above is the canonical definition in packages/shared-config; the API reads from it at startup and applies server-side role filtering before returning entries to clients.
A static snapshot of the registry (for tests and initial scaffold) lives in packages/shared-config/src/featureRegistry.ts and is imported by the API. Do not maintain separate per-client copies.
Web React implementation ​
On the web surface (apps/web):
- A config-driven router reads the API response (or a static registry during local dev) and registers React Router routes only for enabled entries.
- The navigation component iterates the enabled entry list; it renders no entry for a feature not in the list.
- Directly accessing a disabled feature's route returns a 404 from the router — no placeholder page.
- The bottom tab bar (mobile browser / PWA view) and sidebar nav (desktop) both derive their items from the same filtered list.
Mobile implementation (iOS / Android) ​
The React Native (apps/mobile) surface consumes the same GET /config/features endpoint. Tab screen registration and deep-link route tables are built at app-launch from the API response:
- Only enabled feature keys are registered as tab screens.
- The bottom tab bar renders only what the registry returned.
- The core tab bar itself (account/profile, notifications) is not registry-driven; it is always present (see "Core navigation shell" below).
Core navigation shell ​
The following UI elements are present regardless of the registry state:
| Surface | Always-present element |
|---|---|
| Mobile (iOS + Android) | Bottom tab bar shell; Account/Profile tab; Notifications tab |
| Web PWA | Primary navigation bar; Account/Profile link; Notifications entry |
| All | Sign-in / Registration screens; Family portal screens (once Platform ships) |
These elements are platform responsibilities (ADR 0008) and are not gated.
Onboarding a feature ​
A feature is considered "onboarded" when:
- Its API endpoints pass acceptance tests.
- Its web and mobile UI components are built from
@hch/ui(ADR 0027). - Its
FeatureRegistryEntryhas been added topackages/shared-config/src/featureRegistry.tswithenabled: true. - The ADO story acceptance criteria for that feature have been verified (AB# cross-reference in the entry comment).
Setting enabled: false (or omitting the entry) is the sole mechanism to ship a build that does not expose an unfinished feature. No feature branches, no build flags, no per-client config files.
Alternatives considered ​
| Option | Pros | Cons | Why not chosen |
|---|---|---|---|
| Feature registry, API-served (chosen) | Single source of truth; role-aware filtering; works identically on web + mobile | Requires API call at startup; registry shape is a contract that must not drift | — chosen |
| Hardcoded navigation per release | Simple | Every release requires manual nav edits in 3 surfaces; error-prone; untestable | Rejected — violates the "code may not depend on an unvetted choice" principle and creates per-surface drift |
Build-time flags (VITE_FEATURE_X=true) | Zero runtime cost; easy CI toggle | Separate builds per feature set; environment variable sprawl; mobile can't change flags post-install | Rejected — breaks the single-build principle and creates separate runtime artifacts |
| Per-client config files | Decentralized control | Three separate files to maintain; drift is guaranteed; no role awareness | Rejected — duplicates the registry for each surface without eliminating the maintenance problem |
| Placeholder / "Coming soon" pages | Users see what's planned | Erodes trust for a small community; placeholder links in nav are confusing; requires cleanup every release | Rejected — adds noise with no functional value |
Consequences ​
Positive ​
- Features that are not ready are genuinely absent from the UI — no dead links, no "coming soon" clutter, no release-gate complexity.
- Role-filtered API response means a visitor never sees Community Leader-only feature entries even if those features are enabled.
- Adding a new feature to the app requires a registry entry and a code PR — no navigation files to hunt down across surfaces.
- The registry is testable: unit tests can assert that enabled features resolve routes and disabled features do not appear in the nav.
Negative / trade-offs ​
- Feature availability depends on the API being reachable at startup; if
/config/featuresis unavailable the app must fall back to a cached registry (the static snapshot in shared-config serves as the fallback). - The registry shape is a shared contract — changes to it are a breaking API change and require coordinated updates across
shared-config, the API, the web client, and the mobile client.
Risks ​
- Registry drift — a feature is marked
enabled: truebefore its API endpoints are ready. Mitigation: the onboarding checklist (see "Onboarding a feature" above) gates the registry update on acceptance-test passage. - Role filtering bypass — if the web client caches an unfiltered registry, a lower-role user could see a higher-role feature's nav entry. Mitigation: the API performs role filtering server-side and the client must not cache across role changes (clear on sign-out / role change).
References ​
- ADR 0008 — Platform composition — composition contract; features extend the platform
- ADR 0009 — iOS app delivery — iOS-specific nav considerations
- ADR 0014 — Android app delivery — Android-specific nav considerations
- ADR 0027 — Web frontend & design-system tooling —
@hch/uiimport rule; bottom tab bar component - ADR 0031 — Code-first design workflow & cross-platform delivery
- pmo/platform-strategy.md — Delivery roadmap
- ADO: Feature AB#3692, Epic AB#3074 (Platform build)