Skip to content

feat: [JWT-3] feature flags, JWT config, and the Identity Verification gate - #1707

Open
nan-li wants to merge 3 commits into
nan/jwt-pr2-consistencyfrom
nan/jwt-pr3-iv-gate
Open

feat: [JWT-3] feature flags, JWT config, and the Identity Verification gate#1707
nan-li wants to merge 3 commits into
nan/jwt-pr2-consistencyfrom
nan/jwt-pr3-iv-gate

Conversation

@nan-li

@nan-li nan-li commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Description

One Line Summary

Add the Identity Verification foundation: feature flags, three-state JWT requirement config, the IV gate service, and remote-params hydration with retry — nothing consumes the gate yet.

Details

Motivation

Identity Verification needs a known answer to “does this app require a token?” before user-scoped work can be sent. That answer is three-state (unknown / on / off), comes from remote params (jwt_required), and must survive a failed first-launch params download without stranding the session’s queued work forever.

Scope

  • OSFeatureManager, OSUserJwtConfig, OSIdentityVerificationService
  • OneSignal.m params hydration from jwt_required, retry/backoff, cache clears for the new keys
  • Replaces unread require_user_id_auth with jwt_required (the requiresUserAuth property itself is removed in a later PR)
  • Unit tests for the three new types
  • No request-pipeline or public JWT listener API yet

Targets 5.6-main (already includes the project-file normalize and read-your-write consistency fixes).

Testing

Unit testing

  • OSFeatureManagerTests
  • OSUserJwtConfigTests
  • OSIdentityVerificationServiceTests

Manual testing

Built in sequence as part of the local JWT stack against an iOS Simulator.

Affected code checklist

  • Notifications
    • Display
    • Open
    • Push Processing
    • Confirm Deliveries
  • Outcomes
  • Sessions
  • In-App Messaging
  • REST API requests
  • Public API changes

Checklist

Overview

  • I have filled out all REQUIRED sections above
  • PR does one thing
  • Any Public API changes are explained in the PR details and conform to existing APIs

Testing

  • I have included test coverage for these changes, or explained why they are not needed
  • All automated tests pass, or I explained why that is not possible
  • I have personally tested this on my device, or explained why that is not possible

Final pass

  • Code is as readable as possible.
  • I have reviewed this PR myself, ensuring it meets each checklist item

Made with Cursor

@nan-li
nan-li force-pushed the nan/jwt-pr3-iv-gate branch 2 times, most recently from d0b8588 to 1b80146 Compare August 12, 2026 01:30

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-model adversarial review (interrogate)

Intent

Add the Identity Verification foundation without wiring consumers: OSFeatureManager, three-state OSUserJwtConfig (jwt_required), and OSIdentityVerificationService; hydrate from remote params; clear new cache keys on app-id change; retry failed params downloads so first-launch sessions are not stranded with an unknown requirement.

Reviewers

  • A: claude-fable-5-thinking-xhigh — 10 findings
  • B: gpt-5.6-sol-xhigh — 6 findings
  • C: cursor-grok-4.5-high-fast — 5 findings
  • D: claude-opus-5-thinking-high — 14 findings

Act On

  1. Stale appId in params retry (A/B/C/D) — scheduleDownloadIOSParamsRetryWithAppId: captures appId and only guards _downloadedParameters / _didCallDownloadParameters. handleAppIdChange: resets those flags, so a pending retry for app A can run after a switch to B and rehydrate IV + other remote params from the wrong app.
  2. App-id change clears disk but not in-memory singletons (A/B/C/D) — OSUD_USE_IDENTITY_VERIFICATION / OSUD_SDK_FEATURE_FLAGS are removed, but OSUserJwtConfig.shared / OSFeatureManager.shared keep the previous app’s values. refreshIfUnknown() cannot repair a non-unknown in-memory value. Add a production reset() and call it from handleAppIdChange:.
  3. No tests for the OneSignal.m retry/hydrate path (A/B/C/D) — Swift unit tests cover the leaf types; the new retry state machine (four entry points, three statics, dispatch_after) is untested. That is where the highest-severity bugs live.

Consider

  1. Nested retry + indiscriminate 4xx (A/B/C/D) — OneSignalClient already retries 0/5xx up to 5 attempts; the outer loop multiplies that and also retries permanent 4xx / missing-app-id failures.
  2. Empty-body 200 strands unknown (A/B/D) — client can call successBlock(nil); hydration is skipped but _downloadedParameters = true still blocks further retries/session refetch.
  3. Hydration handler double-delivery / stale last value (A/B/C/D) — register-then-read outside the lock races URLSession-queue hydrate. No production consumers yet, but this is the deferred-work signal.
  4. Orphan requiresUserAuth (A/B/C/D) — writer removed; @objc public property remains permanently false. PR text says removal is a later PR — prefer deleting in this wave if the stack allows.
  5. refreshIfUnknown / refreshIfEmpty unwired (D, noted by others) — prewarm helpers have no production caller; recoverIfDeferred already exists in OneSignal.m.

Noted

  • Unused JWT notification/token defines (OS_ON_JWT_CONFIG_*, etc.) before posters exist
  • Feature-flag hydration deferred (scoped; newCodePathsRun == ivBehaviorActive until then)
  • Doc comments claim OneSignalUserManagerImpl references that do not exist yet
  • Hold lock across UserDefaults synchronize; case-canonicalize on isEnabled lookup

Dismissed

  • Collapsing feature manager + jwt config + gate into one type in this PR — stack intentionally lands the foundation before consumers; defer structural judo to the wiring PR unless reset/retry fixes force a reshape.
  • “No consumers yet so races don’t matter” — foundation contract will be trusted by the next PRs; fix the retry/app-id issues now.

Agreement Map

All four models independently flagged (1) stale app-id retry, (2) disk-only app-id clear, and (3) missing OneSignal.m tests. Nested client/outer retry and observer race were near-unanimous. Lone-model pressure was mostly on collapsing types and resilient-storage mirroring — useful context, lower weight for this PR’s stated scope.

Lead verdict: Do not merge until Act On #1#2 are fixed; add at least a focused test for the retry/app-id interaction before wiring consumers.

Open in Web View Automation 

Sent by Cursor Automation: Untitled

Comment thread iOS_SDK/OneSignalSDK/Source/OneSignal.m
Comment thread iOS_SDK/OneSignalSDK/Source/OneSignal.m Outdated
Comment thread iOS_SDK/OneSignalSDK/Source/OneSignal.m Outdated
Comment thread iOS_SDK/OneSignalSDK/Source/OneSignal.m
@nan-li nan-li changed the title feat: feature flags, JWT config, and the Identity Verification gate feat: [JWT-1] feature flags, JWT config, and the Identity Verification gate Aug 12, 2026
@nan-li nan-li changed the title feat: [JWT-1] feature flags, JWT config, and the Identity Verification gate feat: [JWT-3] feature flags, JWT config, and the Identity Verification gate Aug 12, 2026
@nan-li
nan-li force-pushed the nan/jwt-pr3-iv-gate branch 2 times, most recently from 1630188 to e410174 Compare August 21, 2026 03:51
@nan-li
nan-li changed the base branch from 5.6-main to nan/jwt-pr2-consistency August 21, 2026 03:52
@nan-li
nan-li force-pushed the nan/jwt-pr2-consistency branch from e2a7d62 to 5ac1967 Compare August 27, 2026 19:51
nan-li and others added 3 commits August 27, 2026 13:09
…gate

The foundation the rest of Identity Verification is built on. Nothing
consumes the gate yet, so this changes no behavior beyond the params
handling below.

OSUserJwtConfig holds whether the app requires a token as a three-state
answer — unknown until remote params say — because "not yet known" and
"not required" have to be told apart before anything can be sent.
OSFeatureManager carries the flags remote params enable, and
OSIdentityVerificationService is the single place that answers whether
Identity Verification is on.

Remote params now hydrate that requirement from jwt_required. A response
that omits the key means Identity Verification is off for the app, while
an empty response answers nothing and leaves the cached requirement
standing.

Because the operation repo will hold every queued operation until the
requirement is known, a params request that failed was going to cost a
first-launch app every tag, session count and event for the rest of the
session: nothing retried it. It now retries with a backoff over five
attempts and, failing that, tries again on the next session.

Replaces the unread require_user_id_auth key with jwt_required. The
requiresUserAuth property it wrote is removed in the next PR.

Co-authored-by: Cursor <cursoragent@cursor.com>
Outer iOS-params retries now follow OSResponseStatusRetryable so
classified 4xx are not re-armed for the session. Hydration handlers
snapshot requirement under the same lock as registration.

Co-authored-by: Cursor <cursoragent@cursor.com>
Rebasing onto main picked up SDK-5048 (#1723), which shipped a KMP-backed
OSFeatureManager whose catalog already carries sdk_identity_verification.
The standalone OSFeatureManager this branch added duplicated that role
and collided with it, since two types of the same name cannot compile in
one module.

Drop the standalone manager, its tests, and the OSUD_SDK_FEATURE_FLAGS
cache. OSIdentityVerificationService now reads the rollout flag from the
shared manager by its catalog key, and its tests drive the flag through
OSFeatureFlagsStore the way the existing feature-flag tests do. The
app-id-change cleanup for the dropped cache goes too, because
resetAndClearCachedFlags already covers the real flag cache.

OSFeatureManagerProtocol and the OSFeatureFlag enum existed only to
abstract the standalone manager, so they go as well.
@nan-li
nan-li force-pushed the nan/jwt-pr3-iv-gate branch from e410174 to b784b28 Compare August 27, 2026 22:22
@nan-li

nan-li commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

rebased on main, added a commit to reconcile the merged Feature Manager

@fadi-george

fadi-george commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Potential issues:

  • Empty successful params responses can leave JWT requirements unknown while disabling retries.
  • App ID changes clear persisted JWT state but not the in-memory singleton.
  • Retry state is unsynchronized and pending retries may use a stale app ID.
  • Concurrent hydration and registration can duplicate or reorder callbacks.
  • refreshIfUnknown() does not notify waiting observers.

@fadi-george

Copy link
Copy Markdown
Collaborator

Maybe also address flakey tests.

@abdulraqeeb33 abdulraqeeb33 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for splitting the gate out this cleanly. A couple of lifecycle holes around app-id change are worth closing before later PRs start trusting requirement.

Inline notes below, with a suggested fix for each.

[sharedUserDefaults removeValueForKey:OSUD_RECEIVE_RECEIPTS_ENABLED];
[sharedUserDefaults removeValueForKey:OS_PUSH_SUBSCRIPTION_MODEL_STORE_KEY];

[sharedUserDefaults removeValueForKey:OSUD_USE_IDENTITY_VERIFICATION];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Easy to miss because the disk key does get cleared.

handleAppIdChange removes OSUD_USE_IDENTITY_VERIFICATION and resets OSFeatureManager, but OSUserJwtConfig.shared keeps the previous app’s on/off. refreshIfUnknown only does anything while the value is still unknown, so it cannot repair this.

The bad direction is non-IV → IV: the SDK still thinks JWT is off and will create the new app’s user unsigned. Demo’s change-app-id path hits this.

I’d promote resetRequirementToUnknownForTests to a production reset (set _requirement to .unknown, clear the cache key) and call it next to resetAndClearCachedFlags.

A test that hydrates on, changes app id, then asserts requirement == .unknown would lock this in.

@nan-li nan-li Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, we have this app ID change stuff but it's really not supported after we shipped v5. I think something similar about app id changes was called out above and it was marked is a non-issue because the SDK doesn't actually support changing app ID.

We do have a lot of stale code in this method that we either should clean up or actually handle app ID changes (which we have a linear ticket about that discussion a little while back). The latest conversation was that Aug 11 slack thread asking for this feature

if (_downloadedParameters || _didCallDownloadParameters)
return;

[self downloadIOSParamsWithAppId:appId];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same family as the in-memory reset above.

This timer captures appId. handleAppIdChange then flips _downloadedParameters / _didCallDownloadParameters back to false, so 5–40s later the old app’s params can hydrate the new process-wide jwt_required. The success handler at hydrateWithRequiresUserAuth has the same gap: it never checks that appId is still current.

In this dispatch_after and at the top of the params success handler, I’d bail when the captured appId is not OneSignalIdentifiers.currentAppId.

A test like “retry armed for A, switch to B, B’s policy must not become A’s” would cover both.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same response, we never actually support app id change, and there's no real change to downloading remote params here except that the SDK will retry for a retriable error up to 4 times.

_requirement = hydrated
// Written even when the value is unchanged, so a launch whose write was dropped by locked
// storage still ends up with the requirement on disk.
OneSignalUserDefaults.initShared().saveInteger(forKey: OSUD_USE_IDENTITY_VERIFICATION, withValue: hydrated.rawValue)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Smaller, same file family. hydrate keeps the NSLock across saveInteger, and that path calls synchronize. Readers of requirement are on the repo queue, IAM on main, and the executors, so they all wait on a disk flush.

You already kept the log and the handler out of the lock, which is the right instinct. I’d do the same for the write: update _requirement under the lock, then saveInteger after release. The “write even when unchanged” comment still holds.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants