Skip to content

feat: [JWT-5] bind each Delta to the user that owns it - #1710

Open
nan-li wants to merge 2 commits into
nan/jwt-pr4-identity-apifrom
nan/jwt-pr5-delta-ownership
Open

feat: [JWT-5] bind each Delta to the user that owns it#1710
nan-li wants to merge 2 commits into
nan/jwt-pr4-identity-apifrom
nan/jwt-pr5-delta-ownership

Conversation

@nan-li

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

Copy link
Copy Markdown
Contributor

Description

One Line Summary

Bind each OSDelta to the external ID of the user it was made for, and inject the operation repo into model store listeners instead of reaching for the singleton.

Details

Motivation

A Delta recorded a model change but not who it was for, so anything built from one had to ask who the current user is. Between the change and the flush the app may have logged in as somebody else, and the queued work was then sent for whoever happened to be current.

Scope

  • OSDelta — required externalId ownership field so call sites cannot leave it out
  • Model store listeners (OSIdentity / OSProperties / OSSubscription) — take the operation repo by injection rather than the singleton (also what lets the next PR give the repo an Identity Verification-aware instance)
  • OneSignalUserManagerImpl — wires the injected repo into listeners
  • Unit tests for delta ownership and updated executor / concurrency call sites
  • Nothing reads the new field yet; the request pipeline picks it up in the next PR

Stacked on #1709 (nan/jwt-pr4-identity-api).

Testing

Unit testing

  • New OSDeltaTests
  • New DeltaOwnershipTests
  • Existing flush / custom-events / subscription-race / concurrency tests updated for the required ownership argument

Manual testing

Built in sequence as part of the local JWT stack against an iOS Simulator; this PR’s commit compiled on top of PR4.

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

@nan-li nan-li changed the title feat: [PR5] bind each Delta to the user that owns it feat: [JWT-5] bind each Delta to the user that owns it Aug 12, 2026

@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.

Multimodal adversarial review (interrogate)

Skill: Cursor interrogate (pstack). Reviewers: claude-fable-5-thinking-xhigh, gpt-5.6-sol-xhigh, cursor-grok-4.5-high-fast, claude-opus-5-thinking-high. Lead judgment applied below — not a raw dump.

Intent

PR5 of the IV/JWT stack (on PR4). Bind each OSDelta to the owning user's external_id (required init arg; nil for anonymous) so queued work is not re-attributed to whoever is current at flush. Inject OSOperationRepo into model-store listeners (still aliased to the shared singleton) so a later PR can swap in an IV-aware repo. Field is write-only here; request pipeline consumes it next.

Reviewers

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

Act On

  1. Properties/subscription deltas still stamp from _user, not the changed model (A/B/C/D) — Identity was fixed to read args.model; properties/subscription still copy identityModelId + new externalId from OneSignalUserManagerImpl.sharedInstance._user while the payload comes from args.model. That closes the change → later login → flush window for the happy path, but leaves a concurrent login / stale-model window (OSModelStore.add overwrites without unsubscribing; clearModelsFromStore clears the dict without unsubscribing). Only aliases have a “changed model, not current user” test. At minimum: refuse to stamp when the changed model is not the current user’s (properties already have a natural modelId check; mirror currentUser(matching:)), and add the properties/subscription analogues of the alias switch test. Structural owner-on-model can wait if you explicitly scope it for a later PR.
  2. Half-wired operationRepo injection (A/B/C/D) — Listeners take the injected repo, but OneSignalUserManagerImpl still hits OSOperationRepo.sharedInstance for start / addExecutor / paused / flush and — in the two enqueue sites this PR already touched for externalIdupdatePropertiesDeltas and trackEvent. Today they alias the same object; the moment the follow-up swaps operationRepo to an owned instance, listener deltas and manager-enqueued deltas diverge onto different queues. Route every manager repo use through self.operationRepo now (pure rename).

Consider

  • external_id alias deltas stamp post-mutation (D)internalAddAliases mutates then fires; removeAlias("external_id") / overwrite can produce a delta stamped anonymous or with the new id. Snapshot pre-change owner (or special-case OS_EXTERNAL_ID) before PR6 trusts the field for auth.
  • Snapshot externalId vs live identityModelId after identify (A) — Anonymous deltas keep externalId: nil while identify attaches an external id to the same identity model. Document which wins for JWT attachment in the consumer PR.
  • Tests bypass the injection path (D) — Listener tests call get*Delta with hand-built OSModelChangedArgs; they never exercise OSModel.set → store → operationRepo.enqueueDelta. Drive through real mutations and assert deltaQueue.
  • “Upgrade decode” fixture is not legacy-shaped (B/C/D)testADeltaWithoutAnExternalIdStillDecodes round-trips a new encoder writing externalId: nil, not a pre-PR archive missing the key. Behavior likely matches, but the stated upgrade risk deserves a real fixture.

Noted

  • Owner-resolution boilerplate repeated across subscription/properties methods (A) — natural home for the mismatch guard.
  • DeltaOwnershipTests 0.5s sleeps + unsynchronized deltaQueue reads (A) — prefer existing waitUntil.
  • Public protocol init(store:operationRepo:) requirement is unused generically (D) — property alone would suffice.

Dismissed

  • Requiring full owner fields on every properties/subscription model as a hard blocker for this PR — stronger than the stated flush-attribution goal if (1)’s mismatch guard + switch tests land; keep as follow-up structure work.
  • Rewriting identify to re-stamp queued anonymous deltas here — consumer-PR policy call, not a correctness bug in the write path alone.

Agreement Map

All four models independently flagged (1) current-user stamping for non-identity deltas and (2) incomplete repo injection. B/C/D agreed the upgrade decode test is a fixture gap; A alone pushed identify snapshot vs live-model precedence; D alone pushed post-mutation external_id alias stamping and end-to-end listener coverage. Highest-confidence fixes before merge: ownership mismatch guard + switch tests for properties/subscription, and finish the operationRepo rename so PR6 cannot split-brain the queue.

Open in Web View Automation 

Sent by Cursor Automation: Untitled

Comment thread iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeltaTests.swift
@nan-li
nan-li force-pushed the nan/jwt-pr4-identity-api branch from 03f2ebf to 2f3cd34 Compare August 12, 2026 16:57
@nan-li
nan-li force-pushed the nan/jwt-pr5-delta-ownership branch from 96a17a3 to f0dc805 Compare August 12, 2026 16:57
A Delta records a change to a model but not who it was for, so anything
built from one had to ask who the current user is. Between the change and
the flush the app may have logged in as somebody else, and the queued work
was then sent for whoever happened to be current.

OSDelta now carries the external ID of the user it was made for, as a
required argument so no call site can leave it out. The model store
listeners take the operation repo by injection rather than reaching for
the singleton, which is also what lets the next PR give the repo an
Identity Verification-aware instance.

Nothing reads the new field yet; the request pipeline picks it up next.

Co-authored-by: Cursor <cursoragent@cursor.com>
@nan-li
nan-li force-pushed the nan/jwt-pr5-delta-ownership branch from f0dc805 to 2c267bf Compare August 12, 2026 17:35
…tamping

Refuse to stamp the current user when the changed model is no longer theirs
so a concurrent login cannot attach the wrong external_id for PR6 auth.
Properties require the current properties model; email/SMS add requires the
model still be in the store. Remove and push updates keep stamping the
current user.

Co-authored-by: Cursor <cursoragent@cursor.com>
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.

1 participant