After reading the code a little bit more, I understand what is happening.
The object graph is loaded early on in the request - the associations and collections are lazy load enabled via the CGLIB proxies. They have a connection related to the original, top-level query, which is not a "managed" session/connection. Later on, the object collections are used to assist in creating update and insert statements within an @Transactional annotated method, which automatically puts the session into managed mode, and thus is attached to the request (thread) via thread local storage. There is now a problem as the insert/updates and any new select statements run within the scope of the managed session (and therefore transaction) while the lazy loaded properties use the original, unmanaged connection.
The solution was to manage the entire request up front by starting a global transaction. In our case, this was to create a Struts2 Interceptor that does nothing, but does have the @Transactional annotation. This actually helps with performance, since the session is now cached for all database requests in the thread, rather than constantly going out to the openSession() method and utimately to the datasource.
The key, really, is that the entire thread should fall within a managed session. That will ensure that all database access within the thread uses the same connection and participates in the same transaction.
I think that the Guice plugin, with all of its magic, gave us the false sense that the connections where scoped to the servlet request, when, in fact, it is only the SqlSessionManager which is in Singleton scope. It's still up to the developer to manage the actual session/connection himself.
If you can think of a better way to do this or some pitfalls that we might encounter, please feel free to comment!
Thanks,
Mike