# LCEssentials — Feature Control A typed Swift client for Atomenta's Feature Control API: flag evaluation with a TTL cache and safe-degrade fallback, an in-app notifications inbox, and batched exposure telemetry. Lives in its own SPM product, `LCFeatureControl`, not in `LCEssentials` itself — an optional sub-package that target-depends on `LCEssentials` (same shape as [`LCECryptoKit`](Extensions.md), see its section there for the sub-SPM rationale). Every section is a collapsible block — click a heading to expand it. ## Contents - [Setup](#setup) - [Evaluating flags](#evaluating-flags) - [Notifications inbox](#notifications-inbox) - [Exposure telemetry](#exposure-telemetry) - [Errors](#errors) --- ## Setup Add the product explicitly alongside `LCEssentials` to a consumer's `Package.swift`: ```swift .product(name: "LCEssentials", package: "LCEssentials"), .product(name: "LCFeatureControl", package: "LCEssentials"), ``` ```swift import LCFeatureControl ```
FeatureControlConfiguration — wiring for one module deployment ```swift let configuration = FeatureControlConfiguration( baseURL: "https://api.example.com", environment: "production", auth: FeatureControlBearerAuth { await session.currentJWT() } ) ``` | Property | Default | Notes | | --- | --- | --- | | `cacheTTL` | `45` seconds | In-memory client-side cache window | | `requestTimeout` | `3` seconds | Per-request timeout budget | | `evaluatePath` / `notificationsPath` / `telemetryPath` | `/api/feature-control/...` | Override only for a non-default BFF mount |
Auth strategiesFeatureControlAuthorizing No `Atomenta-Token` (module token) type exists in this package on purpose — that token must never be embedded in a customer-facing app. Pick one: ```swift // Internal/admin app hitting Atomenta directly with a panel-role JWT. let auth = FeatureControlBearerAuth { await session.currentJWT() } // Customer app calling its own BFF, which enforces its own auth — // adds exactly the given headers, never synthesizes Authorization. let auth = FeatureControlHeaderAuth(headers: ["X-BFF-Session": sessionToken]) ```
## Evaluating flags
FeatureControlManager — cache, safe-degrade, invalidation ```swift let manager = FeatureControlManager( configuration: configuration, defaults: ["fc.checkout_v2": FeatureControlFlag(enabled: false, variant: nil, payload: nil, reason: "default")] ) let context = FeatureControlContext(subjectType: .customer, subjectId: user.id, storeId: store.id, platform: "ios", appVersion: appVersion) let snapshot = await manager.evaluate(keys: ["fc.checkout_v2"], context: context) snapshot.flags["fc.checkout_v2"]?.enabled // Bool ``` `evaluate(keys:context:)` **never throws** — fresh fetch → stale cache → the `defaults` passed at init, in that order. Use it everywhere a flag gates real product behaviour. `evaluateOrThrow(keys:context:)` surfaces the real failure instead, for admin/debug tooling only. Concurrent calls for the same key on a cold cache are coalesced onto a single in-flight request — three callers evaluating the same key at once produce one network call, not three. ```swift await manager.invalidateCache() // call on user/store change ``` The cache key is `environment + subjectId + sortedKeys + platform + appVersion` — it does **not** include `storeId` or `attributes`. If a subject's flags can legitimately differ by store, call `invalidateCache()` on store switch.
## Notifications inbox
FeatureControlNotificationsClient — JWT-only The OpenAPI fragment declares `security: [bearerAuth]` for every notifications route, no module-token alternative — configure with `FeatureControlBearerAuth`. ```swift let client = FeatureControlNotificationsClient(configuration: configuration) let (items, nextCursor) = try await client.list(status: .unread, limit: 20) let (more, _) = try await client.list(status: .unread, limit: 20, cursor: nextCursor) try await client.markRead(id: notification.id) try await client.markAllRead() ```
## Exposure telemetry
recordExposure(_:) / flushExposures() — best-effort, batched ```swift await manager.recordExposure(FeatureControlExposureEvent( featureKey: "fc.checkout_v2", variant: "on", subjectType: .customer, storeId: store.id )) // ... later, e.g. on app background or a periodic timer: await manager.flushExposures() ``` Buffers locally — nothing is sent until `flushExposures()` is called. Splits into batches of ≤100 (server `maxItems: 100`). Not a critical-path operation: a failed batch is dropped, never retried indefinitely.
## Errors
FeatureControlError — from evaluateOrThrow and the notifications client ```swift public enum FeatureControlError: Error, Sendable, Equatable { case invalidContext(code: String) // 400 case unauthorized(code: String) // 401 case forbidden(code: String) // 403 case rateLimited(code: String) // 429 case server(code: String, status: Int) case transport(message: String) // decode failure / non-HTTP error } ``` The safe `evaluate(...)` path never surfaces this — it degrades to cache/defaults instead. `evaluateOrThrow` also throws `.server(code:status: 200)` if the server responds `200` with an `{"error": true}` envelope (a degraded-but-200 response).
--- See [Extensions.md](Extensions.md) for the rest of `LCEssentials`' Foundation helpers, including the sibling `LCECryptoKit` sub-product.