diff --git a/docs/Engineering_Reference_Manual.md b/docs/Engineering_Reference_Manual.md index 9eafb07a..de5fd320 100644 --- a/docs/Engineering_Reference_Manual.md +++ b/docs/Engineering_Reference_Manual.md @@ -752,6 +752,8 @@ The rules file is organized into helper functions first, then per-collection mat isSignedIn() request.auth != null isOwner(uid) request.auth.uid == uid isCouplesMember(coupleId) request.auth.uid in couples/{coupleId}.userIds +otherCoupleMember(coupleId, uid) the OTHER member of the couple (partner) +partnerReflectedDate(coupleId, dateId, uid) has the partner already reflected on a given date reflection? isValidInviteCode(code) matches('^[a-zA-Z0-9]{6}$') isNotAlreadyPaired() caller has no existing coupleId isImmutable(fields) diff(...).affectedKeys().hasOnly(fields) @@ -764,6 +766,7 @@ isDatePlanContentEncrypted(data) date-plan content fields are enc:v1: or ab coupleEncryptionEnabled(coupleId) couple.encryptionVersion >= 1 isSealedPayload(value) matches('^sealed:v1:[A-Za-z0-9_-]{80,}$') isKeybox(value) matches('^keybox:v1:[A-Za-z0-9_-]{120,}$') +isPublicKey(value) matches('^pub:v1:[A-Za-z0-9_-]{40,}$') # gates users/{uid}/devices/primary write (40+ chars minimum) isCommitmentHash(value) matches('^sha256:[A-Za-z0-9_-]{43}$') isSealedAnswerCreate(data) sealed-answer shape + sealed:v1 + sha256: (schemaVersion 3, legacy) isSealedAnswerUpdate() only reveal-metadata fields (schemaVersion 3) @@ -789,7 +792,7 @@ isUpdatingCoupleRhythm() only streakCount/lastAnsweredAt **`couples/{coupleId}/daily_question/{date}/answers/{userId}`** - the answer is private to its author until reveal. **The metadata doc is content-free** (schemaVersion 2): it holds only `userId`, `questionId`, `answerType`, `schemaVersion`, `answerDate`, `createdAt`, `updatedAt`, `isRevealed` so the partner can see THAT you answered ("your turn" indicator) without leaking the content. The actual `encryptedPayload` lives in a **read-gated subdoc** at `answers/{userId}/secure/payload` and is only readable by the partner once the partner has also submitted (`exists(.../answers/{request.auth.uid})`). The owner always reads their own subdoc. The subdoc shape is `keys().hasOnly(['encryptedPayload'])` and `isCiphertext(encryptedPayload)`. See [Reveal flow](#reveal-flow) for the full path. Legacy schemaVersion 3 answers (sealed:v1:) follow the `isSealedAnswerCreate`/`isSealedAnswerUpdate` helpers and use the `releaseKeys/` subdoc for the ECIES-wrapped keybox. -**`couples/{coupleId}/daily_question/{date}/answers/{userId}/releaseKeys/{recipientId}`** - create-only by the answer owner, readable only by the named recipient. `keybox:v1:` shape is enforced. +**`couples/{coupleId}/daily_question/{date}/answers/{userId}/releaseKeys/{recipientId}`** - create-only by the answer owner (the rule also requires both partners' answers to exist before the release can be written), readable by the **named recipient OR the answer owner** (the sender needs read for an idempotency existence-check in `writeReleaseKey`; the keybox is ECIES-encrypted to the recipient, so the sender reading it leaks nothing). `keybox:v1:` shape is enforced. **`couples/{coupleId}/{this_or_that|desire_sync|how_well|wheel}/{sessionId}`** - `enc:v1:` ciphertext per user. Games are company-proof (server can't read), but not partner-proof (a modified client could read the partner's encrypted slot before the reveal). Sealed per-answer keys are not used here because games are real-time simultaneous - both players submit and see results together.