The API existed and was deliberately unwired, carrying warnings at three layers:
re-wrapping the couple key under a new phrase leaves the PARTNER's stored copy
stale, so their Settings → Security reveals a phrase that unwraps nothing and the
"lost your phrase? ask your partner" path — the whole reason both partners hold it
— hands over a dud. This makes it shippable rather than deleting it.
The phrase can't travel via the server in plaintext, but it can travel sealed to
the key both partners already hold. And the WRAP moves last:
phase 1 publish the new phrase (enc:v1: under the couple key) + phraseGeneration
phase 2 each device confirms it can read it (ack)
phase 3 once BOTH acked, re-wrap under it + phraseWrapGeneration
Until phase 3 the old phrase unwraps everything, so an interrupted change is a
no-op instead of an unrecoverable couple. A device stores the new phrase only when
the wrap is actually made from it — the stored phrase and the wrap never disagree,
which is the invariant the landmine is about. An old client that ignores the fields
never acks, so the change simply never completes: it degrades to "nothing
happened", the right failure direction for a crypto rollout.
Two design notes against the plan. (1) The plan's "disable Rotate mid-handshake"
guard is gone: nothing stores the new phrase until the wrap moves, so a rotation
landing mid-handshake wraps under the phrase everyone still has, and phase 3 runs
in a TRANSACTION that re-reads keyGeneration — without it, a phase-3 write racing a
rotation republishes a wrap of the pre-rotation keyset and rolls the rotation back,
stranding the partner. The transaction subsumes the guard; a UI gate would have been
theatre. (2) Either device completes phase 3, so an offline changer can't leave the
couple showing a phrase the wrap doesn't honour.
I ALSO FOUND THE HARNESS I SHOULD HAVE BEEN USING: firestore-tests/ runs the rules
against the emulator. It immediately proved three bugs in my own rules, two of them
critical, all now fixed and pinned by 20 new tests (141 total, mutation-checked):
- phase 3 was DEAD for every couple. `request.resource.data.keyGeneration` errors
when the field never existed — couples are created without it — so the first
phrase change of any couple was denied AFTER the UI had already shown the user
their new phrase. The dud-phrase outcome, relocated. Both sides now default.
- `phraseWrapGeneration` was in the allowlist but guarded by NOTHING: a bare
one-field write passed (an unchanged wrap short-circuits the wrap clause), and
the partner's client trusts that field as proof the wrap moved — so one write
made them overwrite their working phrase with one that unwraps nothing.
Permanently, silently, no crypto needed. Now only a genuine phase 3 may move it.
- phase 3 never checked the acks server-side (client-only), so a client could
complete before the partner had the phrase — exactly what the handshake exists
to prevent. The rules now require both members' acks at the current generation.
A fourth, caught by the tests themselves: the predicate didn't require the wrap
to actually change, so advancing the generation alone still passed.
Also: the harness revealed the C-ROTATE-001 rules I shipped earlier broke a
standing test ("a member can re-wrap the couple key — allowed"). No live flow used
it (updateWrappedKey was dead code, now deleted), and the test encoded the very
behaviour the hardening removes — rewritten to assert the new invariant plus the
lawful rotation path.
Rules deploy is user-gated and NOT yet done; the client tolerates the old rules
(phase 1 is rejected, nothing breaks). Android suite + 108 functions tests + 141
rules tests green. Live 2-device verification still pending on the throwaway couple.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|---|---|---|
| .github | ||
| .vscode | ||
| app | ||
| docs | ||
| firestore-tests | ||
| functions | ||
| gradle | ||
| iphone | ||
| qa | ||
| scripts | ||
| seed | ||
| .eslintrc.js | ||
| .firebaserc | ||
| .gitignore | ||
| .gitleaksignore | ||
| .prettierrc | ||
| Engineering_Reference_Manual_Plan.md | ||
| Future.md | ||
| HISTORY.md | ||
| IOS_E2EE_STATUS.md | ||
| README.md | ||
| UI_UPGRADE.md | ||
| build.gradle.kts | ||
| firebase.json | ||
| firestore.indexes.json | ||
| firestore.rules | ||
| gradle.properties | ||
| gradlew | ||
| gradlew.bat | ||
| local.properties.example | ||
| seed_generator.py | ||
| settings.gradle.kts | ||
| storage.rules | ||
README.md
Closer Couples
A private space for two.
Daily questions, mutual reveals, shared games, and calm rituals for couples.
Screenshots · What makes it different · Status · Run locally · QA
Android reference app · iOS scaffold in progress · Firebase backend · RevenueCat billing · Private repo
Closer Couples is a native relationship app for couples who want a quieter way to check in with each other. Each partner answers privately, reveals intentionally, and keeps a record of the conversations that matter.
It is not a social network, therapy replacement, or productivity tracker. There are no public feeds, likes, followers, or infinite-scroll loops. The product loop is small on purpose:
answer honestly -> reveal together -> keep the conversation going
Screenshots
Fresh Android captures from the current emulator build.
| Ready · dark | Ready · light | Your turn · dark | Reveal · dark | Reveal · light |
![]() |
![]() |
![]() |
![]() |
![]() |
| Play | This or That | Today | Challenge |
![]() |
![]() |
![]() |
![]() |
Visual system
Closer Couples uses paired light and dark illustration assets so the product can stay warm without fighting the selected theme. The home daily-question card is intentionally people-forward: ready-to-answer uses the partner-prompt couple artwork, reveal-ready uses the card reveal artwork, and quieter waiting states can fall back to the answer-card ritual art.
What makes it different
| Private first Partners answer independently before seeing each other's response. |
Mutual reveal The app is built around intentional sharing, not performative posting. |
Couple-owned trust Android encrypts answers, chat, history, and media on-device with couple-owned keys. |
| One subscription per couple Premium unlocks for both partners through server-verified entitlements. |
Curated prompts Question packs are written and reviewed, not generated at answer time. |
Calm by design No feeds, likes, followers, public profiles, or pressure mechanics. |
| Theme-aware art Light and dark illustrations are paired for the same product moments. |
Human warmth The home ritual leads with people and recognizable Closer Couples card artwork. |
Private rituals The visual language reinforces answering alone, revealing together, and continuing gently. |
Product surface
| Area | Free | Premium |
|---|---|---|
| Daily question | Included | Included |
| Private answers + mutual reveal | Included | Included |
| Curated question packs | Free + mixed access | Full access |
| Spin the Wheel | Included | Included |
| This or That / How Well Do You Know Me | Included | Included |
| Recent answer history | Included | Included |
| Memory Lane capsules | Limited | Full access |
| Desire Sync | Limited | Full access |
| Connection Challenges | Free + mixed access | Full access |
| Date ideas, matches, and bucket list | Included | Premium ideas gated |
| Push reminders + quiet hours | Included | Included |
| Account deletion | Included | Included |
Data export is not currently offered. The in-app privacy copy intentionally says so until a real export flow exists.
Platform status
| Platform | Status | Notes |
|---|---|---|
| Android | Reference implementation | Kotlin, Jetpack Compose, Material 3, Hilt, Room, DataStore, Firebase, Tink |
| iOS | Scaffold in progress | SwiftUI screen parity exists; pairing is blocked until E2EE interop is complete |
| Backend | Shared source of truth | Firebase Auth, Firestore, Cloud Functions, FCM, App Check |
| Billing | Server verified | RevenueCat webhook writes Firestore entitlements observed by the app |
Android is the product source of truth today. iOS has the app shell, Firebase/RevenueCat integration, and SwiftUI screens, but end-to-end pairing waits on CryptoKit/Tink interoperability.
Architecture
Android (Kotlin/Compose) iOS (SwiftUI)
Hilt · Room · DataStore MVVM · AppState · SPM
Tink AEAD · Argon2id CryptoKit interop in progress
\ /
\ /
v v
Firebase + RevenueCat
Auth · Firestore · Functions · FCM
App Check · server-verified billing
Core rules:
- Couple-scoped Firestore data. Users only read/write their own couple surface.
- Server-mediated pairing. Invite lifecycle runs through Cloud Functions.
- Local-first question content. Prompts ship with the app; assignment/sync uses Firebase.
- Server-verified premium. Clients observe entitlements; they do not self-grant access.
- No anonymous auth. Accounts use email/password or Google sign-in.
Security and privacy
- Android encrypts answer content, chat messages, media, capsules, and backed-up conversation history on-device before sync.
- The server stores ciphertext for private content and never receives plaintext answers.
- Recovery phrase wrapping uses Argon2id-derived keys.
- Partner-assisted restore lets a trusted partner help restore the couple key to a new device.
- Quiet hours are enforced server-side before push delivery.
- Account deletion exists; data export does not yet.
The canonical technical reference is docs/Engineering_Reference_Manual.md.
Local development
Prerequisites
- Android Studio, Android SDK, and JDK 17
- Node 20 for Firebase Functions tooling
- Firebase project with Auth, Firestore, Cloud Messaging, Crashlytics, Analytics, and App Check
app/google-services.json- RevenueCat project and Android API key
For iOS work:
- macOS, Xcode 16, XcodeGen, and iOS 17+
iphone/Closer/GoogleService-Info.plist- RevenueCat iOS API key
Android
cp local.properties.example local.properties
sdk.dir=/path/to/Android/Sdk
RC_API_KEY_ANDROID=your_revenuecat_android_key
RC_API_KEY_IOS=your_revenuecat_ios_key
./gradlew :app:assembleDebug
./gradlew :app:installDebug
./gradlew :app:testDebugUnitTest
iOS
cd iphone
xcodegen generate
xed Closer.xcodeproj
xcodebuild -project iphone/Closer.xcodeproj \
-scheme Closer \
-destination 'platform=iOS Simulator,name=iPhone 15' \
build
Firebase Functions
cd functions
npm install
npm run build
npm test
npm run serve
QA and release
The repo keeps repeatable QA checks close to the code:
| Check | Purpose |
|---|---|
scripts/theme-scan.sh |
Finds hardcoded theme/color regressions |
scripts/wiring-scan.sh |
Finds orphaned routes, dead actions, and silent wiring breaks |
| docs/qa/ | Manual QA playbooks and private MVP checklist |
| docs/release/ | Internal testing, store assets, and release prep |
Live emulator fixtures and related credentials are intentionally local-only. Keep them in gitignored
files under qa/, never in the tracked README or public docs.
Repository map
app/ Android app
iphone/ iOS app
functions/ Firebase Cloud Functions
seed/ Question content and seed tooling
scripts/ Static QA scanners
qa/ Local-only emulator fixture notes and smoke helpers
docs/ Architecture, release, screenshots, and QA docs
firestore.rules Firestore rules source of truth
Roadmap
In progress:
- iOS E2EE interoperability with Android's Tink key material
- More on-device/instrumented smoke coverage
- Activity
uiModesync for in-app theme and dark artwork - Internal-testing release configuration, legal/support URLs, and RevenueCat offering validation
Out of scope for now:
- AI-generated core questions
- Group relationship spaces beyond dyadic couples
- Wear OS / watchOS companions
- Live video or voice sessions
Project docs
| Doc | Purpose |
|---|---|
| docs/Engineering_Reference_Manual.md | Architecture, security model, data model, and known landmines |
| docs/brand/ | Visual identity, asset system, generated art |
| docs/release/ | Release prep and store assets |
| docs/qa/ | Manual QA checklist |
| Future.md | Backlog and roadmap |
| HISTORY.md | Changelog and release notes |
| PROJECT.md | Scope, feature matrix, architectural decisions |
License
Private project. All rights reserved.








