Feature: Private circles let a user share an interpretation with named friends instead of the whole network
  A user can create a circle, invite specific peers into it by their
  network identity, and share an interpretation that only current
  circle members can decrypt and read (p2panda-spaces group encryption
  under the hood, see docs/prd/circles.md). Circle membership requires
  each peer to have already discovered the other's encryption key
  bundle — this happens automatically once both peers have been online
  together, via a well-known directory topic every device subscribes
  to on connect, so a short settle window after connecting is needed
  before inviting. Inviting someone only updates the inviter's own
  side; the invitee separately has to join the circle's topic. A real
  invite notification exists now (InterpOp::CircleInviteNotify, see the
  scenario below) telling the invitee's own client the circle id — the
  app still has to act on it by calling open_circle, which these earlier
  scenarios do directly to isolate the sharing guarantee from the
  notification one. A peer who
  was never invited receives nothing readable, even if they join the
  same topic — the ciphertext reaches every subscriber, but only an
  invited member can derive the group secret to decrypt it.

  Scenario: An invited circle member reads a privately shared interpretation
    Given a peer named "Alice" connected to the network
    And a peer named "Bob" connected to the network
    And 3 seconds pass
    And "Alice" creates a circle
    And "Alice" invites "Bob" to the circle
    And "Bob" joins the circle
    And 1 second passes
    When "Alice" shares an interpretation on "natal:circle_shared_reading" to the circle
    Then "Bob" observes the authored interpretation on "natal:circle_shared_reading" within 15 seconds

  Scenario: A peer who was never invited does not receive a circle share
    Given a peer named "Alice" connected to the network
    And a peer named "Bob" connected to the network
    And a peer named "Carol" connected to the network
    And 3 seconds pass
    And "Alice" creates a circle
    And "Alice" invites "Bob" to the circle
    And "Bob" joins the circle
    And "Carol" joins the circle
    And 1 second passes
    When "Alice" shares an interpretation on "natal:circle_excluded_reading" to the circle
    Then "Bob" observes the authored interpretation on "natal:circle_excluded_reading" within 15 seconds
    And "Carol" observes no authored interpretation on "natal:circle_excluded_reading" within 5 seconds

  Scenario: A member invited with write access can also share into the circle
    Given a peer named "Alice" connected to the network
    And a peer named "Bob" connected to the network
    And a peer named "Carol" connected to the network
    And 3 seconds pass
    And "Alice" creates a circle
    And "Alice" invites "Bob" to the circle with write access
    And "Alice" invites "Carol" to the circle
    And "Bob" joins the circle
    And "Carol" joins the circle
    And 1 second passes
    When "Bob" shares an interpretation on "natal:circle_member_write_share" to the circle
    Then "Carol" observes the authored interpretation on "natal:circle_member_write_share" within 15 seconds

  Scenario: circle_members reflects invites and revocations accurately
    Given a peer named "Alice" connected to the network
    And a peer named "Bob" connected to the network
    And 3 seconds pass
    And "Alice" creates a circle
    And "Alice" invites "Bob" to the circle with write access
    Then "Alice" sees "Bob" as a write member of the circle
    When "Alice" revokes "Bob" from the circle
    Then "Alice" no longer sees "Bob" as a member of the circle

  Scenario: A revoked circle member no longer receives new shares
    Given a peer named "Alice" connected to the network
    And a peer named "Bob" connected to the network
    And 3 seconds pass
    And "Alice" creates a circle
    And "Alice" invites "Bob" to the circle
    And "Bob" joins the circle
    And 1 second passes
    When "Alice" shares an interpretation on "natal:circle_revoke_before" to the circle
    Then "Bob" observes the authored interpretation on "natal:circle_revoke_before" within 15 seconds
    When "Alice" revokes "Bob" from the circle
    And "Alice" shares an interpretation on "natal:circle_revoke_after" to the circle
    Then "Bob" observes no authored interpretation on "natal:circle_revoke_after" within 5 seconds

  Scenario: Revoking then reinviting a member restores their access, with a new level
    # p2panda-auth has no in-place access-level promotion (AlreadyAdded on
    # an active member) — revoke-then-reinvite is the documented workaround
    # for "give this read-only member write access instead". Nobody had
    # proven the workaround actually works end-to-end until this scenario.
    Given a peer named "Alice" connected to the network
    And a peer named "Bob" connected to the network
    And 3 seconds pass
    And "Alice" creates a circle
    And "Alice" invites "Bob" to the circle
    And "Bob" joins the circle
    And 1 second passes
    When "Alice" revokes "Bob" from the circle
    And "Alice" invites "Bob" to the circle with write access
    And "Bob" shares an interpretation on "natal:circle_reinvite_write_share" to the circle
    Then "Alice" observes the authored interpretation on "natal:circle_reinvite_write_share" within 15 seconds

  Scenario: A member of one circle does not see another circle's shares
    # Both circles are authored by the same peer (Alice), which matters
    # here specifically: all of one author's circles currently share one
    # underlying log (zodia_circles::CIRCLE_LOG_ID is fixed), so Carol —
    # a member of Beta but never invited to Alpha — still receives
    # Alpha's ciphertext over the wire. This proves she genuinely can't
    # decrypt it, not just that she wasn't sent it.
    #
    # Two invites into two different circles for overlapping peer pairs
    # back-to-back is the same class of p2panda-net multi-topic session-
    # negotiation jitter documented for multi_key_isolation.feature.
    # Unlike that case, extra settle time here did not reduce the failure
    # rate (tried 3s, then 5s+1s+3s split across circle creation) — the
    # invite's own internal 25s retry loop is what actually matters, and
    # this specific combination (3 peers, 2 circles from one author,
    # sharing one underlying log per CIRCLE_LOG_ID) still occasionally
    # exceeds it. Documented as an accepted flaky characteristic of this
    # scenario, same as multi_key_isolation.feature, rather than chased
    # further — real app usage doesn't create two circles back-to-back
    # the instant two peers connect the way this deliberately stresses.
    Given a peer named "Alice" connected to the network
    And a peer named "Bob" connected to the network
    And a peer named "Carol" connected to the network
    And 5 seconds pass
    And "Alice" creates a circle named "Alpha"
    And 1 second passes
    And "Alice" creates a circle named "Beta"
    And 3 seconds pass
    And "Alice" invites "Bob" to circle "Alpha"
    And "Alice" invites "Carol" to circle "Beta"
    And "Bob" joins circle "Alpha"
    And "Carol" joins circle "Beta"
    And 1 second passes
    When "Alice" shares an interpretation on "natal:circle_isolation_alpha" to circle "Alpha"
    Then "Bob" observes the authored interpretation on "natal:circle_isolation_alpha" within 15 seconds
    And "Carol" observes no authored interpretation on "natal:circle_isolation_alpha" within 5 seconds

  Scenario: A revoked member's own view of the circle converges too
    # Every other revocation scenario only checks state from the circle
    # owner's side (Alice). This checks it from Bob's own client instead —
    # does the revoke message actually reach and get applied by the
    # person it targets, or does only the revoker's own state update?
    Given a peer named "Alice" connected to the network
    And a peer named "Bob" connected to the network
    And 3 seconds pass
    And "Alice" creates a circle
    And "Alice" invites "Bob" to the circle
    And "Bob" joins the circle
    And 1 second passes
    When "Alice" revokes "Bob" from the circle
    And 3 seconds pass
    Then "Bob" no longer sees themself as a member of the circle

  Scenario: An invited peer receives a real notification, correctly addressed
    # Closes the gap this feature's own header used to call out of scope:
    # the invitee previously had no in-app way to learn a circle existed
    # at all. Carol's half of this proves the notification is genuinely
    # addressable, not just "Bob happens to be listening": her client also
    # receives the broadcast (it rides the always-on global topic, same as
    # every InterpOp — no metadata-hiding here, only the circle's actual
    # content is encrypted) but can tell from the recipient field it isn't
    # hers. Filtering that out of her own UI is the app layer's job, not
    # something the SDK pretends to do by hiding the event from her.
    Given a peer named "Alice" connected to the network
    And a peer named "Bob" connected to the network
    And a peer named "Carol" connected to the network
    And 3 seconds pass
    And "Alice" creates a circle
    And "Alice" invites "Bob" to the circle
    When "Alice" notifies "Bob" of the circle invite
    Then "Bob" observes a circle invite notification within 15 seconds
    And "Carol" observes a circle invite notification not addressed to them within 15 seconds

  Scenario: A read-only member can leave a circle themselves, without owner action
    # Confirmed directly against the vendored p2panda-auth source before
    # writing this: group::crdt::state::remove() bypasses the Manage-access
    # requirement specifically when the remover is removing themself.
    # Nobody had exercised that path in this app before — every prior
    # revoke scenario is the owner acting on someone else.
    Given a peer named "Alice" connected to the network
    And a peer named "Bob" connected to the network
    And 3 seconds pass
    And "Alice" creates a circle
    And "Alice" invites "Bob" to the circle
    And "Bob" joins the circle
    And 1 second passes
    When "Bob" leaves the circle
    Then "Bob" no longer sees themself as a member of the circle
    # Unlike the owner-revokes-someone-else scenarios above, this assertion
    # is cross-peer: Alice's view only converges once Bob's leave op has
    # actually traveled the network to her, so (unlike those scenarios,
    # which check the revoker's own local state right after their own
    # local action) this needs a real settle window rather than an
    # instant check.
    And 3 seconds pass
    And "Alice" no longer sees "Bob" as a member of the circle

  Scenario: The inviter learns when an invited member actually joins
    # The invite flow already tells the invitee a circle exists
    # (CircleInviteNotify, see the scenario above). The other half of
    # that gap was never closed until now: once Bob accepts, Alice had
    # no in-app way to learn he actually showed up — only a passive
    # circle_members() check would eventually reveal it. Carol's half
    # proves this rides the same always-on global topic as every other
    # InterpOp (everyone technically receives it), but only a client
    # that already recognizes the circle_id has any reason to act on it.
    Given a peer named "Alice" connected to the network
    And a peer named "Bob" connected to the network
    And a peer named "Carol" connected to the network
    And 3 seconds pass
    And "Alice" creates a circle
    And "Alice" invites "Bob" to the circle
    And "Bob" joins the circle
    When "Bob" notifies the circle of joining
    Then "Alice" observes "Bob" joining the circle within 15 seconds
    And "Carol" observes "Bob" joining the circle within 15 seconds
