How I Fix Guideline 3.1.2(c) When Subscription Setup Passes but Value Communication Fails
A Guideline 3.1.2(c) rejection analysis: subscription code can pass while value communication still fails App Review.
In the first App Review pass for Should I Decide, the binary and StoreKit products were both valid, but the reviewer still asked what paid users actually unlock beyond a generic "premium" label. That feedback exposed an architecture gap, not a payment bug.
This pattern appears frequently in developer forum threads under Guideline 3.1.2(c): auto-renewing subscriptions are technically configured, but users cannot clearly see title, duration, price semantics, and concrete paid capability before subscribing.
The app may be useful every day, but each individual interaction is tiny. A decision-support app like Should I Decide might help a user resolve a small choice in ten seconds. That is exactly the value: fast cognitive relief. It is also why the subscription architecture needs to be very clear. The product cannot hide behind complexity.
For this type of app, StoreKit integration is only one layer. The deeper architecture is entitlement, usage policy, feature segmentation, offline behavior, and recovery from StoreKit failure.
If the app cannot explain in code what the free user gets, what the subscriber gets, and how access behaves when the network is bad, the paywall will feel arbitrary.
Subscription Rejection Trigger Matrix (Utility Apps)
| Common trigger in utility apps | Guideline pressure | How I resolve it |
|---|---|---|
| Paid tier is labeled "premium" without concrete capability boundaries | 3.1.2(c) | Encode free/paid limits as explicit usage policy in-product |
| Price terms or renewal context are hard to verify before purchase | 3.1.2 | Make subscription title, duration, and links immediately visible |
| Paywall appears before user sees free value loop | 3.1.2 UX expectation | Show baseline utility first, then upgrade path |
| Registration is forced for non-account subscription actions | 5.1.1 | Keep registration optional unless account scope is truly required |
Define value as usage policy, not marketing text
For Should I Decide, the recurring value is repeated decision support:
- More daily decision sessions.
- Saved decision history.
- Deeper comparison prompts.
- More context retained across repeated dilemmas.
That should exist as code, not only paywall copy.
enum AccessTier: Equatable {
case free
case plus(expirationDate: Date?)
}
struct DecisionUsagePolicy {
let tier: AccessTier
var dailySessionLimit: Int {
switch tier {
case .free: return 5
case .plus: return 100
}
}
var allowsHistory: Bool {
if case .plus = tier { return true }
return false
}
var allowsDeepComparison: Bool {
if case .plus = tier { return true }
return false
}
}
The paywall can then describe actual behavior. "Premium AI" is vague. "More daily decision sessions and saved history" is testable.
The entitlement store
I keep StoreKit transaction handling away from views:
@MainActor
final class EntitlementStore: ObservableObject {
@Published private(set) var tier: AccessTier = .free
@Published private(set) var products: [Product] = []
@Published private(set) var loadingState: LoadingState = .idle
func start() {
Task {
await loadProducts()
await refreshEntitlements()
await observeTransactions()
}
}
}
The implementation should cache the last known entitlement:
struct CachedEntitlement: Codable {
let tier: String
let expirationDate: Date?
let verifiedAt: Date
}
The app should not become unusable because StoreKit product loading is slow. If the last verified state was Plus and the subscription is likely still valid, the app can show cached access while refreshing. If there is no verified access, the free path should remain usable.
StoreKit failure should not produce a blank app
A common mistake is making the paywall the only route forward while products load.
I model the paywall state explicitly:
enum PaywallState {
case loading
case ready(products: [Product])
case unavailable(message: String, canContinueFree: Bool)
}
The UI can then degrade:
switch state {
case .loading:
ProgressView("Loading plans")
case .ready(let products):
ProductList(products: products)
case .unavailable(_, let canContinueFree):
if canContinueFree {
ContinueFreeButton()
} else {
RetryStoreKitButton()
}
}
For a small utility, the free path is part of reliability. It lets the app remain useful even when commerce infrastructure is temporarily unavailable.
The AI engine should know the mode
Subscription value should not be enforced only by hiding buttons. The engine should receive an allowed mode:
enum DecisionMode {
case quick
case compareOptions
case reflectWithHistory
}
actor DecisionEngine {
func answer(
question: String,
mode: DecisionMode,
policy: DecisionUsagePolicy
) async throws -> DecisionResult {
guard policy.allows(mode) else {
throw DecisionError.requiresPlus
}
return try await runPrompt(question: question, mode: mode)
}
}
This prevents accidental premium leakage when a deep feature is reachable from multiple screens.
Metrics that matter
For subscription AI utilities, I track:
| Metric | Why it matters |
|---|---|
| Free core completion rate | proves the app is usable before purchase |
| Paywall load failure rate | catches StoreKit or network issues |
| Restore success rate | catches entitlement bugs |
| Limit-hit conversion | shows whether the limit matches value |
| Subscriber feature usage | proves recurring value exists |
The key is not maximizing paywall impressions. The key is showing the paywall after the user understands the repeated job.
Postmortem checklist
A subscription in a small AI utility should be technically observable. The code should make the value boundary obvious:
- Entitlements are centralized.
- Usage policy is explicit.
- Free and Plus behavior are testable.
- StoreKit failure has a graceful path.
- AI modes enforce access rules below the UI layer.
If the only difference between free and paid is a string on a paywall, the product is under-designed. A durable subscription starts as architecture before it becomes pricing.