diff --git a/README-en.md b/README-en.md index d4b8dca..8a5caa0 100644 --- a/README-en.md +++ b/README-en.md @@ -52,6 +52,11 @@ must not fabricate cache invalidation. Core vector, page, and embedding caches a also rebuildable performance layers, never authorities for revisions, events, facts, actor knowledge, or snapshots. +Authority history reads are bounded at the service boundary. `EventService.list*` +and `RevisionService.history` accept a validated `limit` plus `offset`, so domain +MCPs can implement opaque continuation cursors beyond the first 100 records +without loading the complete campaign history into a public tool response. + ## Current domain implementations | Domain | Current repository | Components versioned together | diff --git a/README.md b/README.md index 802d557..ef95c5f 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,10 @@ no-op 写入不得伪造 cache 失效。Core 的向量、页面与 embedding cac 以上三个垂直仓库是当前唯一源码入口。原独立 MCP、Skills、UI 与通用 Module Generator 仓库已归档,只保留只读历史;新集成不得依赖其分支、发布或文档。 +权威历史读取在服务边界保持有界。`EventService.list*` 与 +`RevisionService.history` 接受经过校验的 `limit` 和 `offset`,让领域 MCP +可以用 opaque continuation cursor 翻过前 100 条记录,而不必把完整战役历史加载到公开工具响应。 + ## MCP 2026 身份基线 现代 Host 使用 `sagasmith.auth-context/v2` 逐请求委托,不依赖隐藏 transport session。 diff --git a/src/sagasmith_core/events.py b/src/sagasmith_core/events.py index cbc6666..5662f36 100644 --- a/src/sagasmith_core/events.py +++ b/src/sagasmith_core/events.py @@ -264,7 +264,12 @@ def _add_in_session( return self._info(row, normalized_participants) def list( - self, campaign_id: str, *, limit: int = 50, branch_id: str | None = None + self, + campaign_id: str, + *, + limit: int = 50, + offset: int = 0, + branch_id: str | None = None, ) -> list[CampaignEventInfo]: with self.database.transaction() as session: campaign = session.get(Campaign, campaign_id) @@ -272,7 +277,7 @@ def list( raise CampaignNotFoundError(campaign_id) branch = resolve_branch(session, campaign, branch_id) rows = self._branch_rows(session, campaign_id, branch) - rows = rows[-max(1, min(limit, 500)) :] + rows = self._recent_page(rows, limit=limit, offset=offset) participants = self._participant_map(session, [row.id for row in rows]) return [self._info(row, participants.get(row.id, [])) for row in rows] @@ -285,6 +290,7 @@ def list_for_actor( knowledge_disclosure_scopes: set[str] | frozenset[str] | None = None, audience: str | None = None, limit: int = 50, + offset: int = 0, branch_id: str | None = None, ) -> list[CampaignEventInfo]: """List visible branch events explicitly indexed to one actor.""" @@ -315,7 +321,7 @@ def list_for_actor( if row.id in actor_event_ids ] rows = self._actor_audience_rows(rows, audience=audience) - rows = rows[-max(1, min(limit, 500)) :] + rows = self._recent_page(rows, limit=limit, offset=offset) participants = self._participant_map(session, [row.id for row in rows]) return [self._info(row, participants.get(row.id, [])) for row in rows] @@ -475,6 +481,7 @@ def list_for_audience( audience: str, actor_id: str | None = None, limit: int = 50, + offset: int = 0, branch_id: str | None = None, ) -> list[CampaignEventInfo]: """List branch events through the one authoritative audience policy.""" @@ -529,10 +536,35 @@ def list_for_audience( if row.audience_scope in PLAYER_EVENT_AUDIENCE_SCOPES or (row.audience_scope == "actor" and row.id in actor_event_ids) ] - rows = rows[-max(1, min(limit, 500)) :] + rows = self._recent_page(rows, limit=limit, offset=offset) participants = self._participant_map(session, [row.id for row in rows]) return [self._info(row, participants.get(row.id, [])) for row in rows] + @staticmethod + def _recent_page( + rows: list[CampaignEvent], *, limit: int, offset: int + ) -> list[CampaignEvent]: + """Return a bounded newest-first window while preserving chronological output. + + ``offset`` counts backwards from the newest visible event. Keeping it in + the authority service lets MCP cursors traverse beyond the first bounded + facade page without asking callers to load the complete campaign history. + """ + + if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 500: + raise ValueError("limit must be an integer between 1 and 500") + if ( + isinstance(offset, bool) + or not isinstance(offset, int) + or not 0 <= offset <= 100_000 + ): + raise ValueError("offset must be an integer between 0 and 100000") + stop = len(rows) - offset + if stop <= 0: + return [] + start = max(0, stop - limit) + return rows[start:stop] + @staticmethod def _branch_rows(session, campaign_id: str, branch) -> list[CampaignEvent]: bound_ids: set[str] = set() diff --git a/src/sagasmith_core/revisions.py b/src/sagasmith_core/revisions.py index f585f28..3444f3a 100644 --- a/src/sagasmith_core/revisions.py +++ b/src/sagasmith_core/revisions.py @@ -344,7 +344,17 @@ def redo( ) return result - def history(self, campaign_id: str, *, limit: int = 100) -> list[RevisionInfo]: + def history( + self, campaign_id: str, *, limit: int = 100, offset: int = 0 + ) -> list[RevisionInfo]: + if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 500: + raise ValueError("limit must be an integer between 1 and 500") + if ( + isinstance(offset, bool) + or not isinstance(offset, int) + or not 0 <= offset <= 100_000 + ): + raise ValueError("offset must be an integer between 0 and 100000") with self.database.transaction() as session: campaign = session.get(Campaign, campaign_id) if campaign is None: @@ -358,7 +368,8 @@ def history(self, campaign_id: str, *, limit: int = 100) -> list[RevisionInfo]: self._visible_branch_revision_clause(session, campaign), ) .order_by(StateRevision.sequence.desc()) - .limit(max(1, min(limit, 500))) + .offset(offset) + .limit(limit) ) return [ self._info(revision, mutation_group=mutation_group) diff --git a/tests/test_state_documents.py b/tests/test_state_documents.py index 08a1fb5..3c894d7 100644 --- a/tests/test_state_documents.py +++ b/tests/test_state_documents.py @@ -2536,6 +2536,27 @@ def test_event_and_all_witness_knowledge_commit_or_rollback_together(database) - assert knowledge.list(campaign.id, actor_id=third.id) == [] +def test_event_history_pages_beyond_first_hundred_without_full_history_load(database) -> None: + campaign = CampaignService(database).create(system_id="dnd5e", name="Long event log") + events = EventService(database) + for index in range(125): + events.add(campaign.id, summary=f"Event {index:03d}") + + newest = events.list(campaign.id, limit=10) + prior = events.list(campaign.id, limit=10, offset=10) + beyond_one_hundred = events.list(campaign.id, limit=10, offset=100) + + assert [item.summary for item in newest] == [f"Event {index:03d}" for index in range(115, 125)] + assert [item.summary for item in prior] == [f"Event {index:03d}" for index in range(105, 115)] + assert [item.summary for item in beyond_one_hundred] == [ + f"Event {index:03d}" for index in range(15, 25) + ] + assert events.list(campaign.id, limit=10, offset=125) == [] + + with pytest.raises(ValueError, match="offset"): + events.list(campaign.id, offset=-1) + + def test_actor_scoped_events_follow_visible_actor_knowledge(database) -> None: campaign = CampaignService(database).create(system_id="dnd5e", name="Separate witnesses") characters = CharacterService(database) @@ -4240,6 +4261,38 @@ def test_state_mutation_persists_exact_replay_response_atomically(database) -> N assert history[0].request_hash == request_hash(public_request) +def test_revision_history_pages_beyond_first_hundred(database) -> None: + campaign = CampaignService(database).create(system_id="dnd5e", name="Long revision log") + revisions = RevisionService(database) + for index in range(125): + revisions.record( + campaign.id, + operation=f"test.history.{index:03d}", + entity_type="campaign", + entity_id=campaign.id, + before={"index": index}, + after={"index": index + 1}, + ) + + newest = revisions.history(campaign.id, limit=10) + prior = revisions.history(campaign.id, limit=10, offset=10) + beyond_one_hundred = revisions.history(campaign.id, limit=10, offset=100) + + assert [item.operation for item in newest] == [ + f"test.history.{index:03d}" for index in range(124, 114, -1) + ] + assert [item.operation for item in prior] == [ + f"test.history.{index:03d}" for index in range(114, 104, -1) + ] + assert [item.operation for item in beyond_one_hundred] == [ + f"test.history.{index:03d}" for index in range(24, 14, -1) + ] + assert revisions.history(campaign.id, limit=10, offset=125) == [] + + with pytest.raises(ValueError, match="offset"): + revisions.history(campaign.id, offset=-1) + + def test_state_mutation_rolls_back_when_atomic_replay_response_cannot_be_built( database, ) -> None: