feat: [JWT-5] bind each Delta to the user that owns it - #1710
Conversation
There was a problem hiding this comment.
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
OSDeltato the owning user'sexternal_id(required init arg;nilfor anonymous) so queued work is not re-attributed to whoever is current at flush. InjectOSOperationRepointo 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
- Properties/subscription deltas still stamp from
_user, not the changed model (A/B/C/D) — Identity was fixed to readargs.model; properties/subscription still copyidentityModelId+ newexternalIdfromOneSignalUserManagerImpl.sharedInstance._userwhile the payload comes fromargs.model. That closes the change → later login → flush window for the happy path, but leaves a concurrent login / stale-model window (OSModelStore.addoverwrites without unsubscribing;clearModelsFromStoreclears 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 naturalmodelIdcheck; mirrorcurrentUser(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. - Half-wired
operationRepoinjection (A/B/C/D) — Listeners take the injected repo, butOneSignalUserManagerImplstill hitsOSOperationRepo.sharedInstanceforstart/addExecutor/paused/ flush and — in the two enqueue sites this PR already touched forexternalId—updatePropertiesDeltasandtrackEvent. Today they alias the same object; the moment the follow-up swapsoperationRepoto an owned instance, listener deltas and manager-enqueued deltas diverge onto different queues. Route every manager repo use throughself.operationReponow (pure rename).
Consider
external_idalias deltas stamp post-mutation (D) —internalAddAliasesmutates then fires;removeAlias("external_id")/ overwrite can produce a delta stamped anonymous or with the new id. Snapshot pre-change owner (or special-caseOS_EXTERNAL_ID) before PR6 trusts the field for auth.- Snapshot
externalIdvs liveidentityModelIdafter identify (A) — Anonymous deltas keepexternalId: nilwhile 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*Deltawith hand-builtOSModelChangedArgs; they never exerciseOSModel.set→ store →operationRepo.enqueueDelta. Drive through real mutations and assertdeltaQueue. - “Upgrade decode” fixture is not legacy-shaped (B/C/D) —
testADeltaWithoutAnExternalIdStillDecodesround-trips a new encoder writingexternalId: 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.
DeltaOwnershipTests0.5s sleeps + unsynchronizeddeltaQueuereads (A) — prefer existingwaitUntil.- 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.
Sent by Cursor Automation: Untitled
03f2ebf to
2f3cd34
Compare
96a17a3 to
f0dc805
Compare
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>
f0dc805 to
2c267bf
Compare
…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>


Description
One Line Summary
Bind each
OSDeltato 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— requiredexternalIdownership field so call sites cannot leave it outOSIdentity/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 listenersStacked on #1709 (
nan/jwt-pr4-identity-api).Testing
Unit testing
OSDeltaTestsDeltaOwnershipTestsManual 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
Checklist
Overview
Testing
Final pass