rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // ── Helpers ──────────────────────────────────────────────────────────────

    function isSignedIn() {
      return request.auth != null;
    }

    function isOwner(uid) {
      return isSignedIn() && request.auth.uid == uid;
    }

    function isCouplesMember(coupleId) {
      return isSignedIn()
        && request.auth.uid in
           get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds;
    }

    function isValidInviteCode(code) {
      // Code must be exactly 6 alphanumeric characters
      return code.matches('^[a-zA-Z0-9]{6}$');
    }

    function isNotAlreadyPaired() {
      // Check that the requesting user does not already have a coupleId.
      // A missing user doc is treated as unpaired.
      let userPath = /databases/$(database)/documents/users/$(request.auth.uid);
      return !exists(userPath) || get(userPath).data.coupleId == null;
    }

    // Admin SDK / Cloud Functions bypass Firestore rules, so any operation that
    // must only be performed server-side is denied for all direct client writes.

    function isImmutable(fields) {
      return fields is list
        && !request.resource.data.diff(resource.data).affectedKeys().hasAny(fields);
    }

    function isValidSwipeAction(action) {
      return action == 'love' || action == 'maybe' || action == 'skip';
    }

    function isValidDatePlanStatus(status) {
      return status == 'draft' || status == 'planned' || status == 'completed';
    }

    function isValidBucketListCategory(category) {
      return category == 'adventure' || category == 'travel' || category == 'food'
          || category == 'learning' || category == 'romance' || category == 'intimacy'
          || category == 'seasonal';
    }

    function isCiphertext(value) {
      return value is string && value.matches('^enc:v1:[A-Za-z0-9+/]+={0,2}$');
    }

    // A field is acceptable if it's absent, null, or enc:v1 ciphertext — never plaintext.
    function cipherOrAbsent(data, key) {
      return !(key in data) || data[key] == null || isCiphertext(data[key]);
    }

    // Date plan user content must be ciphertext; dateIdeaId/scheduledDate/status/timestamps
    // stay plaintext because Firestore queries and ordering depend on them.
    function isDatePlanContentEncrypted(data) {
      return cipherOrAbsent(data, 'scheduledTime')
        && cipherOrAbsent(data, 'budget')
        && cipherOrAbsent(data, 'duration')
        && cipherOrAbsent(data, 'activity')
        && cipherOrAbsent(data, 'food')
        && cipherOrAbsent(data, 'conversationPrompts')
        && cipherOrAbsent(data, 'optionalChallenge');
    }

    function coupleEncryptionEnabled(coupleId) {
      return get(/databases/$(database)/documents/couples/$(coupleId)).data.encryptionVersion >= 1;
    }

    // Sealed-answer helpers (schemaVersion 3, partner-proof reveal).

    function isSealedPayload(value) {
      // sealed:v1: + URL-safe base64 no-padding body; 80 chars minimum rules out trivially short values
      return value is string && value.matches('^sealed:v1:[A-Za-z0-9_-]{80,}$');
    }

    function isKeybox(value) {
      // keybox:v1: + URL-safe base64 no-padding; ECIES-P256 wrapping a 32-byte key is ~174 chars
      return value is string && value.matches('^keybox:v1:[A-Za-z0-9_-]{120,}$');
    }

    function isCommitmentHash(value) {
      // sha256: + URL-safe base64 no-padding of a 32-byte digest = exactly 43 chars
      return value is string && value.matches('^sha256:[A-Za-z0-9_-]{43}$');
    }

    // Returns true when the incoming data satisfies the sealed-answer create shape.
    // Plaintext content fields (writtenText, selectedOptionIds, scaleValue) are
    // rejected by the hasOnly check below — they must never appear in a sealed doc.
    function isSealedAnswerCreate(data) {
      return data.keys().hasOnly([
           'userId', 'questionId', 'answerType', 'encryptedPayload',
           'commitmentHash', 'schemaVersion', 'answerKeyReleased',
           'answerDate', 'createdAt', 'updatedAt', 'isRevealed'
         ])
        && isSealedPayload(data.encryptedPayload)
        && isCommitmentHash(data.commitmentHash)
        && data.schemaVersion == 3
        && data.answerKeyReleased == false
        && data.isRevealed == false;
    }

    // Only the reveal metadata fields may change after a sealed answer is created.
    function isSealedAnswerUpdate() {
      return resource.data.schemaVersion == 3
        && request.resource.data.diff(resource.data).affectedKeys()
             .hasOnly(['isRevealed', 'answerKeyReleased', 'updatedAt']);
    }

    // Thread sealed answers differ from daily answers: no answerDate (threads use threadId
    // as context), no isRevealed field (reveal state is tracked by the thread VM).
    function isSealedThreadAnswerCreate(data) {
      return data.keys().hasOnly([
           'userId', 'questionId', 'answerType', 'encryptedPayload',
           'commitmentHash', 'schemaVersion', 'answerKeyReleased',
           'createdAt', 'updatedAt'
         ])
        && isSealedPayload(data.encryptedPayload)
        && isCommitmentHash(data.commitmentHash)
        && data.schemaVersion == 3
        && data.answerKeyReleased == false;
    }

    function isSealedThreadAnswerUpdate() {
      return resource.data.schemaVersion == 3
        && request.resource.data.diff(resource.data).affectedKeys()
             .hasOnly(['answerKeyReleased', 'updatedAt']);
    }

    function isUpdatingRecoveryWrap() {
      return request.resource.data.encryptionVersion >= 1
        && request.resource.data.wrappedCoupleKey is string
        && request.resource.data.kdfSalt is string
        && request.resource.data.kdfParams is string
        && request.resource.data.diff(resource.data).affectedKeys().hasOnly([
             'wrappedCoupleKey', 'kdfSalt', 'kdfParams'
           ]);
    }

    function isUpdatingCoupleRhythm() {
      return request.resource.data.diff(resource.data).affectedKeys().hasOnly([
        'streakCount', 'lastAnsweredAt'
      ]);
    }

    // ── Users ─────────────────────────────────────────────────────────────────
    // Each user owns exactly their own document.
    // hasPremium is server-only: clients may not write it directly.

    match /users/{uid} {
      // Owner reads their own doc; a paired partner may read the other's doc (name + photo)
      // — they share a coupleId. Without this, partner name/photo never load (shows
      // "Your partner" / blank avatar everywhere: pairing screen, Home, games).
      allow read: if isOwner(uid)
        || (
             request.auth != null
             && resource.data.coupleId != null
             && exists(/databases/$(database)/documents/users/$(request.auth.uid))
             && get(/databases/$(database)/documents/users/$(request.auth.uid)).data.coupleId == resource.data.coupleId
           );
      allow create: if isOwner(uid)
        && !request.resource.data.keys().hasAny(['hasPremium']);
      allow update: if isOwner(uid)
        && !request.resource.data.diff(resource.data).affectedKeys().hasAny(['hasPremium']);

      // Entitlements written server-side only (RevenueCat webhook via Admin SDK).
      // Client needs read access so FirestoreEntitlementChecker can observe premium state.
      match /entitlements/{entitlementDoc} {
        // Owner reads their own; a paired partner may also read it so premium can be shared
        // across the couple (chat media unlocks if EITHER partner is premium).
        allow read: if isOwner(uid)
          || (
               request.auth != null
               && exists(/databases/$(database)/documents/users/$(uid))
               && get(/databases/$(database)/documents/users/$(uid)).data.coupleId != null
               && exists(/databases/$(database)/documents/users/$(request.auth.uid))
               && get(/databases/$(database)/documents/users/$(request.auth.uid)).data.coupleId
                  == get(/databases/$(database)/documents/users/$(uid)).data.coupleId
             );
        allow write: if false;
      }

      // Notification queue written server-side only (Cloud Functions).
      // No client read needed; the app reacts to FCM push, not this collection.
      match /notification_queue/{notificationId} {
        // Written server-side (Admin SDK bypasses rules). The owner may read their own
        // activity feed and flip a notification's `read` flag — nothing else.
        allow read: if isOwner(uid);
        allow update: if isOwner(uid)
          && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['read']);
        allow create, delete: if false;
      }

      // Per-user outcome mirrors for cross-relationship progress stats.
      // Writes are server-side only (submitOutcomeCallable); direct client writes denied.
      match /outcomes/{dayKey} {
        allow read: if isOwner(uid)
          && dayKey in ['day_0', 'day_30', 'day_60', 'day_90'];
        allow create, update, delete: if false;
      }

      // FCM registration tokens: owner can read/write their own tokens.
      match /fcmTokens/{tokenId} {
        allow read, write: if isOwner(uid);
      }

      // Per-user ECIES public keys for sealed-answer key release.
      // The owner writes their own public key; only the user's current partner may read
      // it (to wrap release keys). Restricting to couple members prevents a malicious
      // user from reading arbitrary public keys and pre-encrypting speculative release keys.
      match /devices/{deviceId} {
        allow read: if isOwner(uid)
          || (isSignedIn()
              && get(/databases/$(database)/documents/users/$(uid)).data.coupleId != null
              && get(/databases/$(database)/documents/users/$(uid)).data.coupleId
                 == get(/databases/$(database)/documents/users/$(request.auth.uid)).data.coupleId);
        allow create, update: if isOwner(uid)
          && request.resource.data.publicKey is string
          && request.resource.data.publicKey.matches('^pub:v1:.*')
          && request.resource.data.keys().hasOnly(['deviceId', 'publicKey', 'platform', 'updatedAt']);
        allow delete: if false;
      }
    }

    // ── Date ideas (read-only catalog) ─────────────────────────────────────────
    // Curated date ideas are readable by any authenticated user.
    // Writes are server-only (admin SDK / Cloud Functions seeding).

    match /date_ideas/{dateIdeaId} {
      allow read: if isSignedIn();
      allow create, update, delete: if false;
    }

    // ── Invite codes ──────────────────────────────────────────────────────────
    // Invite system is server-side only for writes. Clients may only read their
    // own pending invites. The invite document ID is a 6-character code; it is
    // enumerable, so direct client create/update/delete is denied.

    match /invites/{code} {
      // Read: only the inviter may read their own invite (e.g. to check status).
      // Non-inviters are denied to prevent invite-code enumeration.
      // Expired invites remain readable by the inviter for diagnostics.
      allow read: if isSignedIn()
        && request.auth.uid == resource.data.inviterUserId;

      // Create / Update / Delete: server-side / Cloud Functions only.
      // The Admin SDK bypasses these rules. Direct client writes are denied
      // because 6-character codes are enumerable and invite creation involves
      // rate limiting, uniqueness checks, and key material the client cannot
      // be trusted to produce safely.
      allow create, update, delete: if false;
    }

    // ── Couples ───────────────────────────────────────────────────────────────
    // Only the two members of a couple may read couple data.
    // Writes are restricted by field ownership and immutability.

    match /couples/{coupleId} {
      // Read: both members can read
      allow read: if isCouplesMember(coupleId);

      // Create: server-side only via the acceptInviteCallable Cloud Function.
      // The Admin SDK bypasses these rules. The shape check remains as defense
      // in depth in case any other trusted server process creates a couple doc.
      allow create: if isSignedIn()
        && request.auth.uid in request.resource.data.userIds
        && request.resource.data.keys().hasAll([
             'id', 'userIds', 'inviteCode', 'createdAt', 'streakCount',
             'wrappedCoupleKey', 'kdfSalt', 'kdfParams', 'encryptionVersion'
           ])
        && request.resource.data.encryptionVersion == 2
        && request.resource.data.wrappedCoupleKey is string
        && request.resource.data.kdfSalt is string
        && request.resource.data.kdfParams is string
        && request.resource.data.keys().hasOnly([
             'id', 'userIds', 'inviteCode', 'createdAt', 'streakCount',
             'wrappedCoupleKey', 'kdfSalt', 'kdfParams', 'encryptionVersion']);

      // Update: field-level restrictions
      // - user IDs, invite code, and createdAt are immutable
      // - encryptionVersion is monotonically non-decreasing (cannot downgrade)
      // - only the explicitly listed mutable fields may change; everything else
      //   (including currentQuestionId, activePackId, id) is server-only
      allow update: if isCouplesMember(coupleId)
        && isImmutable(['id', 'userIds', 'inviteCode', 'createdAt'])
        && (
          isUpdatingCoupleRhythm()
          || isUpdatingRecoveryWrap()
        );

      // Delete: server-only (admin SDK only). Admin SDK bypasses rules.
      allow delete: if false;

      match /sessions/{sessionId} {
        // Read: both members can read
        allow read: if isCouplesMember(coupleId);

        // Create: either member can start a session
        allow create: if isCouplesMember(coupleId)
          && request.resource.data.startedByUserId == request.auth.uid;

        // Update: only the user who started the session can update it, OR valid status transitions.
        // startedByUserId is immutable for direct client writes.
        allow update: if isCouplesMember(coupleId)
          // Either the original starter can update
          && (resource.data.startedByUserId == request.auth.uid
          // Or status transition is valid: active → completed
          || (resource.data.status == 'active' && request.resource.data.status == 'completed'))
          // startedByUserId cannot be changed by clients
          && request.resource.data.startedByUserId == resource.data.startedByUserId
          // Only a fixed set of fields may change
          && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status', 'completedAt']);

        // Delete: server-only (admin SDK). Admin SDK bypasses rules.
        allow delete: if false;
      }

      // Question threads live under the couple document.
      match /question_threads/{threadId} {
        // Read: both members can read
        allow read: if isCouplesMember(coupleId);

        // Create: either member can create a thread
        allow create: if isCouplesMember(coupleId)
          && request.resource.data.createdByUserId == request.auth.uid;

        // Update: valid state transitions only, currentIndex only incrementable
        allow update: if isCouplesMember(coupleId)
          // Status transitions must be valid: NOT_STARTED → ANSWERED_BY_ONE → REVEALED → COMPLETED
          && (resource.data.status == 'NOT_STARTED' && request.resource.data.status == 'ANSWERED_BY_ONE'
           || resource.data.status == 'ANSWERED_BY_ONE' && request.resource.data.status == 'REVEALED'
           || resource.data.status == 'REVEALED' && request.resource.data.status == 'COMPLETED'
           || resource.data.status == 'ANSWERED_BY_ONE' && request.resource.data.status == 'COMPLETED'
           || resource.data.status == request.resource.data.status
              && request.resource.data.currentIndex > resource.data.currentIndex)
          // currentIndex can only be incremented, never decremented or reset
          && request.resource.data.currentIndex != null
          && (resource.data.currentIndex == null || request.resource.data.currentIndex >= resource.data.currentIndex)
          // No other fields should change
          && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status', 'currentIndex']);

        // Delete: server-only (admin SDK). Admin SDK bypasses rules.
        allow delete: if false;

        // Answers: each user writes their own; both members can read all answers.
        // Strict couples must use schemaVersion 3 (sealed:v1: partner-proof).
        // schemaVersion 2 is accepted only for v1 migration couples.
        match /answers/{userId} {
          allow read:   if isCouplesMember(coupleId);
          allow delete: if false;
          allow create: if isCouplesMember(coupleId)
            && isOwner(userId)
            && request.resource.data.userId == request.auth.uid
            && coupleEncryptionEnabled(coupleId)
            && isSealedThreadAnswerCreate(request.resource.data);
          allow update: if isCouplesMember(coupleId)
            && isOwner(userId)
            && isSealedThreadAnswerUpdate();

          // One-time key release for sealed thread answers (same guards as daily answer release keys).
          match /releaseKeys/{recipientId} {
            allow read: if isCouplesMember(coupleId) && request.auth.uid == recipientId;
            allow create: if isCouplesMember(coupleId)
              && request.auth.uid == userId
              && recipientId != userId
              && recipientId in get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds
              // Both answers must exist before either key can be released — prevents early single-sided release.
              && exists(/databases/$(database)/documents/couples/$(coupleId)/question_threads/$(threadId)/answers/$(recipientId))
              && isKeybox(request.resource.data.encryptedAnswerKey)
              && request.resource.data.recipientUserId == recipientId
              && request.resource.data.keys().hasOnly(['recipientUserId', 'encryptedAnswerKey', 'releasedAt']);
            allow update, delete: if false;
          }
        }

        // Discussion messages: any couple member can read, but only the author can write/update/delete
        match /messages/{messageId} {
          allow read:   if isCouplesMember(coupleId);
          // Text messages carry ciphertext in `text`; image messages carry only a `mediaUrl`
          // pointing at the encrypted bytes in Storage (the photo itself is E2E-encrypted).
          allow create: if isCouplesMember(coupleId)
            && coupleEncryptionEnabled(coupleId)
            && request.resource.data.authorUserId == request.auth.uid
            && request.resource.data.keys().hasOnly(['authorUserId', 'text', 'createdAt', 'type', 'mediaUrl'])
            && (
              (request.resource.data.get('type', 'text') == 'image'
                && request.resource.data.mediaUrl is string
                && request.resource.data.mediaUrl.size() > 0)
              ||
              (request.resource.data.get('type', 'text') == 'text'
                && isCiphertext(request.resource.data.text))
            );
          allow update: if isCouplesMember(coupleId)
            && coupleEncryptionEnabled(coupleId)
            && resource.data.authorUserId == request.auth.uid
            && request.resource.data.keys().hasOnly(['text'])
            && isCiphertext(request.resource.data.text);
          allow delete: if isCouplesMember(coupleId)
            && resource.data.authorUserId == request.auth.uid;
        }

        // Reactions: any couple member can read, but only the creator can write/update/delete
        match /reactions/{reactionId} {
          allow read:   if isCouplesMember(coupleId);
          allow create: if isCouplesMember(coupleId)
            && request.resource.data.userId == request.auth.uid
            && request.resource.data.keys().hasOnly(['userId', 'emoji', 'createdAt']);
          allow update: if isCouplesMember(coupleId)
            && resource.data.userId == request.auth.uid
            && request.resource.data.keys().hasOnly(['userId', 'emoji', 'createdAt']);
          allow delete: if isCouplesMember(coupleId)
            && resource.data.userId == request.auth.uid;
        }
      }

      // Conversations: the Messages inbox. Each conversation (the couple chat or a per-question
      // discussion) holds E2E-encrypted messages; only metadata + the encrypted last-message
      // preview live on the conversation doc.
      match /conversations/{conversationId} {
        allow read: if isCouplesMember(coupleId);
        // Members may create/merge-update the conversation doc within the allowed shape; any
        // last-message preview must be ciphertext (encrypted on-device before write).
        allow write: if isCouplesMember(coupleId)
          && request.resource.data.keys().hasOnly(
               ['type', 'questionId', 'createdAt', 'lastMessageAt', 'lastMessagePreview', 'lastMessageSenderId', 'reads', 'typing'])
          && (!('lastMessagePreview' in request.resource.data)
               || isCiphertext(request.resource.data.lastMessagePreview));

        // Messages: author-only create; text is ciphertext OR it's an image message pointing at
        // encrypted Storage bytes; immutable after creation.
        match /messages/{messageId} {
          allow read: if isCouplesMember(coupleId);
          allow create: if isCouplesMember(coupleId)
            && coupleEncryptionEnabled(coupleId)
            && request.resource.data.authorUserId == request.auth.uid
            && request.resource.data.keys().hasOnly(['authorUserId', 'text', 'createdAt', 'type', 'mediaUrl', 'durationMs', 'reactions', 'deleted'])
            && (
              (request.resource.data.get('type', 'text') in ['image', 'voice']
                && request.resource.data.mediaUrl is string
                && request.resource.data.mediaUrl.size() > 0)
              ||
              (request.resource.data.get('type', 'text') == 'text'
                && isCiphertext(request.resource.data.text))
            );
          // Reactions: any couple member may change ONLY the reactions map.
          // Unsend: only the author may set the `deleted` tombstone.
          allow update: if isCouplesMember(coupleId) && (
              request.resource.data.diff(resource.data).affectedKeys().hasOnly(['reactions'])
              ||
              (resource.data.authorUserId == request.auth.uid
                && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['deleted']))
            );
          allow delete: if false;
        }
      }

      // Date swipes: per-couple, per-date partner swipe state. The action is E2E
      // ciphertext so the server can't read date preferences; only swipedAt is plaintext.
      match /date_swipes/{dateIdeaId} {
        // Read: both couple members can read the shared swipe document (ciphertext).
        allow read: if isCouplesMember(coupleId);

        // Create (doc doesn't exist yet): only the caller's own entry may be present,
        // and the action must be ciphertext.
        allow create: if isCouplesMember(coupleId)
          && request.resource.data.keys().hasOnly(['actions'])
          && request.resource.data.actions.keys().hasOnly([request.auth.uid])
          && request.resource.data.actions[request.auth.uid].keys().hasOnly(['action', 'swipedAt'])
          && isCiphertext(request.resource.data.actions[request.auth.uid].action)
          && request.resource.data.actions[request.auth.uid].swipedAt is number;

        // Update (partner may already have an entry): a merge write exposes the whole
        // post-write doc, so diff to ensure ONLY the caller's own entry changed.
        allow update: if isCouplesMember(coupleId)
          && request.resource.data.keys().hasOnly(['actions'])
          && request.resource.data.actions[request.auth.uid].keys().hasOnly(['action', 'swipedAt'])
          && isCiphertext(request.resource.data.actions[request.auth.uid].action)
          && request.resource.data.actions[request.auth.uid].swipedAt is number
          && resource.data.actions.diff(request.resource.data.actions).affectedKeys().hasOnly([request.auth.uid]);

        // Delete: server-only (admin SDK). Admin SDK bypasses rules.
        allow delete: if false;
      }

      // Date matches: revealed mutual-love matches (matchId == dateIdeaId).
      // Server is blind to encrypted swipes, so the client writes the marker when it
      // detects mutual love; a Cloud Function fires the notification on create. The
      // creator must be one of the two matched members. fcmNotified flips server-side.
      match /date_matches/{matchId} {
        allow read: if isCouplesMember(coupleId);
        allow create: if isCouplesMember(coupleId)
          && request.resource.data.keys().hasOnly(['dateIdeaId', 'revealedAt', 'matchedBy', 'fcmNotified'])
          && request.resource.data.dateIdeaId is string
          && request.resource.data.matchedBy is list
          && request.resource.data.matchedBy.size() == 2
          && request.auth.uid in request.resource.data.matchedBy
          && request.resource.data.fcmNotified == false;
        allow update, delete: if false;
      }

      // Date plan preferences: per-partner preferences for building date plans.
      // Both members can read; either member can write a preference document.
      // Document IDs are Firestore auto-IDs (not user IDs).
      match /date_plan_preferences/{prefId} {
        allow read: if isCouplesMember(coupleId);
        allow create, update: if isCouplesMember(coupleId)
          && request.resource.data.keys().hasAll(['dateIdeaId', 'createdAt', 'updatedAt'])
          && request.resource.data.keys().hasOnly([
               'dateIdeaId', 'preferredDate', 'preferredTime',
               'budget', 'duration', 'createdAt', 'updatedAt'
             ])
          // Strict E2EE: user-entered details are ciphertext (dateIdeaId/dates stay plaintext for queries).
          && cipherOrAbsent(request.resource.data, 'preferredTime')
          && cipherOrAbsent(request.resource.data, 'budget')
          && cipherOrAbsent(request.resource.data, 'duration');
        allow delete: if false;
      }

      // Date plans: complete plans assembled from partner preferences.
      // Both members can read and delete; writes are field-validated.
      // createdAt is immutable after creation (excluded from the update allowed-keys set).
      match /date_plans/{planId} {
        allow read: if isCouplesMember(coupleId);
        allow create: if isCouplesMember(coupleId)
          && request.resource.data.keys().hasAll(['dateIdeaId', 'scheduledDate', 'status', 'createdAt', 'updatedAt'])
          && request.resource.data.keys().hasOnly([
               'dateIdeaId', 'scheduledDate', 'scheduledTime', 'budget', 'duration',
               'status', 'activity', 'food', 'conversationPrompts', 'optionalChallenge',
               'createdAt', 'updatedAt'
             ])
          && isValidDatePlanStatus(request.resource.data.status)
          && isDatePlanContentEncrypted(request.resource.data);
        allow update: if isCouplesMember(coupleId)
          // Only the explicitly-listed fields may change on update.
          // createdAt is intentionally absent — it cannot be modified after creation.
          && request.resource.data.diff(resource.data).affectedKeys().hasOnly([
               'dateIdeaId', 'scheduledDate', 'scheduledTime', 'budget', 'duration',
               'status', 'activity', 'food', 'conversationPrompts', 'optionalChallenge',
               'updatedAt'
             ])
          && isValidDatePlanStatus(request.resource.data.status)
          && isDatePlanContentEncrypted(request.resource.data);
        allow delete: if isCouplesMember(coupleId);
      }

      // Bucket list items: shared list for both partners.
      // addedBy must match the caller on creation; addedBy and addedAt are immutable.
      // Marking an item complete requires the caller to own the completedBy field.
      match /bucket_list/{itemId} {
        allow read: if isCouplesMember(coupleId);
        allow create: if isCouplesMember(coupleId)
          && request.resource.data.keys().hasAll(['title', 'addedBy', 'addedAt', 'isCompleted'])
          && request.resource.data.keys().hasOnly([
               'title', 'description', 'category', 'addedBy', 'addedAt',
               'completedBy', 'completedAt', 'isCompleted'
             ])
          && request.resource.data.addedBy == request.auth.uid
          && isValidBucketListCategory(request.resource.data.category)
          // Strict E2EE: user content must be ciphertext.
          && isCiphertext(request.resource.data.title)
          && (!('description' in request.resource.data)
              || request.resource.data.description == null
              || isCiphertext(request.resource.data.description));
        allow update: if isCouplesMember(coupleId)
          && request.resource.data.diff(resource.data).affectedKeys().hasOnly([
               'title', 'description', 'category', 'isCompleted', 'completedBy', 'completedAt'
             ])
          && isImmutable(['addedBy', 'addedAt'])
          // completedBy must be the caller when marking an item complete
          && (!request.resource.data.isCompleted || request.resource.data.completedBy == request.auth.uid)
          // Strict E2EE: title/description remain ciphertext (merged result is always encrypted).
          && isCiphertext(request.resource.data.title)
          && (!('description' in request.resource.data)
              || request.resource.data.description == null
              || isCiphertext(request.resource.data.description));
        allow delete: if isCouplesMember(coupleId);
      }

      // Couple Lore stores revealed answer summaries. Summary text must remain
      // encrypted with the couple key; prompts/metadata can stay plaintext.
      match /lore/{loreId} {
        allow read: if isCouplesMember(coupleId);
        allow create, update: if isCouplesMember(coupleId)
          && coupleEncryptionEnabled(coupleId)
          && request.resource.data.keys().hasOnly([
               'questionId', 'questionText', 'ownAnswer', 'partnerAnswer',
               'modeTag', 'date', 'schemaVersion', 'savedAt'
             ])
          && request.resource.data.questionId is string
          && request.resource.data.questionText is string
          && request.resource.data.date is string
          && request.resource.data.schemaVersion == 2
          && isCiphertext(request.resource.data.ownAnswer)
          && (!('partnerAnswer' in request.resource.data)
              || request.resource.data.partnerAnswer == null
              || isCiphertext(request.resource.data.partnerAnswer));
        allow delete: if false;
      }

      // Outcomes: couple-level 30/60/90 day check-ins. Both members can read.
      // Writes are server-side only via submitOutcomeCallable; direct client writes denied.
      match /outcomes/{dayKey} {
        allow read: if isCouplesMember(coupleId)
          && dayKey in ['day_0', 'day_30', 'day_60', 'day_90'];
        allow create, update, delete: if false;
      }

      // Daily question: server-assigned once per day per couple.
      // Writes are server-only (Cloud Functions / Admin SDK).
      match /daily_question/{date} {
        allow read: if isCouplesMember(coupleId);
        allow write: if false;
      }

      // Daily question answers: each user writes their own; both members read.
      match /daily_question/{date}/answers/{userId} {
        allow read: if isCouplesMember(coupleId);

        allow create: if isCouplesMember(coupleId)
          && request.auth.uid == userId
          && request.resource.data.userId == request.auth.uid
          && request.resource.data.questionId is string
          && request.resource.data.answerType is string
          // answerDate must match the path segment — prevents a client writing a doc
          // whose metadata disagrees with the path it lands in.
          && request.resource.data.answerDate is string
          && request.resource.data.answerDate == date
          // schemaVersion 3: partner-proof sealed answer (the only accepted shape).
          && isSealedAnswerCreate(request.resource.data);

        allow update: if isCouplesMember(coupleId)
          && request.auth.uid == userId
          && request.resource.data.userId == resource.data.userId
          && request.resource.data.questionId == resource.data.questionId
          && request.resource.data.answerType == resource.data.answerType
          // Sealed answers: only reveal metadata may change; payload is immutable.
          && isSealedAnswerUpdate();

        allow delete: if false;

        // Release keys: the sender releases their one-time answer key to the recipient
        // after both partners have submitted.
        match /releaseKeys/{recipientId} {
          // The recipient reads the key released to them. The sender (answer owner = {userId})
          // must also be able to read their own released doc, because writeReleaseKey does an
          // idempotency existence-check get() before writing — without this, that get() was
          // PERMISSION_DENIED, releaseOwnKey threw, and the daily reveal failed. The keybox is
          // ECIES-encrypted to the recipient, so the sender reading it leaks nothing.
          allow read: if isCouplesMember(coupleId)
            && (request.auth.uid == recipientId || request.auth.uid == userId);

          // Create-only: written by the answer owner (sender) after both answers exist.
          allow create: if isCouplesMember(coupleId)
            && request.auth.uid == userId
            && recipientId != userId
            && recipientId in get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds
            && exists(/databases/$(database)/documents/couples/$(coupleId)/daily_question/$(date)/answers/$(recipientId))
            && isKeybox(request.resource.data.encryptedAnswerKey)
            && request.resource.data.recipientUserId == recipientId
            && request.resource.data.keys().hasOnly(['recipientUserId', 'encryptedAnswerKey', 'releasedAt']);

          allow update: if false;
          allow delete: if false;
        }
      }

      // Games use enc:v1: (schemaVersion 2 / shared couple key).
      // They are company-proof but not partner-proof: a modified client could read the
      // partner's encrypted slot before the reveal screen. Sealed per-answer keys are not
      // used here because games are real-time simultaneous — both players submit and see
      // results together; there is no single async "reveal" event to gate on.
      match /{gameCollection}/{sessionId} {
        allow read: if isCouplesMember(coupleId)
          && gameCollection in ['this_or_that', 'desire_sync', 'how_well', 'wheel'];
        allow create: if isCouplesMember(coupleId)
          && coupleEncryptionEnabled(coupleId)
          && gameCollection in ['this_or_that', 'desire_sync', 'how_well', 'wheel']
          && request.resource.data.answers is map
          && request.resource.data.answers.keys().hasOnly([request.auth.uid])
          && isCiphertext(request.resource.data.answers[request.auth.uid])
          && request.resource.data.keys().hasOnly(['answers', 'categoryName', 'questions']);
        allow update: if isCouplesMember(coupleId)
          && coupleEncryptionEnabled(coupleId)
          && gameCollection in ['this_or_that', 'desire_sync', 'how_well', 'wheel']
          && request.resource.data.answers is map
          && request.resource.data.answers.diff(resource.data.answers).affectedKeys()
               .hasOnly([request.auth.uid])
          && isCiphertext(request.resource.data.answers[request.auth.uid])
          && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['answers']);
        allow delete: if false;
      }
    }

    // ── entitlement_events ────────────────────────────────────────────────────
    // Cloud Functions write idempotency markers here via the Admin SDK.
    // No client access needed — explicit deny prevents accidental future grants.
    match /entitlement_events/{eventId} {
      allow read, write: if false;
    }
  }
}
