From 683890e508399d62bb6e282318810211ef336bbb Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Sat, 29 Aug 2026 23:32:23 +0800 Subject: [PATCH] fix: filter actor memory sources by audience --- src/sagasmith_core/events.py | 176 +++++++++++++++++++----------- src/sagasmith_core/knowledge.py | 21 ++++ tests/test_state_documents.py | 185 ++++++++++++++++++++++++++++++++ 3 files changed, 319 insertions(+), 63 deletions(-) diff --git a/src/sagasmith_core/events.py b/src/sagasmith_core/events.py index 13d05a1..cbc6666 100644 --- a/src/sagasmith_core/events.py +++ b/src/sagasmith_core/events.py @@ -282,15 +282,18 @@ def list_for_actor( *, actor_id: str, roles: set[str] | frozenset[str] | None = None, + knowledge_disclosure_scopes: set[str] | frozenset[str] | None = None, + audience: str | None = None, limit: int = 50, branch_id: str | None = None, ) -> list[CampaignEventInfo]: """List visible branch events explicitly indexed to one actor.""" - selected_roles = set(roles or EVENT_PARTICIPANT_ROLES) - unknown_roles = selected_roles - EVENT_PARTICIPANT_ROLES - if unknown_roles: - raise ValueError(f"invalid event participant roles: {sorted(unknown_roles)}") + selected_roles, selected_disclosure_scopes = self._actor_filters( + roles=roles, + knowledge_disclosure_scopes=knowledge_disclosure_scopes, + audience=audience, + ) with self.database.transaction() as session: campaign = session.get(Campaign, campaign_id) if campaign is None: @@ -299,19 +302,20 @@ def list_for_actor( if actor is None or actor.campaign_id != campaign_id: raise LookupError(actor_id) branch = resolve_branch(session, campaign, branch_id) - participant_event_ids = set( - session.scalars( - select(CampaignEventParticipant.event_id).where( - CampaignEventParticipant.actor_id == actor_id, - CampaignEventParticipant.role.in_(selected_roles), - ) - ) + actor_event_ids = self._actor_event_ids( + session, + branch_id=branch.id, + actor_id=actor_id, + roles=selected_roles, + knowledge_disclosure_scopes=selected_disclosure_scopes, ) rows = [ row for row in self._branch_rows(session, campaign_id, branch) - if row.id in participant_event_ids - ][-max(1, min(limit, 500)) :] + if row.id in actor_event_ids + ] + rows = self._actor_audience_rows(rows, audience=audience) + rows = rows[-max(1, min(limit, 500)) :] participants = self._participant_map(session, [row.id for row in rows]) return [self._info(row, participants.get(row.id, [])) for row in rows] @@ -323,6 +327,7 @@ def search_for_actor( query: str, roles: set[str] | frozenset[str] | None = None, knowledge_disclosure_scopes: set[str] | frozenset[str] | None = None, + audience: str | None = None, limit: int = 50, branch_id: str | None = None, ) -> list[CampaignEventInfo]: @@ -331,24 +336,11 @@ def search_for_actor( normalized_query = str(query or "").strip() if not normalized_query: raise ValueError("event search query must not be blank") - selected_roles = set(roles or EVENT_PARTICIPANT_ROLES) - unknown_roles = selected_roles - EVENT_PARTICIPANT_ROLES - if unknown_roles: - raise ValueError(f"invalid event participant roles: {sorted(unknown_roles)}") - selected_disclosure_scopes = ( - None - if knowledge_disclosure_scopes is None - else set(knowledge_disclosure_scopes) + selected_roles, selected_disclosure_scopes = self._actor_filters( + roles=roles, + knowledge_disclosure_scopes=knowledge_disclosure_scopes, + audience=audience, ) - if selected_disclosure_scopes is not None: - unknown_scopes = ( - selected_disclosure_scopes - ACTOR_KNOWLEDGE_DISCLOSURE_SCOPES - ) - if unknown_scopes: - raise ValueError( - "invalid actor-knowledge disclosure scopes: " - f"{sorted(unknown_scopes)}" - ) with self.database.transaction() as session: campaign = session.get(Campaign, campaign_id) if campaign is None: @@ -357,45 +349,19 @@ def search_for_actor( if actor is None or actor.campaign_id != campaign_id: raise LookupError(actor_id) branch = resolve_branch(session, campaign, branch_id) - participant_event_ids = set( - session.scalars( - select(CampaignEventParticipant.event_id).where( - CampaignEventParticipant.actor_id == actor_id, - CampaignEventParticipant.role.in_(selected_roles), - ) - ) + actor_event_ids = self._actor_event_ids( + session, + branch_id=branch.id, + actor_id=actor_id, + roles=selected_roles, + knowledge_disclosure_scopes=selected_disclosure_scopes, ) - knowledge_event_ids: set[str] = set() - if selected_disclosure_scopes: - knowledge_event_ids = { - str(source_event_id) - for source_event_id in session.scalars( - select(ActorKnowledgeRevision.source_event_id) - .join( - BranchActorKnowledgeHead, - BranchActorKnowledgeHead.revision_id - == ActorKnowledgeRevision.id, - ) - .join( - ActorKnowledge, - ActorKnowledge.id == BranchActorKnowledgeHead.knowledge_id, - ) - .where( - BranchActorKnowledgeHead.branch_id == branch.id, - ActorKnowledge.actor_id == actor_id, - ActorKnowledgeRevision.source_event_id.is_not(None), - ActorKnowledgeRevision.disclosure_scope.in_( - selected_disclosure_scopes - ), - ) - ) - } - actor_event_ids = participant_event_ids | knowledge_event_ids rows = [ row for row in self._branch_rows(session, campaign_id, branch) if row.id in actor_event_ids ] + rows = self._actor_audience_rows(rows, audience=audience) scored = [ ( lexical_score( @@ -418,6 +384,90 @@ def search_for_actor( participants = self._participant_map(session, [row.id for row in ranked]) return [self._info(row, participants.get(row.id, [])) for row in ranked] + @staticmethod + def _actor_filters( + *, + roles: set[str] | frozenset[str] | None, + knowledge_disclosure_scopes: set[str] | frozenset[str] | None, + audience: str | None, + ) -> tuple[set[str], set[str] | None]: + selected_roles = set(roles or EVENT_PARTICIPANT_ROLES) + unknown_roles = selected_roles - EVENT_PARTICIPANT_ROLES + if unknown_roles: + raise ValueError(f"invalid event participant roles: {sorted(unknown_roles)}") + selected_disclosure_scopes = ( + None + if knowledge_disclosure_scopes is None + else set(knowledge_disclosure_scopes) + ) + if selected_disclosure_scopes is not None: + unknown_scopes = selected_disclosure_scopes - ACTOR_KNOWLEDGE_DISCLOSURE_SCOPES + if unknown_scopes: + raise ValueError( + "invalid actor-knowledge disclosure scopes: " + f"{sorted(unknown_scopes)}" + ) + if audience is not None and audience not in CONTINUITY_AUDIENCES: + raise ValueError("audience must be 'dm' or 'player'") + if audience == "player" and selected_disclosure_scopes is not None: + selected_disclosure_scopes &= PLAYER_OWNED_ACTOR_DISCLOSURE_SCOPES + return selected_roles, selected_disclosure_scopes + + @staticmethod + def _actor_event_ids( + session, + *, + branch_id: str, + actor_id: str, + roles: set[str], + knowledge_disclosure_scopes: set[str] | None, + ) -> set[str]: + event_ids = set( + session.scalars( + select(CampaignEventParticipant.event_id).where( + CampaignEventParticipant.actor_id == actor_id, + CampaignEventParticipant.role.in_(roles), + ) + ) + ) + if knowledge_disclosure_scopes: + event_ids.update( + str(source_event_id) + for source_event_id in session.scalars( + select(ActorKnowledgeRevision.source_event_id) + .join( + BranchActorKnowledgeHead, + BranchActorKnowledgeHead.revision_id == ActorKnowledgeRevision.id, + ) + .join( + ActorKnowledge, + ActorKnowledge.id == BranchActorKnowledgeHead.knowledge_id, + ) + .where( + BranchActorKnowledgeHead.branch_id == branch_id, + ActorKnowledge.actor_id == actor_id, + ActorKnowledgeRevision.source_event_id.is_not(None), + ActorKnowledgeRevision.disclosure_scope.in_( + knowledge_disclosure_scopes + ), + ) + ) + ) + return event_ids + + @staticmethod + def _actor_audience_rows( + rows: list[CampaignEvent], *, audience: str | None + ) -> list[CampaignEvent]: + if audience != "player": + return rows + return [ + row + for row in rows + if row.audience_scope in PLAYER_EVENT_AUDIENCE_SCOPES + or row.audience_scope == "actor" + ] + def list_for_audience( self, campaign_id: str, diff --git a/src/sagasmith_core/knowledge.py b/src/sagasmith_core/knowledge.py index 5c7b409..33b5679 100644 --- a/src/sagasmith_core/knowledge.py +++ b/src/sagasmith_core/knowledge.py @@ -286,7 +286,20 @@ def list( actor_id: str, branch_id: str | None = None, include_inactive: bool = False, + disclosure_scopes: set[str] | frozenset[str] | None = None, ) -> list[ActorKnowledgeInfo]: + selected_disclosure_scopes = ( + None if disclosure_scopes is None else set(disclosure_scopes) + ) + if selected_disclosure_scopes is not None: + unknown_scopes = ( + selected_disclosure_scopes - ACTOR_KNOWLEDGE_DISCLOSURE_SCOPES + ) + if unknown_scopes: + raise ValueError( + "invalid actor-knowledge disclosure scopes: " + f"{sorted(unknown_scopes)}" + ) with self.database.transaction() as session: campaign = session.get(Campaign, campaign_id) if campaign is None: @@ -314,6 +327,12 @@ def list( INACTIVE_ACTOR_KNOWLEDGE_STATUSES ) ) + if selected_disclosure_scopes is not None: + statement = statement.where( + ActorKnowledgeRevision.disclosure_scope.in_( + selected_disclosure_scopes + ) + ) rows = session.execute(statement) return [self._info(*row) for row in rows] @@ -346,12 +365,14 @@ def search( branch_id: str | None = None, limit: int = 8, include_inactive: bool = False, + disclosure_scopes: set[str] | frozenset[str] | None = None, ) -> list[ActorKnowledgeInfo]: values = self.list( campaign_id, actor_id=actor_id, branch_id=branch_id, include_inactive=include_inactive, + disclosure_scopes=disclosure_scopes, ) ranked = sorted( values, diff --git a/tests/test_state_documents.py b/tests/test_state_documents.py index 1464f07..08a1fb5 100644 --- a/tests/test_state_documents.py +++ b/tests/test_state_documents.py @@ -2416,6 +2416,84 @@ def test_branch_scoped_facts_events_and_actor_knowledge_do_not_leak(database) -> ].proposition.endswith("guard room.") +def test_actor_knowledge_list_and_search_filter_disclosure_scopes_per_branch( + database, +) -> None: + campaign = CampaignService(database).create(system_id="dnd5e", name="Scoped beliefs") + actor = CharacterService(database).create( + system_id="dnd5e", campaign_id=campaign.id, name="Witness", character_type="pc" + ) + knowledge = ActorKnowledgeService(database) + snapshots = SnapshotService(database) + + for scope in ("owner", "public"): + knowledge.add( + campaign.id, + actor_id=actor.id, + knowledge_key=f"{scope}-marker", + proposition=f"The {scope} branch marker is remembered.", + disclosure_scope=scope, + ) + base = snapshots.create(campaign.id, label="Shared knowledge") + main = BranchService(database).current(campaign.id) + alternate = BranchService(database).create( + campaign.id, + name="private-knowledge", + from_snapshot_id=base.id, + checkout=True, + ) + snapshots.checkout_branch(campaign.id, alternate.id) + for scope in ("dm", "player"): + knowledge.add( + campaign.id, + actor_id=actor.id, + knowledge_key=f"{scope}-marker", + proposition=f"The {scope} branch marker is remembered.", + disclosure_scope=scope, + ) + + assert { + item.disclosure_scope + for item in knowledge.list( + campaign.id, + actor_id=actor.id, + branch_id=alternate.id, + disclosure_scopes={"owner", "player"}, + ) + } == {"owner", "player"} + assert { + item.disclosure_scope + for item in knowledge.search( + campaign.id, + actor_id=actor.id, + branch_id=alternate.id, + query="branch marker", + limit=10, + disclosure_scopes={"dm", "public"}, + ) + } == {"dm", "public"} + assert knowledge.list( + campaign.id, + actor_id=actor.id, + branch_id=main.id, + disclosure_scopes={"dm", "player"}, + ) == [] + assert { + item.disclosure_scope + for item in knowledge.list( + campaign.id, + actor_id=actor.id, + branch_id=alternate.id, + ) + } == {"dm", "owner", "player", "public"} + with pytest.raises(ValueError, match="invalid actor-knowledge disclosure scopes"): + knowledge.list( + campaign.id, + actor_id=actor.id, + disclosure_scopes={"facilitator"}, + ) + + def test_event_and_all_witness_knowledge_commit_or_rollback_together(database) -> None: campaign = CampaignService(database).create(system_id="dnd5e", name="Atomic witnesses") characters = CharacterService(database) @@ -2709,6 +2787,113 @@ def test_actor_event_search_includes_only_permitted_knowledge_source_events( ) +def test_actor_event_list_and_search_apply_event_audience_after_actor_indexing( + database, +) -> None: + campaign = CampaignService(database).create(system_id="dnd5e", name="Private history") + actor = CharacterService(database).create( + system_id="dnd5e", campaign_id=campaign.id, name="Witness", character_type="pc" + ) + events = EventService(database) + direct_dm = events.add( + campaign.id, + summary="Direct participant secret marker.", + audience_scope="dm", + participants=[{"actor_id": actor.id, "role": "witness"}], + ) + direct_actor = events.add( + campaign.id, + summary="Direct participant actor marker.", + audience_scope="actor", + participants=[{"actor_id": actor.id, "role": "witness"}], + ) + owner_actor, _ = events.add_with_actor_knowledge( + campaign.id, + summary="Owner knowledge actor marker.", + actor_ids=[actor.id], + knowledge_key="owner-actor-marker", + proposition="The owner may recall this actor-scoped event.", + audience_scope="actor", + disclosure_scope="owner", + ) + dm_actor, _ = events.add_with_actor_knowledge( + campaign.id, + summary="DM knowledge actor marker.", + actor_ids=[actor.id], + knowledge_key="dm-actor-marker", + proposition="Only the DM may use this knowledge source.", + audience_scope="actor", + disclosure_scope="dm", + ) + owner_dm, _ = events.add_with_actor_knowledge( + campaign.id, + summary="Owner knowledge DM marker.", + actor_ids=[actor.id], + knowledge_key="owner-dm-marker", + proposition="The knowledge is owner-visible but its source event is DM-only.", + audience_scope="dm", + disclosure_scope="owner", + ) + + player_filters = { + "audience": "player", + "knowledge_disclosure_scopes": {"owner", "party", "public", "player"}, + } + dm_filters = { + "audience": "dm", + "knowledge_disclosure_scopes": {"dm", "owner", "party", "public", "player"}, + } + expected_player_ids = {direct_actor.id, owner_actor.id} + expected_dm_ids = { + direct_dm.id, + direct_actor.id, + owner_actor.id, + dm_actor.id, + owner_dm.id, + } + + assert { + item.id + for item in events.list_for_actor(campaign.id, actor_id=actor.id, **player_filters) + } == expected_player_ids + assert { + item.id + for item in events.search_for_actor( + campaign.id, + actor_id=actor.id, + query="marker", + **player_filters, + ) + } == expected_player_ids + assert { + item.id + for item in events.list_for_actor(campaign.id, actor_id=actor.id, **dm_filters) + } == expected_dm_ids + assert { + item.id + for item in events.search_for_actor( + campaign.id, + actor_id=actor.id, + query="marker", + **dm_filters, + ) + } == expected_dm_ids + assert { + item.id + for item in events.list_for_actor( + campaign.id, + actor_id=actor.id, + audience="player", + knowledge_disclosure_scopes={"dm"}, + ) + } == {direct_actor.id} + + # Existing callers remain participant-only and unfiltered until they opt in. + assert { + item.id for item in events.list_for_actor(campaign.id, actor_id=actor.id) + } == {direct_dm.id, direct_actor.id} + + def test_continuity_commit_snapshots_event_participants_and_detects_index_tampering( database, ) -> None: