fix(auth): make a profile write unable to unpair a couple (C-AUTH-001 hardening)

The previous commit fixed the path that reached the hazard. This removes the
hazard: createUser was a whole-document set() of every field, so any caller
holding a partially-loaded User wrote coupleId = null over a paired user and
dropped coupleId/partnerId. The User that does this is not exotic — for ~200ms
after every sign-in, users/{uid} exists with no fields on it at all. Getting a
read slightly wrong should cost a no-op, not a relationship's history.

createUser now merges, and UserProfileWrite omits every null or blank value, so a
field is only ever written with a real value and a hollow User produces an empty
map. For a document that doesn't exist yet — the only case callers actually intend
— the result is byte-identical to before. Clearing a field stays the job of the
targeted update* methods, where the call site says so. It also carries the same
locked-placeholder require()s the targeted writers already had.

plan is deliberately no longer written: User.plan defaults to "free", so merging it
would silently downgrade a paying user whose document we read while it was hollow.
The server owns that field (RevenueCat), reads already default a missing plan to
"free", and no gate reads it — entitlements live in the server-only subdoc. Checked
against firestore.rules: create requires no fields, and update's hasOnly allowlist
still covers everything written.

Correcting my last commit message while I'm here: it claimed CreateProfile submit
calls createUser and overwrites the name. Only half true — it uses targeted merges
when it sees an existing doc, and only calls createUser when it reads null. The
path that actually reached the sharp edge was Google sign-in: mergeGoogleProfile
decided "new account" from runCatching { getUser(uid) }.getOrNull(), which is null
for a failed read as much as for a missing document — C-AUTH-001's conflation, in a
place that then wrote a whole document. It also existed as two verbatim copies, in
LoginViewModel and SignUpViewModel, so the same bug was there twice. Now one
GoogleProfileMerger: it does nothing on a failed read, and won't seed a name over a
stub (which would overwrite a returning user's real name with their Google one).
Both view models drop userRepository entirely as a result.

User.isSignInStub is now shared rather than private to OnboardingViewModel, since
two callers need the same question answered.

UserProfileWriteTest pins the rule; mutation-checked by reverting the omit rule,
which fails 3 of them. Full unit suite green. Sign-in verified live on the throwaway.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-15 00:58:28 -05:00
parent 6330bf98ca
commit 9448537b6b
8 changed files with 276 additions and 74 deletions

View File

@ -95,21 +95,35 @@ class FirestoreUserDataSource @Inject constructor(
awaitClose { listener.remove() } awaitClose { listener.remove() }
} }
/**
* Write a user's profile without ever erasing what is already there.
*
* This was a whole-document `set()` of every field, which made it destructive by construction:
* any caller holding a partially-loaded [User] or the field-less document that exists for
* ~200ms after sign-in (C-AUTH-001) would write `coupleId = null` over a paired user, dropping
* `coupleId`/`partnerId` and unpairing the couple, plus overwriting their name. That is far too
* sharp an edge for a function every auth path calls: getting a read slightly wrong should cost
* a no-op, not a relationship's history.
*
* So it merges, and [UserProfileWrite] omits anything null or blank a field is only ever
* written with a real value. For a document that doesn't exist yet (the only case callers
* actually intend) the result is identical to before. Clearing a field stays the job of the
* targeted `update*` methods, which say so at the call site.
*/
suspend fun createUser(user: User) { suspend fun createUser(user: User) {
userRef(user.id).set( // Same guard the targeted writers carry: never persist the "locked" UI string as a value.
mapOf( require(user.displayName != FieldEncryptor.LOCKED_PLACEHOLDER) {
"email" to user.email, "Refusing to persist the locked placeholder as displayName"
"displayName" to encryptProfileField(user.displayName, user.coupleId), }
"photoUrl" to user.photoUrl, require(user.sex != FieldEncryptor.LOCKED_PLACEHOLDER) {
"sex" to encryptProfileField(user.sex, user.coupleId), "Refusing to persist the locked placeholder as sex"
"partnerId" to user.partnerId, }
"coupleId" to user.coupleId, val fields = UserProfileWrite.fields(
"plan" to user.plan, user,
"birthDate" to user.birthDate, displayName = encryptProfileField(user.displayName, user.coupleId),
"createdAt" to user.createdAt, sex = encryptProfileField(user.sex, user.coupleId)
"lastActiveAt" to user.lastActiveAt )
) userRef(user.id).set(fields, SetOptions.merge()).await()
).await()
} }
/** Age-gate DOB (O-AGE-001). Set once when a Google/legacy user has no birthDate yet. */ /** Age-gate DOB (O-AGE-001). Set once when a Google/legacy user has no birthDate yet. */

View File

@ -0,0 +1,49 @@
package app.closer.data.remote
import app.closer.domain.model.User
/**
* The field map [FirestoreUserDataSource.createUser] writes.
*
* Split out from the data source so the one rule that matters can be pinned by a JVM test, with no
* Firestore in the way: **a null or blank value is never written.** The map is merged into a document
* that may already hold a real, paired profile, so anything hollow in the [User] has to be left alone
* rather than written as an erasure.
*
* This exists because the write used to be a whole-document `set()`, which put every caller one bad
* read away from unpairing a couple: a partially-loaded `User` or the field-less document that
* exists for ~200ms after sign-in (C-AUTH-001) carries `coupleId = null`, and writing that over a
* paired user drops `coupleId`/`partnerId` and overwrites their name. Clearing a field is the job of
* the targeted `update*` methods, where the call site says so out loud.
*/
internal object UserProfileWrite {
/**
* [displayName] and [sex] are passed in already encrypted the data source owns the couple key.
*
* `plan` is deliberately absent: it is a billing fact the server owns (RevenueCat writes it), and
* [User.plan] defaults to `"free"`, so merging it would silently downgrade a paying user whose
* document we read while it was still hollow. Reads already treat a missing `plan` as `"free"`,
* so not writing it is the same thing for a new user and harmless for everyone else.
*/
fun fields(user: User, displayName: String, sex: String): Map<String, Any> = buildMap {
putIfReal("email", user.email)
putIfReal("displayName", displayName)
putIfReal("photoUrl", user.photoUrl)
putIfReal("sex", sex)
putIfReal("partnerId", user.partnerId)
putIfReal("coupleId", user.coupleId)
putIfReal("birthDate", user.birthDate)
// Epoch millis: 0 is "unset" (the hollow document), never a real timestamp.
putIfReal("createdAt", user.createdAt.takeIf { it > 0L })
putIfReal("lastActiveAt", user.lastActiveAt.takeIf { it > 0L })
}
private fun MutableMap<String, Any>.putIfReal(key: String, value: Any?) {
when (value) {
null -> Unit
is String -> if (value.isNotBlank()) put(key, value)
else -> put(key, value)
}
}
}

View File

@ -16,3 +16,15 @@ data class User(
val createdAt: Long = System.currentTimeMillis(), val createdAt: Long = System.currentTimeMillis(),
val lastActiveAt: Long = System.currentTimeMillis() val lastActiveAt: Long = System.currentTimeMillis()
) )
/**
* True when nothing has been written to this person's document yet as opposed to "this person has
* no profile", which looks identical field-by-field and means the opposite (C-AUTH-001).
*
* Signing in registers an FCM token, and that merge-write materialises `users/{uid}` before the
* profile has synced: for roughly the first 200ms every read returns a document that exists and has
* no identity on it. Every path that creates a real user writes [email] and [createdAt] first, so
* their absence means "too early to tell", never "new user". Read it again rather than acting on it.
*/
val User.isSignInStub: Boolean
get() = email.isBlank() && createdAt == 0L && coupleId == null

View File

@ -0,0 +1,73 @@
package app.closer.domain.usecase
import android.util.Log
import app.closer.domain.model.GoogleSignInResult
import app.closer.domain.model.User
import app.closer.domain.model.isSignInStub
import app.closer.domain.repository.UserRepository
import javax.inject.Inject
import javax.inject.Singleton
/**
* Seeds a user's profile from what Google already knows (name, photo) at sign-in.
*
* Best-effort by design: it fills gaps for a new account and otherwise stays out of the way. It ran
* as identical copies inside both `LoginViewModel` and `SignUpViewModel`, which is how the same bug
* came to exist twice.
*
* The bug: it decided "this is a new account" from `runCatching { getUser(uid) }.getOrNull()`, which
* returns null both when the user genuinely has no document *and* when the read simply failed. On the
* second reading it created the profile from scratch, overwriting a real one. That is the same
* conflation as C-AUTH-001, and the same rule applies a read you don't trust decides nothing.
*/
@Singleton
class GoogleProfileMerger @Inject constructor(
private val userRepository: UserRepository
) {
suspend fun merge(result: GoogleSignInResult) {
val uid = result.uid
if (uid.isBlank()) return
val read = runCatching { userRepository.getUser(uid) }
if (read.isFailure) {
// Can't tell a new account from an unreadable one. Writing either way risks overwriting a
// real profile, and the only thing at stake here is a pre-filled name — so do nothing.
Log.w(TAG, "Couldn't read the profile at Google sign-in; leaving it untouched", read.exceptionOrNull())
return
}
val existing = read.getOrNull()
if (existing == null) {
userRepository.createUser(
User(
id = uid,
email = result.email,
displayName = result.displayName,
photoUrl = result.photoUrl,
createdAt = System.currentTimeMillis(),
lastActiveAt = System.currentTimeMillis()
)
)
return
}
// A document with nothing written to it yet looks exactly like a brand-new account: every
// field blank. Filling it in from Google would overwrite the real name of a returning user
// whose profile simply hadn't synced yet. Their name is already set — leave it alone.
if (existing.isSignInStub) {
Log.w(TAG, "Profile not synced yet at Google sign-in; not seeding name/photo over it")
return
}
if (existing.displayName.isBlank() && result.displayName.isNotBlank()) {
userRepository.updateDisplayName(uid, result.displayName)
}
if (existing.photoUrl.isBlank() && result.photoUrl.isNotBlank()) {
userRepository.updatePhotoUrl(uid, result.photoUrl)
}
}
private companion object {
const val TAG = "GoogleProfileMerger"
}
}

View File

@ -2,10 +2,8 @@ package app.closer.ui.auth
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.domain.model.GoogleSignInResult
import app.closer.domain.model.User
import app.closer.domain.repository.AuthRepository import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.UserRepository import app.closer.domain.usecase.GoogleProfileMerger
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -26,7 +24,7 @@ data class LoginUiState(
@HiltViewModel @HiltViewModel
class LoginViewModel @Inject constructor( class LoginViewModel @Inject constructor(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val userRepository: UserRepository private val googleProfileMerger: GoogleProfileMerger
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState()) private val _uiState = MutableStateFlow(LoginUiState())
@ -51,7 +49,7 @@ class LoginViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
authRepository.signInWithGoogle(idToken) authRepository.signInWithGoogle(idToken)
.onSuccess { result -> .onSuccess { result ->
mergeGoogleProfile(result) googleProfileMerger.merge(result)
_uiState.update { it.copy(isLoading = false, success = true) } _uiState.update { it.copy(isLoading = false, success = true) }
} }
.onFailure { e -> _uiState.update { it.copy(isLoading = false, error = friendlyError(e)) } } .onFailure { e -> _uiState.update { it.copy(isLoading = false, error = friendlyError(e)) } }
@ -69,30 +67,6 @@ class LoginViewModel @Inject constructor(
} }
} }
private suspend fun mergeGoogleProfile(result: GoogleSignInResult) {
val uid = result.uid
if (uid.isBlank()) return
val existing = runCatching { userRepository.getUser(uid) }.getOrNull()
if (existing == null) {
userRepository.createUser(
User(
id = uid,
email = result.email,
displayName = result.displayName,
photoUrl = result.photoUrl,
createdAt = System.currentTimeMillis(),
lastActiveAt = System.currentTimeMillis()
)
)
} else {
if (existing.displayName.isBlank() && result.displayName.isNotBlank()) {
userRepository.updateDisplayName(uid, result.displayName)
}
if (existing.photoUrl.isBlank() && result.photoUrl.isNotBlank()) {
userRepository.updatePhotoUrl(uid, result.photoUrl)
}
}
}
private fun friendlyError(e: Throwable): String = when { private fun friendlyError(e: Throwable): String = when {
e.message?.contains("no user record") == true -> "No account found with that email." e.message?.contains("no user record") == true -> "No account found with that email."

View File

@ -5,10 +5,8 @@ import androidx.lifecycle.viewModelScope
import app.closer.domain.AgeGate import app.closer.domain.AgeGate
import app.closer.domain.SignupHandoff import app.closer.domain.SignupHandoff
import app.closer.ui.brand.CloserCopy import app.closer.ui.brand.CloserCopy
import app.closer.domain.model.GoogleSignInResult
import app.closer.domain.model.User
import app.closer.domain.repository.AuthRepository import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.UserRepository import app.closer.domain.usecase.GoogleProfileMerger
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -35,7 +33,7 @@ data class SignUpUiState(
@HiltViewModel @HiltViewModel
class SignUpViewModel @Inject constructor( class SignUpViewModel @Inject constructor(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val userRepository: UserRepository, private val googleProfileMerger: GoogleProfileMerger,
private val signupHandoff: SignupHandoff private val signupHandoff: SignupHandoff
) : ViewModel() { ) : ViewModel() {
@ -89,7 +87,7 @@ class SignUpViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
authRepository.signInWithGoogle(idToken) authRepository.signInWithGoogle(idToken)
.onSuccess { result -> .onSuccess { result ->
mergeGoogleProfile(result) googleProfileMerger.merge(result)
_uiState.update { it.copy(isLoading = false, googleSuccess = true) } _uiState.update { it.copy(isLoading = false, googleSuccess = true) }
} }
.onFailure { e -> _uiState.update { it.copy(isLoading = false, error = friendlyError(e)) } } .onFailure { e -> _uiState.update { it.copy(isLoading = false, error = friendlyError(e)) } }
@ -98,30 +96,6 @@ class SignUpViewModel @Inject constructor(
fun reportError(message: String) = _uiState.update { it.copy(error = message) } fun reportError(message: String) = _uiState.update { it.copy(error = message) }
private suspend fun mergeGoogleProfile(result: GoogleSignInResult) {
val uid = result.uid
if (uid.isBlank()) return
val existing = runCatching { userRepository.getUser(uid) }.getOrNull()
if (existing == null) {
userRepository.createUser(
User(
id = uid,
email = result.email,
displayName = result.displayName,
photoUrl = result.photoUrl,
createdAt = System.currentTimeMillis(),
lastActiveAt = System.currentTimeMillis()
)
)
} else {
if (existing.displayName.isBlank() && result.displayName.isNotBlank()) {
userRepository.updateDisplayName(uid, result.displayName)
}
if (existing.photoUrl.isBlank() && result.photoUrl.isNotBlank()) {
userRepository.updatePhotoUrl(uid, result.photoUrl)
}
}
}
private fun friendlyError(e: Throwable): String = when { private fun friendlyError(e: Throwable): String = when {
e.message?.contains("email address is already") == true -> "An account with this email already exists." e.message?.contains("email address is already") == true -> "An account with this email already exists."

View File

@ -0,0 +1,105 @@
package app.closer.data.remote
import app.closer.domain.model.User
import app.closer.domain.model.isSignInStub
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* These are about one thing: a profile write must never be able to erase a couple.
*
* `createUser` used to `set()` the whole document, so a caller holding a partially-loaded [User]
* would write `coupleId = null` over a paired user and unpair them (C-AUTH-001). The read that
* produces such a User is not exotic for ~200ms after every sign-in, `users/{uid}` exists with no
* fields on it at all so the write itself has to be incapable of the damage.
*/
class UserProfileWriteTest {
/** Exactly what a read returns in the first ~200ms after sign-in. */
private val signInStub = User(id = "u1", createdAt = 0L, lastActiveAt = 0L)
private val pairedUser = User(
id = "u1",
email = "ava@example.com",
displayName = "enc:v1:name",
photoUrl = "https://example.com/a.jpg",
sex = "enc:v1:sex",
partnerId = "ben",
coupleId = "couple1",
birthDate = 946_684_800_000L,
createdAt = 1_700_000_000_000L,
lastActiveAt = 1_700_000_001_000L
)
private fun fieldsOf(user: User) =
UserProfileWrite.fields(user, displayName = user.displayName, sex = user.sex)
@Test
fun aHollowUserCannotUnpairACouple() {
val fields = fieldsOf(signInStub)
// The whole bug in one assertion: merging this must not touch the pairing.
assertFalse("coupleId must not be written from a hollow user", fields.containsKey("coupleId"))
assertFalse("partnerId must not be written from a hollow user", fields.containsKey("partnerId"))
}
@Test
fun aHollowUserWritesNothingAtAll() {
// Not just the pairing: there is nothing real on this document, so nothing should be said
// about it. A merge of an empty map is a no-op, which is the correct outcome.
assertEquals(emptyMap<String, Any>(), fieldsOf(signInStub))
}
@Test
fun aRealUserStillWritesEveryField() {
// The non-destructive rule must not cost a genuine create anything.
val fields = fieldsOf(pairedUser)
assertEquals("ava@example.com", fields["email"])
assertEquals("enc:v1:name", fields["displayName"])
assertEquals("https://example.com/a.jpg", fields["photoUrl"])
assertEquals("enc:v1:sex", fields["sex"])
assertEquals("ben", fields["partnerId"])
assertEquals("couple1", fields["coupleId"])
assertEquals(946_684_800_000L, fields["birthDate"])
assertEquals(1_700_000_000_000L, fields["createdAt"])
assertEquals(1_700_000_001_000L, fields["lastActiveAt"])
}
@Test
fun blanksAreOmittedRatherThanWrittenAsErasures() {
val partial = pairedUser.copy(photoUrl = "", sex = "", birthDate = null)
val fields = fieldsOf(partial)
assertFalse("a blank photoUrl must not clear an existing one", fields.containsKey("photoUrl"))
assertFalse("a blank sex must not clear an existing one", fields.containsKey("sex"))
assertFalse("a null birthDate must not clear an existing one", fields.containsKey("birthDate"))
// ...while everything real about the same user is still written.
assertEquals("couple1", fields["coupleId"])
}
@Test
fun planIsNeverWritten() {
// User.plan defaults to "free", so writing it would downgrade a paying user whose document
// we happened to read while it was hollow. The server owns this field.
assertFalse(fieldsOf(pairedUser).containsKey("plan"))
assertFalse(fieldsOf(pairedUser.copy(plan = "premium")).containsKey("plan"))
}
@Test
fun unsetTimestampsAreNotWrittenAsZero() {
// 0 is "unset", and writing it would date a real account to 1970.
val fields = fieldsOf(pairedUser.copy(createdAt = 0L, lastActiveAt = 0L))
assertFalse(fields.containsKey("createdAt"))
assertFalse(fields.containsKey("lastActiveAt"))
}
@Test
fun theStubIsRecognisedAndARealUserIsNot() {
assertTrue("the post-sign-in document must read as a stub", signInStub.isSignInStub)
assertFalse("a paired user must never read as a stub", pairedUser.isSignInStub)
// A genuinely new account is NOT a stub: every create writes email + createdAt first, which
// is what keeps new users flowing to profile setup instead of being retried forever.
val brandNew = User(id = "u2", email = "new@example.com", createdAt = 1_700_000_000_000L)
assertFalse("a new account with identity on it is not a stub", brandNew.isSignInStub)
}
}

View File

@ -1481,8 +1481,9 @@ OOB code = `truncate6(SHA-256(pubkey ‖ nonce))`.
### C-AUTH-001 - a `users/{uid}` read right after sign-in returns a field-less document ### C-AUTH-001 - a `users/{uid}` read right after sign-in returns a field-less document
**Symptom**: signing in on a *new device* with a fully set-up, paired account routed to `CREATE_PROFILE` ("What should your partner call you?", over a `🔒 Couldn't unlock on this device` value) instead of `RECOVERY`. Reproduced end to end on a throwaway emulator with a real paired account. **Symptom**: signing in on a *new device* with a fully set-up, paired account routed to `CREATE_PROFILE` ("What should your partner call you?", over a `🔒 Couldn't unlock on this device` value) instead of `RECOVERY`. Reproduced end to end on a throwaway emulator with a real paired account.
**Cause**: for roughly the first 200ms after sign-in, `getUser(uid)` returns a document that **exists with no fields at all** - no `email`, no `displayName`, no `coupleId` - and the real document only lands afterwards. Sign-in registers the FCM token, and that `SetOptions.merge()` write materialises `users/{uid}` before the profile has synced. Measured on-device: two reads at t+0ms field-less, the real document at t+215ms. `Source.SERVER` does **not** dodge it, so "read from the server" is not the fix. **Cause**: for roughly the first 200ms after sign-in, `getUser(uid)` returns a document that **exists with no fields at all** - no `email`, no `displayName`, no `coupleId` - and the real document only lands afterwards. Sign-in registers the FCM token, and that `SetOptions.merge()` write materialises `users/{uid}` before the profile has synced. Measured on-device: two reads at t+0ms field-less, the real document at t+215ms. `Source.SERVER` does **not** dodge it, so "read from the server" is not the fix.
**Why it was destructive**: `FirestoreUserDataSource.createUser` is a whole-document `set()`, not a merge, and `CreateProfileViewModel` submit calls it. Tapping through that mistakenly-shown screen overwrites the real `displayName` with the locked placeholder and drops `coupleId`/`partnerId`, unpairing the couple - and the user never reaches Recovery. **Why it was destructive**: the user never reaches Recovery, and the screen they get instead invites them to overwrite the profile it couldn't read. `CreateProfileViewModel` submit is only *partly* protected - it uses targeted merges when it sees an existing doc (and `updateDisplayName`/`updateSex` `require` the value isn't the locked placeholder), but it calls `createUser` when it reads `null`, and `createUser` was a whole-document `set()` of every field. Any caller holding a partially-loaded `User` therefore wrote `coupleId = null` over a paired user, dropping `coupleId`/`partnerId`. The Google path reached exactly that: `mergeGoogleProfile` decided "new account" from `runCatching { getUser(uid) }.getOrNull()`, which is `null` for a *failed read* as much as for a missing doc.
**Fix (R30)**: `OnboardingViewModel.resolveDestination` treats a field-less document as "ask again", not as "new user": it retries (3x, 400ms backoff) until a document with real identity on it arrives, and only that decides. A read that never succeeds routes Home (non-destructive; Home routes to Recovery by itself when the couple key is missing). `UserRepository.getUserFromServer` (`Source.SERVER`) backs the decision so an offline read throws instead of guessing from cache. **Fix (R30)**: `OnboardingViewModel.resolveDestination` treats a field-less document as "ask again", not as "new user": it retries (3x, 400ms backoff) until a document with real identity on it arrives, and only that decides. A read that never succeeds routes Home (non-destructive; Home routes to Recovery by itself when the couple key is missing). `UserRepository.getUserFromServer` (`Source.SERVER`) backs the decision so an offline read throws instead of guessing from cache. `User.isSignInStub` is the shared predicate.
**Hardening (R30)**: the primitive can no longer do the damage. `createUser` merges, and `UserProfileWrite` omits every null/blank value, so a hollow `User` produces an empty map - a no-op - instead of an erasure; it also carries the placeholder `require`s, and deliberately never writes `plan` (client default `"free"` would downgrade a paying user; the server owns it, reads default it). `mergeGoogleProfile` was duplicated verbatim in `LoginViewModel` and `SignUpViewModel` - the same bug twice - and is now one `GoogleProfileMerger` that does nothing on a failed read and won't seed over a stub. Pinned by `UserProfileWriteTest` (mutation-checked: reverting the omit rule fails 3 of them).
**Re-introduction risk**: **any** one-shot `users/{uid}` read on a startup path that branches on a field being blank. `exists() == true` does not mean "populated". Blank fields this early mean "not synced yet", and `createUser`'s `set()` semantics make guessing wrong expensive. If you need "does this person have a profile", require identity (`email`/`createdAt`) to be present before believing the answer. **Re-introduction risk**: **any** one-shot `users/{uid}` read on a startup path that branches on a field being blank. `exists() == true` does not mean "populated". Blank fields this early mean "not synced yet", and `createUser`'s `set()` semantics make guessing wrong expensive. If you need "does this person have a profile", require identity (`email`/`createdAt`) to be present before believing the answer.
--- ---