Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README-en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。
Expand Down
40 changes: 36 additions & 4 deletions src/sagasmith_core/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,15 +264,20 @@ 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)
if campaign is None:
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]

Expand All @@ -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."""
Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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()
Expand Down
15 changes: 13 additions & 2 deletions src/sagasmith_core/revisions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down
53 changes: 53 additions & 0 deletions tests/test_state_documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down