From 1660944a20814b5ef521102806b0bf31a6f909a7 Mon Sep 17 00:00:00 2001 From: sunrioa Date: Fri, 24 Jul 2026 02:08:24 +0800 Subject: [PATCH] fix: enforce runtime state and snapshot invariants --- compat/documentation_test.go | 71 ++ docs/architecture.md | 27 +- docs/architecture.zh-CN.md | 20 +- docs/protocol-v1.md | 55 +- docs/protocol-v1.zh-CN.md | 49 +- protocol/state_invariants_test.go | 522 +++++++++++++ protocol/state_validate.go | 257 ++++++- protocol/validate.go | 17 + runtime/engine.go | 250 ++++++- runtime/engine_state_invariants_test.go | 951 ++++++++++++++++++++++++ runtime/engine_test.go | 10 +- runtime/hash.go | 14 + runtime/memory.go | 58 +- runtime/memory_state_invariants_test.go | 647 ++++++++++++++++ runtime/reducer.go | 219 +++++- runtime/state_closure_property_test.go | 382 ++++++++++ 16 files changed, 3441 insertions(+), 108 deletions(-) create mode 100644 protocol/state_invariants_test.go create mode 100644 runtime/engine_state_invariants_test.go create mode 100644 runtime/memory_state_invariants_test.go create mode 100644 runtime/state_closure_property_test.go diff --git a/compat/documentation_test.go b/compat/documentation_test.go index 7f28a6f..0c11f16 100644 --- a/compat/documentation_test.go +++ b/compat/documentation_test.go @@ -169,6 +169,77 @@ func TestPublicDocsUseOutcomeReportingSemantics(t *testing.T) { } } +func TestStateClosureDocumentationContract(t *testing.T) { + required := map[string][]string{ + "../docs/protocol-v1.md": { + "Actor Goals | 32", + "Actor detailed Memories | 128", + "Actor Beliefs / BeliefSets | 256", + "Recall counts saturate at 1,000,000", + "Retained Proposal and Arbitration tick fields are not upper-bounded", + "Imported historical Receipt revisions become", + "provide a permanent Event ID index", + }, + "../docs/protocol-v1.zh-CN.md": { + "Actor Goals | 32", + "Actor 详细 Memories | 128", + "Actor Beliefs / BeliefSets | 256", + "RecallCount 在 1,000,000 饱和", + "State 中保留的 Proposal 与 Arbitration tick 不受", + "导入的历史 Receipt revision 会在", + "尚未提供超出这些投影的永久 Event ID", + }, + "../docs/architecture.md": { + "reducer or candidate-validation failure", + "Policy calls receive isolated copies", + "Receipt revisions are set to zero", + }, + "../docs/architecture.zh-CN.md": { + "reducer 或候选校验失败", + "Policy 调用收到 State、Actor 和请求的隔离副本", + "历史 Receipt revision 设为 0", + }, + } + for path, fragments := range required { + payload, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, fragment := range fragments { + if !strings.Contains(string(payload), fragment) { + t.Errorf("%s is missing state-closure rule %q", path, fragment) + } + } + } + + prohibited := map[string][]string{ + "../docs/architecture.md": { + "A failed transition therefore leaves both the event log", + "Receipt revision metadata is rebased", + }, + "../docs/architecture.zh-CN.md": { + "失败的转换既不会改变事件日志", + }, + "../docs/protocol-v1.md": { + "persistent idempotency index described in the migration roadmap", + }, + "../docs/protocol-v1.zh-CN.md": { + "迁移路线中的持久幂等索引", + }, + } + for path, fragments := range prohibited { + payload, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, fragment := range fragments { + if strings.Contains(string(payload), fragment) { + t.Errorf("%s retains obsolete state-closure wording %q", path, fragment) + } + } + } +} + func TestPublicDocumentationLanguage(t *testing.T) { required := map[string]string{ "../README.en.md": "> Game-native agent runtime.", diff --git a/docs/architecture.md b/docs/architecture.md index f1951b3..116a74c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -167,6 +167,24 @@ produces a complete, verifiable snapshot without writing to the store. `rin inspect` reuses both paths for machine-readable diagnostics; opening a data directory still verifies the entire event hash chain. +### Mutation and state closure + +Every event is first applied to an isolated candidate state. The reducer then +validates the complete `SessionState`, including feature-gated fields, +capacities, revision and tick bounds, actor references, and paired belief +projections. Only a valid candidate may be appended to the Store and published +as the live state. A reducer or candidate-validation failure therefore leaves +both the event log and the in-memory session unchanged. Store write failures +use the separate append-confirmation and reconciliation rules described by the +outcome protocol. + +Policy calls receive isolated copies of the State, Actor, and request. Policy +code may inspect or mutate those values locally, but it cannot mutate the live +session outside an event. Runtime-owned collections also close their +references when bounded retention runs: memory compaction rewrites recalled +IDs to the replacement Summary, non-archive eviction removes those references, +and Belief/BeliefSet eviction is deterministic and paired. + ### Store File-store layout: @@ -206,7 +224,9 @@ only scheduling time, never boundaries or the action allowlist. ## Save and rollback - Game saves should store snapshots returned by Rin, not internal file paths. -- A snapshot carries the content-pack binding and state hash. +- A snapshot carries the content-pack binding and state hash. Rin validates a + cloned State before hashing or saving it, so every successfully returned + snapshot passes the same structural validation used by Restore. - With `outcome-reporting-v1`, Restore retains pending proposals so a saved, unhandled Proposal Attempt can resume, and so a game-save Outcome Outbox can report actions already applied before the save. Restored proposals never @@ -217,6 +237,11 @@ only scheduling time, never boundaries or the action allowlist. proposals. - Committed events, memories, facts, goal progress, and scheduling ticks are restored. +- Restore starts a new local event-chain generation. Retained Proposal, + Memory, Belief, Activity, and Arbitration revision metadata is rebased to + that generation before the restored State is published. Imported historical + Receipt revisions are set to zero; the new Restore Receipt records the local + generation. - A new data directory may import a snapshot; its local event chain then begins with a restore event. - When loading the same save repeatedly, callers should bind the restore diff --git a/docs/architecture.zh-CN.md b/docs/architecture.zh-CN.md index 3771db1..e9112b2 100644 --- a/docs/architecture.zh-CN.md +++ b/docs/architecture.zh-CN.md @@ -85,6 +85,19 @@ Ren'Py worker registry、Godot `HTTPRequest` 和 Unity coroutine 都只存在于 Timeline 只从事件 payload 提取 ID 和枚举状态,不返回玩家原话、剧情摘要、Commit outcome 或模型内容。Replay 则运行同一个 reducer 到指定 revision,生成完整且可验证的 Snapshot,不写回 Store。`rin inspect` 复用这两条路径输出机器可读诊断;打开数据目录时仍会验证全部事件 hash chain。 +### Mutation 与状态闭包 + +每个事件都先应用到隔离的候选 State。Reducer 随后校验完整 +`SessionState`,包括 Feature 门禁、容量、revision/tick 上界、Actor 引用和 +成对的 Belief 投影;只有通过校验的候选状态才能追加到 Store 并发布为 live +State。因此 reducer 或候选校验失败既不会改变事件日志,也不会改变内存中的 +Session。Store 写入失败则遵循 outcome 协议单独定义的 append 确认与对账规则。 + +Policy 调用收到 State、Actor 和请求的隔离副本。Policy 可以在本地读取或修改 +这些值,但不能绕过事件直接改变 live Session。Runtime 的有界保留也会闭合 +引用:Memory 归档会把 recalled ID 改写到替代 Summary,未启用归档时会移除 +被淘汰的引用,Belief 与 BeliefSet 则按确定性顺序成对淘汰。 + ### 存储 文件存储结构: @@ -113,7 +126,8 @@ rin-data/ ## 存档与回滚 - 游戏存档应保存 Rin 返回的 Snapshot,而不是内部文件路径。 -- Snapshot 带内容包 Binding 和状态哈希。 +- Snapshot 带内容包 Binding 和状态哈希。Rin 在计算哈希或保存前先校验克隆的 + State,因此每个成功返回的 Snapshot 都通过与 Restore 相同的结构校验。 - 启用 `outcome-reporting-v1` 后,Restore 会保留 pending Proposal,既让存档中 尚未处理的 Proposal Attempt 能恢复,也让 Outcome Outbox 能补报读档前已经 应用的动作。恢复出的 Proposal 不授权执行;游戏必须依赖持久化 Attempt 和 @@ -121,6 +135,10 @@ rin-data/ 重做已经处理的动作。 - 未启用该 Feature 的 Session 保留旧版 Restore 行为并清空 Proposal。 - 已提交事件、记忆、事实、目标进度和调度 tick 会恢复。 +- Restore 会开始一个新的本地事件链 generation;保留的 Proposal、Memory、 + Belief、Activity 和 Arbitration revision 元数据会在发布恢复状态前重基到该 + generation。导入的历史 Receipt revision 设为 0,本次 Restore Receipt 则记录 + 新的本地 generation。 - 新数据目录可以导入 Snapshot;此时本地事件链从一条 restore 事件开始。 - 重复载入同一存档时,调用方应让 restore request ID 同时绑定 Snapshot hash 与当前 Sidecar head,以区分网络重试和真正的再次回档。 diff --git a/docs/protocol-v1.md b/docs/protocol-v1.md index 0b25196..6c3a7ce 100644 --- a/docs/protocol-v1.md +++ b/docs/protocol-v1.md @@ -424,6 +424,45 @@ the entire batch without partial mutation. Results are stably sorted by `next_think_tick` and actor ID for turn-based, regional, and time-sliced games. +## State closure and bounded retention + +Every successful mutation produces a complete State that passes the same +structural validation used by Snapshot and Restore. Reducers validate an +isolated candidate before Store append, so an invalid transition is rejected +without a partial in-memory or durable update. Dynamic references such as Fact +visibility must name actors in the Session. With `belief-conflicts-v1`, +`beliefs` and `belief_sets` have exactly the same keys and selected Fact. + +Retained collections use these protocol bounds: + +| Collection | Bound | Full-capacity behavior | +|---|---:|---| +| Actor Goals | 32, including distinct pending ProposedGoal reservations | Reject a new reservation; never silently drop a Goal | +| Actor detailed Memories | 128 | Archive into a Summary when `memory-archive-v1` is enabled; otherwise evict details and remove their recalled references | +| Actor Memory Summaries | 32 | Deterministically merge older summaries; level saturates at 16 | +| Actor Beliefs / BeliefSets | 256 keys | Deterministically evict the oldest projected key and its paired set | +| Actor RecentActions | 32 | Retain the latest game-occurrence outcomes | +| Session Proposals | 64 | Evict only resolved proposals; fail closed when all retained proposals are pending | +| Session Arbitrations | 32 | Retain the latest records | +| Session Receipts | 1024 | Retain the newest revision generation | + +Recall counts saturate at 1,000,000. Memory compaction rewrites a Proposal or +RecentAction reference to the replacement Summary ID; non-archive eviction +removes the unavailable ID. Revisions, ticks, selected belief sources, Goal +status sources, and visibility actors retained by Memory, Summary, Belief, +Activity, Goal, and outcome metadata must remain inside the containing State. +Retained Proposal and Arbitration tick fields are not upper-bounded by +`state.tick` and may describe work ahead of it; live Propose and Arbitrate +requests still reject tick regression. `nil` and an empty Fact visibility list +are the same JSON contract value. + +An `event_id` is rejected while it is discoverable from any retained Proposal, +RecentAction, Goal status, Memory, Summary, Belief claim, or observation +Receipt. The current v1 hot path does not scan the unbounded event log after +all bounded projections of that ID have been evicted; applications must still +use globally unique IDs. This retained-State implementation does not yet +provide a permanent Event ID index beyond those projections. + ## Snapshot and restore Snapshot and Session State requests use the same shape: @@ -444,7 +483,21 @@ Restore: ``` Restore rejects snapshots with an invalid hash, different session ID, or -different binding. With `outcome-reporting-v1`, it retains pending proposals +different binding. Rin validates a cloned State before computing or saving a +Snapshot, so every successfully returned Snapshot immediately passes +`ValidateSnapshot` and can be imported into a fresh or non-exhausted matching +Session. + +Restore writes a new local event-chain generation. Retained nested revision +metadata is rebased to the Restore event; a retained Proposal references the +preceding local revision and head hash. On a fresh import that base is revision +zero with an empty head hash. Imported historical Receipt revisions become +zero before the new Restore Receipt is inserted, so a full 1,024-entry map +cannot evict the operation that just succeeded. World revision advances +without wrapping; importing an already-maximal world revision keeps it +saturated, while later world mutations fail closed. + +With `outcome-reporting-v1`, Restore retains pending proposals for two durable recovery states: an unresolved Proposal Attempt received before the game handled it, or an already-handled operation whose saved Outcome Outbox still needs to report. A restored Proposal never authorizes execution. The game diff --git a/docs/protocol-v1.zh-CN.md b/docs/protocol-v1.zh-CN.md index f0faa28..276043a 100644 --- a/docs/protocol-v1.zh-CN.md +++ b/docs/protocol-v1.zh-CN.md @@ -357,6 +357,41 @@ reducer。超时或暂时错误只能使用相同 `request_id` 重报,不能 按 `next_think_tick` 和 actor ID 稳定排序,便于回合制、区域制和时间片游戏使用。 +## 状态闭包与有界保留 + +每个成功 Mutation 都会产生通过 Snapshot 与 Restore 同一套结构校验的完整 +State。Reducer 在 Store append 前校验隔离的候选状态,因此无效转换不会留下 +部分内存更新或持久化更新。Fact visibility 等动态引用必须指向 Session 中的 +Actor。启用 `belief-conflicts-v1` 后,`beliefs` 与 `belief_sets` 必须具有完全 +相同的 key,并投影同一个 selected Fact。 + +保留集合遵循以下协议上限: + +| 集合 | 上限 | 满容量行为 | +|---|---:|---| +| Actor Goals | 32,包含不同 pending ProposedGoal 预留 | 拒绝新预留,不静默删除 Goal | +| Actor 详细 Memories | 128 | 启用 `memory-archive-v1` 时归档为 Summary;否则淘汰明细并移除其 recalled 引用 | +| Actor Memory Summaries | 32 | 确定性合并较旧摘要,level 在 16 饱和 | +| Actor Beliefs / BeliefSets | 256 个 key | 确定性淘汰最旧投影 key 及其配对 Set | +| Actor RecentActions | 32 | 保留按游戏发生时间排序的最新 outcome | +| Session Proposals | 64 | 只淘汰 resolved Proposal;全部为 pending 时 fail closed | +| Session Arbitrations | 32 | 保留最新记录 | +| Session Receipts | 1024 | 保留最新 revision generation | + +RecallCount 在 1,000,000 饱和。Memory 归档会把 Proposal 或 RecentAction 的 +引用改写到替代 Summary ID;未启用归档时会移除不可用 ID。revision、tick、 +selected belief source、Goal status source 与 visibility actor 等 Memory、 +Summary、Belief、Activity、Goal 和 outcome 元数据都必须处在容器 State 的 +有效范围内。State 中保留的 Proposal 与 Arbitration tick 不受 `state.tick` +上界限制,可以描述其后的工作;实时 Propose 与 Arbitrate 请求仍会拒绝 tick +倒退。Fact visibility 的 `null`/缺省与空数组属于相同 JSON 契约值。 + +只要 `event_id` 仍能从保留的 Proposal、RecentAction、Goal status、Memory、 +Summary、Belief claim 或 observation Receipt 找到,就会被拒绝。当前 v1 热 +路径不会在该 ID 的所有有界投影均被淘汰后扫描无界事件日志;应用仍必须生成 +全局唯一 ID。当前 retained-State 实现尚未提供超出这些投影的永久 Event ID +索引。 + ## Snapshot 与 Restore Snapshot 请求和 Session State 请求结构相同: @@ -376,7 +411,19 @@ Restore: } ``` -Restore 拒绝 hash 错误、Session ID 不同或 Binding 不同的快照。启用 +Restore 拒绝 hash 错误、Session ID 不同或 Binding 不同的快照。Rin 在计算或 +保存 Snapshot 前校验克隆的 State,因此每个成功返回的 Snapshot 都会立即 +通过 `ValidateSnapshot`,并可导入空 Session 或尚未耗尽 revision 的匹配 +Session。 + +Restore 会写入新的本地事件链 generation。保留的嵌套 revision 元数据会重基 +到 Restore 事件;保留 Proposal 引用前一个本地 revision 与 head hash。Fresh +import 的 base 是 revision 0 和空 head hash。导入的历史 Receipt revision 会在 +插入本次 Restore Receipt 前改为 0,因此已经装满 1,024 项的 map 不会淘汰刚 +成功的操作。World revision 只前进、不回绕;导入已经达到最大值的 world +revision 时保持饱和,后续 world mutation 则 fail closed。 + +启用 `outcome-reporting-v1` 时,它会为两种持久恢复状态保留 pending Proposal: 游戏处理前收到但尚未结算的 Proposal Attempt,以及动作已经处理、但存档中的 Outcome Outbox 仍待补报的 Operation。恢复 Proposal 绝不授权游戏执行它。 diff --git a/protocol/state_invariants_test.go b/protocol/state_invariants_test.go new file mode 100644 index 0000000..1efd547 --- /dev/null +++ b/protocol/state_invariants_test.go @@ -0,0 +1,522 @@ +package protocol + +import ( + "strings" + "testing" +) + +func TestSessionStateProposalGenerationInvariants(t *testing.T) { + state := invariantTestState() + proposal := invariantTestProposal(state, "proposal.general", "pending") + state.Proposals[proposal.ID] = proposal + requireValidState(t, state) + + tests := []struct { + name string + mutate func(*ActionProposal) + }{ + { + name: "base equals creation", + mutate: func(proposal *ActionProposal) { + proposal.BasedOnRevision = proposal.CreatedRevision + }, + }, + { + name: "zero base outside fresh restore", + mutate: func(proposal *ActionProposal) { + proposal.BasedOnRevision = 0 + proposal.BasedOnHeadHash = "" + }, + }, + { + name: "missing hash for positive base", + mutate: func(proposal *ActionProposal) { + proposal.BasedOnHeadHash = "" + }, + }, + { + name: "creation exceeds state", + mutate: func(proposal *ActionProposal) { + proposal.CreatedRevision = state.Revision + 1 + }, + }, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + invalid := state + invalid.Proposals = make(map[string]ActionProposal, len(state.Proposals)) + for id, proposal := range state.Proposals { + testCase.mutate(&proposal) + invalid.Proposals[id] = proposal + } + requireInvalidState(t, invalid) + }) + } + + fresh := invariantTestState() + freshProposal := invariantTestProposal(fresh, "proposal.fresh", "pending") + freshProposal.CreatedRevision = 1 + freshProposal.BasedOnRevision = 0 + freshProposal.BasedOnHeadHash = "" + fresh.Proposals[freshProposal.ID] = freshProposal + requireValidState(t, fresh) + + freshProposal.BasedOnHeadHash = invariantTestHash() + fresh.Proposals[freshProposal.ID] = freshProposal + requireInvalidState(t, fresh) +} + +func TestSessionStateRecalledIDsOnlyReferenceLiveMemoryEntities(t *testing.T) { + state := invariantTestState(FeatureMemoryArchive) + actor := state.Actors["npc.test"] + actor.MemorySummaries = []MemorySummary{ + invariantTestSummary("summary.live", "memory.archived", "event.archived"), + invariantTestSummary("summary.other", "memory.archived", "event.archived"), + } + state.Actors[actor.ID] = actor + proposal := invariantTestProposal(state, "proposal.memory", "pending") + proposal.RecalledMemoryIDs = []string{"summary.live"} + state.Proposals[proposal.ID] = proposal + + // Lineage is local metadata on each summary. It may overlap across + // summaries, but it is not itself a recallable memory entity. + requireValidState(t, state) + + proposal.RecalledMemoryIDs = []string{"memory.archived"} + state.Proposals[proposal.ID] = proposal + requireInvalidState(t, state) + + proposal.RecalledMemoryIDs = []string{"summary.live"} + state.Proposals[proposal.ID] = proposal + actor = state.Actors["npc.test"] + actor.MemorySummaries[0].SourceMemoryIDs = []string{"memory.archived", "memory.archived"} + state.Actors[actor.ID] = actor + requireInvalidState(t, state) +} + +func TestSessionStatePendingProposedGoalReservations(t *testing.T) { + t.Run("unique pending reservation", func(t *testing.T) { + state := invariantTestState(FeatureGoalCandidates) + proposal := invariantTestProposal(state, "proposal.goal.one", "pending") + proposal.GoalID = "goal.candidate" + proposal.ProposedGoal = invariantTestCandidateGoal("goal.candidate") + state.Proposals[proposal.ID] = proposal + requireValidState(t, state) + }) + + t.Run("pending reservations cannot overlap", func(t *testing.T) { + state := invariantTestState(FeatureGoalCandidates) + for _, id := range []string{"proposal.goal.one", "proposal.goal.two"} { + proposal := invariantTestProposal(state, id, "pending") + proposal.GoalID = "goal.candidate" + proposal.ProposedGoal = invariantTestCandidateGoal("goal.candidate") + state.Proposals[proposal.ID] = proposal + } + requireInvalidState(t, state) + }) + + t.Run("pending reservation cannot reuse actor goal", func(t *testing.T) { + state := invariantTestState(FeatureGoalCandidates) + proposal := invariantTestProposal(state, "proposal.goal.existing", "pending") + proposal.GoalID = "goal.test" + proposal.ProposedGoal = invariantTestCandidateGoal("goal.test") + state.Proposals[proposal.ID] = proposal + requireInvalidState(t, state) + }) + + t.Run("pending reservations count against capacity", func(t *testing.T) { + state := invariantTestState(FeatureGoalCandidates) + actor := state.Actors["npc.test"] + actor.Goals = make([]Goal, 32) + for index := range actor.Goals { + actor.Goals[index] = *invariantTestCandidateGoal("goal." + testIndex(index)) + } + state.Actors[actor.ID] = actor + proposal := invariantTestProposal(state, "proposal.goal.overflow", "pending") + proposal.GoalID = "goal.overflow" + proposal.ProposedGoal = invariantTestCandidateGoal("goal.overflow") + state.Proposals[proposal.ID] = proposal + requireInvalidState(t, state) + }) + + t.Run("resolved proposal may retain proposed goal", func(t *testing.T) { + state := invariantTestState(FeatureGoalCandidates) + accepted := invariantTestProposal(state, "proposal.goal.accepted", "accepted") + accepted.GoalID = "goal.test" + accepted.ProposedGoal = invariantTestCandidateGoal("goal.test") + state.Proposals[accepted.ID] = accepted + rejected := invariantTestProposal(state, "proposal.goal.rejected", "rejected") + rejected.GoalID = "goal.rejected" + rejected.ProposedGoal = invariantTestCandidateGoal("goal.rejected") + state.Proposals[rejected.ID] = rejected + requireValidState(t, state) + }) + + t.Run("accepted proposed goal must enter actor state", func(t *testing.T) { + state := invariantTestState(FeatureGoalCandidates) + accepted := invariantTestProposal(state, "proposal.goal.missing", "accepted") + accepted.GoalID = "goal.missing" + accepted.ProposedGoal = invariantTestCandidateGoal("goal.missing") + state.Proposals[accepted.ID] = accepted + requireInvalidState(t, state) + + delete(state.Proposals, accepted.ID) + actor := state.Actors["npc.test"] + actor.RecentActions = []ActionProposal{accepted} + state.Actors[actor.ID] = actor + requireInvalidState(t, state) + }) +} + +func TestSessionStateBeliefClosureAndSemanticVisibility(t *testing.T) { + state := invariantTestState(FeatureBeliefConflicts) + actor := state.Actors["npc.test"] + selected := Fact{ + SubjectID: "world.door", + Predicate: "state", + Object: "open", + Confidence: 100, + SourceEventID: "event.door", + } + claim := selected + claim.Visibility = []string{} + key := selected.SubjectID + ":" + selected.Predicate + actor.Beliefs[key] = selected + actor.BeliefSets = map[string]BeliefSet{ + key: { + SubjectID: selected.SubjectID, + Predicate: selected.Predicate, + Claims: []BeliefClaim{{Fact: claim, ObservedRevision: 2}}, + SelectedSourceEventID: selected.SourceEventID, + }, + } + state.Actors[actor.ID] = actor + requireValidState(t, state) + + baseActor := actor + missingSet := state + missingSet.Actors = make(map[string]ActorState, 1) + missingActor := baseActor + missingActor.BeliefSets = map[string]BeliefSet{} + missingSet.Actors[missingActor.ID] = missingActor + requireInvalidState(t, missingSet) + + unknownVisibility := state + unknownVisibility.Actors = make(map[string]ActorState, 1) + unknownActor := baseActor + set := unknownActor.BeliefSets[key] + set.Claims = append([]BeliefClaim(nil), set.Claims...) + set.Claims[0].Fact.Visibility = []string{"npc.unknown"} + unknownActor.BeliefSets = map[string]BeliefSet{key: set} + unknownVisibility.Actors[unknownActor.ID] = unknownActor + requireInvalidState(t, unknownVisibility) +} + +func TestSessionStateGoalAndTemporalBounds(t *testing.T) { + state := invariantTestState( + FeatureOutcomeReporting, + FeatureMemoryArchive, + FeatureBeliefConflicts, + FeatureActorActivity, + ) + actor := state.Actors["npc.test"] + actor.Memories = []Memory{{ + ID: "memory.test", EventID: "event.memory", Tick: 1, + Summary: "A memory.", Importance: 1, CreatedRevision: 2, + }} + actor.MemorySummaries = []MemorySummary{ + invariantTestSummary("summary.test", "memory.old", "event.old"), + } + actor.Activity = &ActorActivity{State: "awake", UpdatedTick: 1, UpdatedRevision: 2} + fact := Fact{ + SubjectID: "world.door", Predicate: "state", Object: "open", + Confidence: 100, SourceEventID: "event.fact", ObservedTick: 1, + } + key := fact.SubjectID + ":" + fact.Predicate + actor.Beliefs[key] = fact + actor.BeliefSets = map[string]BeliefSet{ + key: { + SubjectID: fact.SubjectID, Predicate: fact.Predicate, + Claims: []BeliefClaim{{Fact: fact, ObservedRevision: 2}}, + SelectedSourceEventID: fact.SourceEventID, + }, + } + state.Actors[actor.ID] = actor + state.Receipts["request.legacy"] = RequestReceipt{Kind: "observed", Revision: 0} + requireValidState(t, state) + + tests := []struct { + name string + mutate func(*SessionState) + }{ + { + name: "memory future revision", + mutate: func(state *SessionState) { + actor := state.Actors["npc.test"] + actor.Memories[0].CreatedRevision = state.Revision + 1 + state.Actors[actor.ID] = actor + }, + }, + { + name: "summary future tick", + mutate: func(state *SessionState) { + actor := state.Actors["npc.test"] + actor.MemorySummaries[0].EndTick = state.Tick + 1 + state.Actors[actor.ID] = actor + }, + }, + { + name: "claim future revision", + mutate: func(state *SessionState) { + actor := state.Actors["npc.test"] + set := actor.BeliefSets[key] + set.Claims[0].ObservedRevision = state.Revision + 1 + actor.BeliefSets[key] = set + state.Actors[actor.ID] = actor + }, + }, + { + name: "activity future revision", + mutate: func(state *SessionState) { + actor := state.Actors["npc.test"] + activity := *actor.Activity + activity.UpdatedRevision = state.Revision + 1 + actor.Activity = &activity + state.Actors[actor.ID] = actor + }, + }, + { + name: "receipt future revision", + mutate: func(state *SessionState) { + state.Receipts["request.future"] = RequestReceipt{ + Kind: "observed", Revision: state.Revision + 1, + } + }, + }, + { + name: "automatic status disagrees with progress", + mutate: func(state *SessionState) { + actor := state.Actors["npc.test"] + actor.Goals[0].Status = "released" + state.Actors[actor.ID] = actor + }, + }, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + invalid := invariantTestState( + FeatureOutcomeReporting, + FeatureMemoryArchive, + FeatureBeliefConflicts, + FeatureActorActivity, + ) + invalidActor := invalid.Actors["npc.test"] + invalidActor.Memories = append([]Memory(nil), actor.Memories...) + invalidActor.MemorySummaries = append([]MemorySummary(nil), actor.MemorySummaries...) + invalidActor.Activity = actor.Activity + invalidActor.Beliefs = map[string]Fact{key: fact} + set := actor.BeliefSets[key] + set.Claims = append([]BeliefClaim(nil), set.Claims...) + invalidActor.BeliefSets = map[string]BeliefSet{key: set} + invalid.Actors[invalidActor.ID] = invalidActor + invalid.Receipts["request.legacy"] = RequestReceipt{Kind: "observed", Revision: 0} + testCase.mutate(&invalid) + requireInvalidState(t, invalid) + }) + } +} + +func TestSessionStateArbitrationDisabledRequiresZeroWorldBases(t *testing.T) { + state := invariantTestState() + state.WorldRevision = 1 + requireInvalidState(t, state) + + state = invariantTestState() + proposal := invariantTestProposal(state, "proposal.world", "pending") + proposal.BasedOnWorldRevision = 1 + state.Proposals[proposal.ID] = proposal + requireInvalidState(t, state) +} + +func TestSessionStateArbitrationTemporalBounds(t *testing.T) { + state := invariantTestState(FeatureArbitration) + state.Arbitrations = []ArbitrationRecord{{ + ID: "arbitration.test", RequestID: "request.arbitration", + Tick: 20, BasedOnWorldRevision: 2, CreatedRevision: 3, + Decisions: []ArbitrationDecision{{ + ProposalID: "proposal.selected", ActorID: "npc.test", + Status: "selected", Reason: "No conflicting target.", + }}, + }} + requireValidState(t, state) + + state.Arbitrations[0].CreatedRevision = state.Revision + 1 + requireInvalidState(t, state) +} + +func TestActorSeedsRequireUniqueBoundaryAndGoalIDs(t *testing.T) { + request := CreateSessionRequest{ + ProtocolVersion: Version, + RequestID: "create.unique", + SessionID: "session.unique", + Binding: invariantTestBinding(), + Actors: []ActorSeed{{ + ID: "npc.test", Kind: "npc", DisplayName: "Test", + ThinkEveryTicks: 1, Enabled: true, + Boundaries: []Boundary{ + {ID: "boundary.same", Description: "First.", Response: "refuse"}, + {ID: "boundary.same", Description: "Second.", Response: "wait"}, + }, + }}, + } + if err := ValidateCreateSession(request); err == nil { + t.Fatal("duplicate boundary ids should fail") + } + + request.Actors[0].Boundaries = nil + request.Actors[0].Goals = []Goal{ + {ID: "goal.same", Description: "First.", Priority: 1, TargetProgress: 1, Status: "active"}, + {ID: "goal.same", Description: "Second.", Priority: 2, TargetProgress: 1, Status: "active"}, + } + if err := ValidateCreateSession(request); err == nil { + t.Fatal("duplicate goal ids should fail") + } +} + +func TestCreateSessionOutcomeGoalStatusStartsCoherently(t *testing.T) { + request := CreateSessionRequest{ + ProtocolVersion: Version, + RequestID: "create.test", + SessionID: "session.test", + Binding: invariantTestBinding(), + Features: []string{FeatureOutcomeReporting}, + Actors: []ActorSeed{{ + ID: "npc.test", Kind: "npc", DisplayName: "Test", + ThinkEveryTicks: 1, Enabled: true, + Goals: []Goal{{ + ID: "goal.test", Description: "Test goal.", Priority: 1, + Progress: 1, TargetProgress: 1, Status: "active", + }}, + }}, + } + if err := ValidateCreateSession(request); err == nil { + t.Fatal("completed progress with automatic active status should fail") + } + request.Actors[0].Goals[0].Status = "completed" + if err := ValidateCreateSession(request); err != nil { + t.Fatalf("explicit initial completed status should pass: %v", err) + } +} + +func invariantTestState(features ...string) SessionState { + state := SessionState{ + ProtocolVersion: Version, + SessionID: "session.test", + Binding: invariantTestBinding(), + Features: append([]string(nil), features...), + Tick: 10, + Revision: 3, + HeadHash: invariantTestHash(), + Actors: map[string]ActorState{ + "npc.test": { + ActorSeed: ActorSeed{ + ID: "npc.test", Kind: "npc", DisplayName: "Test", + ThinkEveryTicks: 1, Enabled: true, + Boundaries: []Boundary{{ + ID: "boundary.test", Description: "Test boundary.", + TriggerTags: []string{"private"}, Response: "refuse", + }}, + Goals: []Goal{{ + ID: "goal.test", Description: "Test goal.", Priority: 1, + TargetProgress: 10, Status: "active", + }}, + }, + Beliefs: make(map[string]Fact), + }, + }, + Proposals: make(map[string]ActionProposal), + Receipts: make(map[string]RequestReceipt), + } + if HasFeature(features, FeatureArbitration) { + state.WorldRevision = 2 + } + return state +} + +func invariantTestProposal(state SessionState, id, status string) ActionProposal { + proposal := ActionProposal{ + ID: id, + SessionID: state.SessionID, + RequestID: "request." + id, + ActorID: "npc.test", + Tick: 1, + BasedOnRevision: 2, + BasedOnHeadHash: invariantTestHash(), + CreatedRevision: 3, + Action: ActionSpec{ + ID: "action.wait", Kind: "wait", Description: "Wait.", + }, + Stance: "wait", + Summary: "Wait.", + Rationale: "A deterministic test action.", + Status: status, + } + if HasFeature(state.Features, FeatureArbitration) { + proposal.BasedOnWorldRevision = state.WorldRevision + } + if status != "pending" && HasFeature(state.Features, FeatureOutcomeReporting) { + proposal.OutcomeEventID = "event.outcome." + id + proposal.OutcomeTick = 2 + } + return proposal +} + +func invariantTestCandidateGoal(id string) *Goal { + return &Goal{ + ID: id, Description: "Candidate goal.", Priority: 1, + TargetProgress: 10, Status: "active", + } +} + +func invariantTestSummary(id, sourceMemoryID, sourceEventID string) MemorySummary { + return MemorySummary{ + ID: id, Level: 1, Summary: "A compacted memory.", + SourceMemoryIDs: []string{sourceMemoryID}, + SourceEventIDs: []string{sourceEventID}, + StartTick: 1, EndTick: 2, Importance: 1, + Reason: "episodic_capacity", CreatedRevision: 2, + } +} + +func invariantTestBinding() Binding { + return Binding{ + GameID: "game.test", ContentID: "base", + ContentVersion: "1", ContentHash: "hash", + } +} + +func invariantTestHash() string { + return strings.Repeat("a", 64) +} + +func testIndex(index int) string { + const digits = "0123456789" + return string([]byte{ + digits[(index/10)%10], + digits[index%10], + }) +} + +func requireValidState(t *testing.T, state SessionState) { + t.Helper() + if err := ValidateSessionState(state); err != nil { + t.Fatalf("state should be valid: %v", err) + } +} + +func requireInvalidState(t *testing.T, state SessionState) { + t.Helper() + if err := ValidateSessionState(state); err == nil { + t.Fatal("state should be invalid") + } +} diff --git a/protocol/state_validate.go b/protocol/state_validate.go index 7693370..b0d5942 100644 --- a/protocol/state_validate.go +++ b/protocol/state_validate.go @@ -2,7 +2,6 @@ package protocol import ( "fmt" - "reflect" "regexp" ) @@ -29,16 +28,23 @@ func ValidateSessionState(state SessionState) error { if state.Revision == 0 { return &ValidationError{Field: "state.revision", Message: "must be greater than zero"} } - if HasFeature(state.Features, FeatureArbitration) && state.WorldRevision == 0 { + arbitration := HasFeature(state.Features, FeatureArbitration) + if arbitration && state.WorldRevision == 0 { return &ValidationError{Field: "state.world_revision", Message: "must be greater than zero when arbitration is enabled"} } + if !arbitration && state.WorldRevision != 0 { + return &ValidationError{Field: "state.world_revision", Message: "must be zero when arbitration is disabled"} + } if !hashPattern.MatchString(state.HeadHash) { return &ValidationError{Field: "state.head_hash", Message: "must be a lowercase SHA-256 hash"} } outcomeReporting := HasFeature(state.Features, FeatureOutcomeReporting) + beliefConflicts := HasFeature(state.Features, FeatureBeliefConflicts) if len(state.Actors) == 0 || len(state.Actors) > 128 { return &ValidationError{Field: "state.actors", Message: "must contain 1-128 actors"} } + actorMemoryIDs := make(map[string]map[string]struct{}, len(state.Actors)) + actorGoalIDs := make(map[string]map[string]struct{}, len(state.Actors)) for id, actor := range state.Actors { base := "state.actors." + id if id != actor.ID { @@ -47,7 +53,9 @@ func ValidateSessionState(state SessionState) error { if err := validateActor(base, actor.ActorSeed); err != nil { return err } + goalIDs := make(map[string]struct{}, len(actor.Goals)) for index, goal := range actor.Goals { + goalIDs[goal.ID] = struct{}{} if !outcomeReporting && (goal.UpdatedTick != 0 || goal.ProgressAccumulator != 0 || @@ -91,8 +99,33 @@ func ValidateSessionState(state SessionState) error { Message: "must be the bounded projection of progress_accumulator", } } + expectedStatus := "active" + if goal.Progress >= goal.TargetProgress { + expectedStatus = "completed" + } + if !goal.StatusExplicit && goal.Status != expectedStatus { + return &ValidationError{ + Field: fmt.Sprintf("%s.goals[%d].status", base, index), + Message: "automatic status must match bounded progress", + } + } + if goal.StatusExplicit { + if goal.StatusUpdatedTick != 0 && goal.StatusSourceEventID == "" { + return &ValidationError{ + Field: fmt.Sprintf("%s.goals[%d].status_source_event_id", base, index), + Message: "is required for a timestamped explicit status", + } + } + if goal.StatusSourceEventID == "" && goal.Status == "active" { + return &ValidationError{ + Field: fmt.Sprintf("%s.goals[%d].status", base, index), + Message: "an initial explicit status must be completed or released", + } + } + } } } + actorGoalIDs[id] = goalIDs if actor.NextThinkTick < 0 { return &ValidationError{Field: base + ".next_think_tick", Message: "must not be negative"} } @@ -110,7 +143,7 @@ func ValidateSessionState(state SessionState) error { memoryIDs := make(map[string]struct{}, len(actor.Memories)+len(actor.MemorySummaries)) for index, memory := range actor.Memories { field := fmt.Sprintf("%s.memories[%d]", base, index) - if err := validateMemory(field, memory); err != nil { + if err := validateMemory(field, memory, state.Revision, state.Tick); err != nil { return err } if _, exists := memoryIDs[memory.ID]; exists { @@ -126,7 +159,7 @@ func ValidateSessionState(state SessionState) error { } for index, summary := range actor.MemorySummaries { field := fmt.Sprintf("%s.memory_summaries[%d]", base, index) - if err := validateMemorySummary(field, summary); err != nil { + if err := validateMemorySummary(field, summary, state.Revision, state.Tick); err != nil { return err } if _, exists := memoryIDs[summary.ID]; exists { @@ -134,6 +167,7 @@ func ValidateSessionState(state SessionState) error { } memoryIDs[summary.ID] = struct{}{} } + actorMemoryIDs[id] = memoryIDs if len(actor.Beliefs) > 256 { return &ValidationError{Field: base + ".beliefs", Message: "must contain at most 256 values"} } @@ -151,6 +185,9 @@ func ValidateSessionState(state SessionState) error { if !outcomeReporting && fact.ObservedTick != 0 { return &ValidationError{Field: field + ".observed_tick", Message: "requires outcome-reporting-v1"} } + if fact.SourceEventID == "" { + return &ValidationError{Field: field + ".source_event_id", Message: "is required"} + } for _, visibleActor := range fact.Visibility { if _, exists := state.Actors[visibleActor]; !exists { return &ValidationError{Field: field + ".visibility", Message: "references an unknown actor"} @@ -160,12 +197,12 @@ func ValidateSessionState(state SessionState) error { if len(actor.BeliefSets) > 256 { return &ValidationError{Field: base + ".belief_sets", Message: "must contain at most 256 values"} } - if len(actor.BeliefSets) > 0 && !HasFeature(state.Features, FeatureBeliefConflicts) { + if len(actor.BeliefSets) > 0 && !beliefConflicts { return &ValidationError{Field: base + ".belief_sets", Message: "requires belief-conflicts-v1"} } for key, set := range actor.BeliefSets { field := base + ".belief_sets." + key - if err := validateBeliefSet(field, key, set, state.Revision, state.Tick, outcomeReporting); err != nil { + if err := validateBeliefSet(field, key, set, state, outcomeReporting); err != nil { return err } selected, exists := actor.Beliefs[key] @@ -174,7 +211,8 @@ func ValidateSessionState(state SessionState) error { } matched := false for _, claim := range set.Claims { - if claim.Fact.SourceEventID == set.SelectedSourceEventID && reflect.DeepEqual(claim.Fact, selected) { + if claim.Fact.SourceEventID == set.SelectedSourceEventID && + factsEquivalent(claim.Fact, selected) { matched = true } } @@ -182,21 +220,45 @@ func ValidateSessionState(state SessionState) error { return &ValidationError{Field: field + ".selected_source_event_id", Message: "must select the projected belief"} } } + if beliefConflicts { + for key := range actor.Beliefs { + if _, exists := actor.BeliefSets[key]; !exists { + return &ValidationError{ + Field: base + ".beliefs." + key, + Message: "must have a belief set when belief-conflicts-v1 is enabled", + } + } + } + } if len(actor.RecentActions) > 32 { return &ValidationError{Field: base + ".recent_actions", Message: "must contain at most 32 values"} } + recentActionIDs := make(map[string]struct{}, len(actor.RecentActions)) + recentOutcomeIDs := make(map[string]struct{}, len(actor.RecentActions)) for index, proposal := range actor.RecentActions { - if err := validateProposal(fmt.Sprintf("%s.recent_actions[%d]", base, index), state, actor, proposal, memoryIDs); err != nil { + field := fmt.Sprintf("%s.recent_actions[%d]", base, index) + if err := validateProposal(field, state, actor, proposal, memoryIDs); err != nil { return err } if proposal.Status != "accepted" { - return &ValidationError{Field: fmt.Sprintf("%s.recent_actions[%d].status", base, index), Message: "must be accepted"} + return &ValidationError{Field: field + ".status", Message: "must be accepted"} + } + if _, exists := recentActionIDs[proposal.ID]; exists { + return &ValidationError{Field: base + ".recent_actions", Message: "proposal ids must be unique"} + } + recentActionIDs[proposal.ID] = struct{}{} + if proposal.OutcomeEventID != "" { + if _, exists := recentOutcomeIDs[proposal.OutcomeEventID]; exists { + return &ValidationError{Field: base + ".recent_actions", Message: "outcome event ids must be unique"} + } + recentOutcomeIDs[proposal.OutcomeEventID] = struct{}{} } } } if len(state.Proposals) > 64 { return &ValidationError{Field: "state.proposals", Message: "must contain at most 64 values"} } + pendingGoalReservations := make(map[string]map[string]struct{}, len(state.Actors)) for id, proposal := range state.Proposals { if id != proposal.ID { return &ValidationError{Field: "state.proposals." + id, Message: "map key must match proposal id"} @@ -205,15 +267,38 @@ func ValidateSessionState(state SessionState) error { if !exists { return &ValidationError{Field: "state.proposals." + id + ".actor_id", Message: "references an unknown actor"} } - memoryIDs := make(map[string]struct{}, len(actor.Memories)+len(actor.MemorySummaries)) - for _, memory := range actor.Memories { - memoryIDs[memory.ID] = struct{}{} + if err := validateProposal("state.proposals."+id, state, actor, proposal, actorMemoryIDs[proposal.ActorID]); err != nil { + return err } - for _, summary := range actor.MemorySummaries { - memoryIDs[summary.ID] = struct{}{} + if proposal.Status != "pending" || proposal.ProposedGoal == nil { + continue } - if err := validateProposal("state.proposals."+id, state, actor, proposal, memoryIDs); err != nil { - return err + goalID := proposal.ProposedGoal.ID + if _, exists := actorGoalIDs[proposal.ActorID][goalID]; exists { + return &ValidationError{ + Field: "state.proposals." + id + ".proposed_goal.id", + Message: "is already part of actor goals", + } + } + reservations := pendingGoalReservations[proposal.ActorID] + if reservations == nil { + reservations = make(map[string]struct{}) + pendingGoalReservations[proposal.ActorID] = reservations + } + if _, exists := reservations[goalID]; exists { + return &ValidationError{ + Field: "state.proposals." + id + ".proposed_goal.id", + Message: "is already reserved by another pending proposal", + } + } + reservations[goalID] = struct{}{} + } + for actorID, reservations := range pendingGoalReservations { + if len(actorGoalIDs[actorID])+len(reservations) > 32 { + return &ValidationError{ + Field: "state.actors." + actorID + ".goals", + Message: "goals and pending goal reservations must contain at most 32 values", + } } } if len(state.Arbitrations) > 32 { @@ -255,11 +340,14 @@ func ValidateSessionState(state SessionState) error { Message: "must be a lowercase SHA-256 digest", } } + if receipt.Revision > state.Revision { + return &ValidationError{Field: field + ".revision", Message: "must not exceed the session revision"} + } } return nil } -func validateMemory(field string, memory Memory) error { +func validateMemory(field string, memory Memory, stateRevision uint64, stateTick int64) error { if err := validateID(field+".id", memory.ID); err != nil { return err } @@ -269,6 +357,15 @@ func validateMemory(field string, memory Memory) error { if memory.Tick < 0 || memory.LastRecalledTick < 0 || memory.RecallCount < 0 || memory.RecallCount > 1_000_000 { return &ValidationError{Field: field, Message: "tick and recall values must not be negative"} } + if memory.Tick > stateTick { + return &ValidationError{Field: field + ".tick", Message: "must not exceed the session tick"} + } + if memory.LastRecalledTick > stateTick { + return &ValidationError{Field: field + ".last_recalled_tick", Message: "must not exceed the session tick"} + } + if memory.CreatedRevision == 0 || memory.CreatedRevision > stateRevision { + return &ValidationError{Field: field + ".created_revision", Message: "must reference an existing session revision"} + } if err := validateText(field+".summary", memory.Summary, 1000, true); err != nil { return err } @@ -284,7 +381,7 @@ func validateMemory(field string, memory Memory) error { return nil } -func validateMemorySummary(field string, summary MemorySummary) error { +func validateMemorySummary(field string, summary MemorySummary, stateRevision uint64, stateTick int64) error { if err := validateID(field+".id", summary.ID); err != nil { return err } @@ -309,14 +406,23 @@ func validateMemorySummary(field string, summary MemorySummary) error { if summary.StartTick < 0 || summary.EndTick < summary.StartTick || summary.LastRecalledTick < 0 { return &ValidationError{Field: field, Message: "contains an invalid tick range"} } + if summary.EndTick > stateTick { + return &ValidationError{Field: field + ".end_tick", Message: "must not exceed the session tick"} + } + if summary.LastRecalledTick > stateTick { + return &ValidationError{Field: field + ".last_recalled_tick", Message: "must not exceed the session tick"} + } if summary.Importance < 1 || summary.Importance > 5 { return &ValidationError{Field: field + ".importance", Message: "must be between 1 and 5"} } if err := validateID(field+".reason", summary.Reason); err != nil { return err } - if summary.CreatedRevision == 0 || summary.RecallCount < 0 || summary.RecallCount > 1_000_000 { - return &ValidationError{Field: field, Message: "contains invalid revision or recall values"} + if summary.CreatedRevision == 0 || summary.CreatedRevision > stateRevision { + return &ValidationError{Field: field + ".created_revision", Message: "must reference an existing session revision"} + } + if summary.RecallCount < 0 || summary.RecallCount > 1_000_000 { + return &ValidationError{Field: field + ".recall_count", Message: "must be between 0 and 1000000"} } return nil } @@ -325,8 +431,7 @@ func validateBeliefSet( field string, key string, set BeliefSet, - stateRevision uint64, - stateTick int64, + state SessionState, outcomeReporting bool, ) error { if err := validateID(field+".subject_id", set.SubjectID); err != nil { @@ -352,7 +457,7 @@ func validateBeliefSet( if err := validateFact(claimField+".fact", claim.Fact); err != nil { return err } - if claim.Fact.ObservedTick > stateTick { + if claim.Fact.ObservedTick > state.Tick { return &ValidationError{Field: claimField + ".fact.observed_tick", Message: "must not exceed the session tick"} } if !outcomeReporting && claim.Fact.ObservedTick != 0 { @@ -364,13 +469,18 @@ func validateBeliefSet( if claim.Fact.SourceEventID == "" { return &ValidationError{Field: claimField + ".fact.source_event_id", Message: "is required"} } + for _, visibleActor := range claim.Fact.Visibility { + if _, exists := state.Actors[visibleActor]; !exists { + return &ValidationError{Field: claimField + ".fact.visibility", Message: "references an unknown actor"} + } + } if _, exists := sources[claim.Fact.SourceEventID]; exists { return &ValidationError{Field: field + ".claims", Message: "source event ids must be unique"} } sources[claim.Fact.SourceEventID] = struct{}{} objects[claim.Fact.Object] = struct{}{} - if claim.ObservedRevision == 0 { - return &ValidationError{Field: claimField + ".observed_revision", Message: "must be greater than zero"} + if claim.ObservedRevision == 0 || claim.ObservedRevision > state.Revision { + return &ValidationError{Field: claimField + ".observed_revision", Message: "must reference an existing session revision"} } selectedExists = selectedExists || claim.Fact.SourceEventID == set.SelectedSourceEventID } @@ -383,6 +493,26 @@ func validateBeliefSet( return nil } +// factsEquivalent compares the JSON contract fields while treating nil and +// empty omitempty slices as the same projected value. +func factsEquivalent(left Fact, right Fact) bool { + if left.SubjectID != right.SubjectID || + left.Predicate != right.Predicate || + left.Object != right.Object || + left.Confidence != right.Confidence || + left.SourceEventID != right.SourceEventID || + left.ObservedTick != right.ObservedTick || + len(left.Visibility) != len(right.Visibility) { + return false + } + for index := range left.Visibility { + if left.Visibility[index] != right.Visibility[index] { + return false + } + } + return true +} + func validateActorActivity(field string, activity ActorActivity, state SessionState) error { if activity.RegionID != "" { if err := validateID(field+".region_id", activity.RegionID); err != nil { @@ -398,8 +528,8 @@ func validateActorActivity(field string, activity ActorActivity, state SessionSt if activity.UpdatedTick < 0 || activity.UpdatedTick > state.Tick { return &ValidationError{Field: field + ".updated_tick", Message: "must reference the current timeline"} } - if activity.UpdatedRevision == 0 { - return &ValidationError{Field: field + ".updated_revision", Message: "must be greater than zero"} + if activity.UpdatedRevision == 0 || activity.UpdatedRevision > state.Revision { + return &ValidationError{Field: field + ".updated_revision", Message: "must reference an existing session revision"} } return nil } @@ -417,13 +547,15 @@ func validateArbitrationRecord(field string, record ArbitrationRecord, state Ses if record.BasedOnWorldRevision == 0 || record.BasedOnWorldRevision > state.WorldRevision { return &ValidationError{Field: field + ".based_on_world_revision", Message: "must reference an existing world revision"} } - if record.CreatedRevision == 0 { - return &ValidationError{Field: field + ".created_revision", Message: "must be greater than zero"} + if record.CreatedRevision == 0 || record.CreatedRevision > state.Revision { + return &ValidationError{Field: field + ".created_revision", Message: "must reference an existing session revision"} } if len(record.Decisions) == 0 || len(record.Decisions) > 64 { return &ValidationError{Field: field + ".decisions", Message: "must contain 1-64 decisions"} } proposalIDs := make(map[string]struct{}, len(record.Decisions)) + actorIDs := make(map[string]struct{}, len(record.Decisions)) + statuses := make(map[string]string, len(record.Decisions)) for index, decision := range record.Decisions { base := fmt.Sprintf("%s.decisions[%d]", field, index) if err := validateID(base+".proposal_id", decision.ProposalID); err != nil { @@ -448,6 +580,41 @@ func validateArbitrationRecord(field string, record ArbitrationRecord, state Ses return &ValidationError{Field: field + ".decisions", Message: "proposal ids must be unique"} } proposalIDs[decision.ProposalID] = struct{}{} + if _, exists := actorIDs[decision.ActorID]; exists { + return &ValidationError{Field: field + ".decisions", Message: "actor ids must be unique"} + } + actorIDs[decision.ActorID] = struct{}{} + statuses[decision.ProposalID] = decision.Status + if decision.Status == "selected" && len(decision.ConflictingProposalIDs) != 0 { + return &ValidationError{Field: base + ".conflicting_proposal_ids", Message: "selected decisions cannot have conflicts"} + } + if decision.Status == "deferred" && len(decision.ConflictingProposalIDs) == 0 { + return &ValidationError{Field: base + ".conflicting_proposal_ids", Message: "deferred decisions must identify a conflict"} + } + } + for index, decision := range record.Decisions { + base := fmt.Sprintf("%s.decisions[%d].conflicting_proposal_ids", field, index) + for conflictIndex, proposalID := range decision.ConflictingProposalIDs { + if proposalID == decision.ProposalID { + return &ValidationError{ + Field: fmt.Sprintf("%s[%d]", base, conflictIndex), + Message: "cannot reference the decision itself", + } + } + status, exists := statuses[proposalID] + if !exists { + return &ValidationError{ + Field: fmt.Sprintf("%s[%d]", base, conflictIndex), + Message: "references an unknown decision", + } + } + if status != "selected" { + return &ValidationError{ + Field: fmt.Sprintf("%s[%d]", base, conflictIndex), + Message: "must reference a selected decision", + } + } + } } return nil } @@ -466,6 +633,27 @@ func validateProposal(field string, state SessionState, actor ActorState, propos if proposal.Tick < 0 { return &ValidationError{Field: field + ".tick", Message: "must not be negative"} } + if proposal.CreatedRevision == 0 || proposal.CreatedRevision > state.Revision { + return &ValidationError{Field: field + ".created_revision", Message: "must reference an existing session revision"} + } + if proposal.BasedOnRevision == 0 { + if proposal.CreatedRevision != 1 || proposal.BasedOnHeadHash != "" { + return &ValidationError{ + Field: field + ".based_on_revision", + Message: "zero is only valid for a fresh restore generation", + } + } + } else { + if proposal.BasedOnRevision >= proposal.CreatedRevision { + return &ValidationError{ + Field: field + ".based_on_revision", + Message: "must precede the proposal creation revision", + } + } + if !hashPattern.MatchString(proposal.BasedOnHeadHash) { + return &ValidationError{Field: field + ".based_on_head_hash", Message: "must be a lowercase SHA-256 hash"} + } + } outcomeReporting := HasFeature(state.Features, FeatureOutcomeReporting) if !outcomeReporting && (proposal.OutcomeEventID != "" || proposal.OutcomeTick != 0) { return &ValidationError{Field: field, Message: "outcome occurrence metadata requires outcome-reporting-v1"} @@ -488,13 +676,12 @@ func validateProposal(field string, state SessionState, actor ActorState, propos return &ValidationError{Field: field + ".outcome_tick", Message: "must be between proposal tick and session tick"} } } - if !hashPattern.MatchString(proposal.BasedOnHeadHash) { - return &ValidationError{Field: field + ".based_on_head_hash", Message: "must be a lowercase SHA-256 hash"} - } if HasFeature(state.Features, FeatureArbitration) { if proposal.BasedOnWorldRevision == 0 || proposal.BasedOnWorldRevision > state.WorldRevision { return &ValidationError{Field: field + ".based_on_world_revision", Message: "must reference an existing world revision"} } + } else if proposal.BasedOnWorldRevision != 0 { + return &ValidationError{Field: field + ".based_on_world_revision", Message: "must be zero when arbitration is disabled"} } if err := validateAction(field+".action", proposal.Action); err != nil { return err @@ -546,6 +733,12 @@ func validateProposal(field string, state SessionState, actor ActorState, propos proposal.ProposedGoal.StatusSourceEventID != "" { return &ValidationError{Field: field + ".proposed_goal", Message: "must match an active zero-progress goal_id without state metadata"} } + if proposal.Status == "accepted" && !found { + return &ValidationError{ + Field: field + ".proposed_goal.id", + Message: "accepted proposed goal must be present in actor goals", + } + } found = true } if !found { diff --git a/protocol/validate.go b/protocol/validate.go index 3763e8d..7c6645e 100644 --- a/protocol/validate.go +++ b/protocol/validate.go @@ -151,11 +151,16 @@ func validateActor(field string, actor ActorSeed) error { if len(actor.Boundaries) > 24 { return &ValidationError{Field: field + ".boundaries", Message: "must contain at most 24 values"} } + boundaryIDs := make(map[string]struct{}, len(actor.Boundaries)) for index, boundary := range actor.Boundaries { base := fmt.Sprintf("%s.boundaries[%d]", field, index) if err := validateID(base+".id", boundary.ID); err != nil { return err } + if _, exists := boundaryIDs[boundary.ID]; exists { + return &ValidationError{Field: field + ".boundaries", Message: "boundary ids must be unique"} + } + boundaryIDs[boundary.ID] = struct{}{} if err := validateText(base+".description", boundary.Description, 300, true); err != nil { return err } @@ -169,10 +174,15 @@ func validateActor(field string, actor ActorSeed) error { if len(actor.Goals) > 32 { return &ValidationError{Field: field + ".goals", Message: "must contain at most 32 values"} } + goalIDs := make(map[string]struct{}, len(actor.Goals)) for index, goal := range actor.Goals { if err := validateGoal(fmt.Sprintf("%s.goals[%d]", field, index), goal); err != nil { return err } + if _, exists := goalIDs[goal.ID]; exists { + return &ValidationError{Field: field + ".goals", Message: "goal ids must be unique"} + } + goalIDs[goal.ID] = struct{}{} } if len(actor.Metadata) > 32 { return &ValidationError{Field: field + ".metadata", Message: "must contain at most 32 values"} @@ -207,6 +217,7 @@ func ValidateCreateSession(request CreateSessionRequest) error { if len(request.Actors) == 0 || len(request.Actors) > 128 { return &ValidationError{Field: "actors", Message: "must contain 1-128 actors"} } + outcomeReporting := HasFeature(request.Features, FeatureOutcomeReporting) seen := make(map[string]struct{}, len(request.Actors)) for index, actor := range request.Actors { if err := validateActor(fmt.Sprintf("actors[%d]", index), actor); err != nil { @@ -223,6 +234,12 @@ func ValidateCreateSession(request CreateSessionRequest) error { Message: "server-owned occurrence metadata must be zero when creating a session", } } + if outcomeReporting && goal.Status == "active" && goal.Progress >= goal.TargetProgress { + return &ValidationError{ + Field: fmt.Sprintf("actors[%d].goals[%d].status", index, goalIndex), + Message: "active status must match initial progress when outcome-reporting-v1 is enabled", + } + } } if _, exists := seen[actor.ID]; exists { return &ValidationError{Field: "actors", Message: "actor ids must be unique"} diff --git a/runtime/engine.go b/runtime/engine.go index ad6c22a..ba993eb 100644 --- a/runtime/engine.go +++ b/runtime/engine.go @@ -84,7 +84,7 @@ func (e *Engine) CreateSession(request protocol.CreateSessionRequest) (protocol. payload := createdPayload{Request: request} event, err := newEvent(protocol.SessionState{}, EventSessionCreated, request.RequestID, payload, e.now()) if err != nil { - return protocol.MutationResult{}, NewError("event_encode_failed", "could not encode session event", err) + return protocol.MutationResult{}, eventEncodeError(err, "could not encode session event") } state, err := e.createAndConfirm(request.SessionID, event) if err != nil { @@ -110,6 +110,9 @@ func (e *Engine) Observe(request protocol.ObserveRequest) (protocol.MutationResu } return protocol.MutationResult{}, requestConflict(request.RequestID) } + if err := worldRevisionAdvanceError(session.state); err != nil { + return protocol.MutationResult{}, err + } if !protocol.HasFeature(session.state.Features, protocol.FeatureOutcomeReporting) && request.Tick < session.state.Tick { return protocol.MutationResult{}, NewFieldError("tick_regressed", "observation tick is older than session state", "tick", ErrConflict) @@ -119,12 +122,15 @@ func (e *Engine) Observe(request protocol.ObserveRequest) (protocol.MutationResu return protocol.MutationResult{}, NewFieldError("unknown_actor", "observer is not registered", "observer_ids", ErrNotFound) } } + if err := validateFactVisibility(session.state, request.Facts, "facts"); err != nil { + return protocol.MutationResult{}, err + } if eventIDExists(session.state, request.EventID) { return protocol.MutationResult{}, NewFieldError("event_exists", "event id was already observed", "event_id", ErrConflict) } event, err := newEvent(session.state, EventObserved, request.RequestID, observedPayload{Request: request}, e.now()) if err != nil { - return protocol.MutationResult{}, NewError("event_encode_failed", "could not encode observation", err) + return protocol.MutationResult{}, eventEncodeError(err, "could not encode observation") } if err := e.appendAndApply(session, event); err != nil { return protocol.MutationResult{}, err @@ -139,7 +145,15 @@ func (e *Engine) Propose(ctx context.Context, request protocol.ProposeRequest) ( if err := protocol.ValidatePropose(request); err != nil { return protocol.ActionProposal{}, false, validationError(err) } - requestHash, err := hashJSON(request) + requestSnapshot, err := clone(request) + if err != nil { + return protocol.ActionProposal{}, false, NewError( + "request_copy_failed", + "could not prepare an isolated policy request", + err, + ) + } + requestHash, err := hashJSON(requestSnapshot) if err != nil { return protocol.ActionProposal{}, false, NewError( "request_encode_failed", @@ -207,11 +221,20 @@ func (e *Engine) Propose(ctx context.Context, request protocol.ProposeRequest) ( session.mu.Unlock() return protocol.ActionProposal{}, false, NewFieldError("feature_not_enabled", "candidate goals require goal-candidates-v1", "candidate_goals", ErrConflict) } - for index, goal := range request.CandidateGoals { + for index, goal := range requestSnapshot.CandidateGoals { if goalExists(actor, goal.ID) { session.mu.Unlock() return protocol.ActionProposal{}, false, NewFieldError("goal_exists", "candidate goal is already part of actor state", fmt.Sprintf("candidate_goals[%d].id", index), ErrConflict) } + if pendingProposedGoalReserved(session.state, actor.ID, goal.ID, "") { + session.mu.Unlock() + return protocol.ActionProposal{}, false, NewFieldError( + "goal_exists", + "candidate goal is already reserved by a pending proposal", + fmt.Sprintf("candidate_goals[%d].id", index), + ErrConflict, + ) + } } if !canRetainAnotherProposal(session.state) { session.mu.Unlock() @@ -233,18 +256,46 @@ func (e *Engine) Propose(ctx context.Context, request protocol.ProposeRequest) ( session.mu.Unlock() return protocol.ActionProposal{}, false, NewError("actor_not_due", "actor is not scheduled to think yet", ErrNotDue) } + if session.state.Revision == ^uint64(0) { + session.mu.Unlock() + return protocol.ActionProposal{}, false, NewFieldError( + "revision_overflow", + "session revision is exhausted", + "revision", + ErrConflict, + ) + } + validationActor, err := clone(actor) + if err != nil { + session.mu.Unlock() + return protocol.ActionProposal{}, false, NewError( + "state_copy_failed", + "could not prepare an isolated actor validation context", + err, + ) + } stateCopy, err := clone(session.state) if err != nil { session.mu.Unlock() return protocol.ActionProposal{}, false, NewError("state_copy_failed", "could not prepare policy context", err) } + policyActor, exists := stateCopy.Actors[request.ActorID] + if !exists { + session.mu.Unlock() + return protocol.ActionProposal{}, false, NewFieldError("unknown_actor", "actor is not registered", "actor_id", ErrNotFound) + } + policyRequest, err := clone(requestSnapshot) + if err != nil { + session.mu.Unlock() + return protocol.ActionProposal{}, false, NewError("request_copy_failed", "could not prepare policy context", err) + } baseRevision := session.state.Revision baseHash := session.state.HeadHash baseWorldRevision := session.state.WorldRevision arbitrationEnabled := protocol.HasFeature(session.state.Features, protocol.FeatureArbitration) session.mu.Unlock() - draft, err := e.policy.Propose(ctx, PolicyContext{State: stateCopy, Actor: actor, Request: request}) + draft, err := e.policy.Propose(ctx, PolicyContext{State: stateCopy, Actor: policyActor, Request: policyRequest}) if err != nil { if errors.Is(err, ErrNoSafeAction) { return protocol.ActionProposal{}, false, NewError("no_safe_action", "no candidate action satisfies the actor boundary", err) @@ -254,7 +305,7 @@ func (e *Engine) Propose(ctx context.Context, request protocol.ProposeRequest) ( if err := ctx.Err(); err != nil { return protocol.ActionProposal{}, false, NewError("proposal_canceled", "proposal request was canceled", err) } - selected, proposedGoal, err := validateDraft(request, actor, draft) + selected, proposedGoal, err := validateDraft(requestSnapshot, validationActor, draft) if err != nil { return protocol.ActionProposal{}, false, err } @@ -311,6 +362,17 @@ func (e *Engine) Propose(ctx context.Context, request protocol.ProposeRequest) ( ErrConflict, ) } + if proposedGoal != nil { + if err := validateProposedGoalReservation( + session.state, + request.ActorID, + proposedGoal.ID, + "", + "proposed_goal.id", + ); err != nil { + return protocol.ActionProposal{}, false, err + } + } proposalHash, err := hashJSON(struct { SessionID string `json:"session_id"` RequestID string `json:"request_id"` @@ -348,7 +410,7 @@ func (e *Engine) Propose(ctx context.Context, request protocol.ProposeRequest) ( e.now(), ) if err != nil { - return protocol.ActionProposal{}, false, NewError("event_encode_failed", "could not encode proposal", err) + return protocol.ActionProposal{}, false, eventEncodeError(err, "could not encode proposal") } if err := e.appendAndApply(session, event); err != nil { if ErrorCode(err) == "proposal_outcome_unknown" { @@ -381,6 +443,9 @@ func (e *Engine) Commit(request protocol.CommitRequest) (protocol.MutationResult } return protocol.MutationResult{}, requestConflict(request.RequestID) } + if err := worldRevisionAdvanceError(session.state); err != nil { + return protocol.MutationResult{}, err + } proposal, exists := session.state.Proposals[request.ProposalID] if !exists { return protocol.MutationResult{}, NewFieldError("unknown_proposal", "proposal is not retained", "proposal_id", ErrNotFound) @@ -428,6 +493,9 @@ func (e *Engine) Commit(request protocol.CommitRequest) (protocol.MutationResult ) } } + if err := validateFactVisibility(session.state, request.Facts, "facts"); err != nil { + return protocol.MutationResult{}, err + } if eventIDExists(session.state, request.EventID) { return protocol.MutationResult{}, NewFieldError("event_exists", "event id was already observed or reported", "event_id", ErrConflict) } @@ -435,6 +503,17 @@ func (e *Engine) Commit(request protocol.CommitRequest) (protocol.MutationResult if request.Accepted && request.Tick > maxInt64-actor.ThinkEveryTicks { return protocol.MutationResult{}, NewFieldError("tick_overflow", "commit tick cannot be scheduled safely", "tick", ErrConflict) } + if request.Accepted && proposal.ProposedGoal != nil { + if err := validateProposedGoalReservation( + session.state, + proposal.ActorID, + proposal.ProposedGoal.ID, + proposal.ID, + "proposal_id", + ); err != nil { + return protocol.MutationResult{}, err + } + } for index, update := range request.GoalUpdates { if !goalExists(actor, update.GoalID) && (proposal.ProposedGoal == nil || proposal.ProposedGoal.ID != update.GoalID) { return protocol.MutationResult{}, NewFieldError("unknown_goal", "goal update references an unknown goal", fmt.Sprintf("goal_updates[%d].goal_id", index), ErrNotFound) @@ -442,7 +521,7 @@ func (e *Engine) Commit(request protocol.CommitRequest) (protocol.MutationResult } event, err := newEvent(session.state, EventCommitted, request.RequestID, committedPayload{Request: request}, e.now()) if err != nil { - return protocol.MutationResult{}, NewError("event_encode_failed", "could not encode action commit", err) + return protocol.MutationResult{}, eventEncodeError(err, "could not encode action commit") } if err := e.appendAndApply(session, event); err != nil { return protocol.MutationResult{}, err @@ -469,6 +548,9 @@ func (e *Engine) CommitBatch(request protocol.BatchCommitRequest) (protocol.Muta } return protocol.MutationResult{}, requestConflict(request.RequestID) } + if err := worldRevisionAdvanceError(session.state); err != nil { + return protocol.MutationResult{}, err + } outcomeReporting := protocol.HasFeature(session.state.Features, protocol.FeatureOutcomeReporting) if !outcomeReporting && request.Tick < session.state.Tick { return protocol.MutationResult{}, NewFieldError("tick_regressed", "batch commit tick is older than session state", "tick", ErrConflict) @@ -518,6 +600,13 @@ func (e *Engine) CommitBatch(request protocol.BatchCommitRequest) (protocol.Muta ) } } + if err := validateFactVisibility( + session.state, + item.Facts, + fmt.Sprintf("items[%d].facts", index), + ); err != nil { + return protocol.MutationResult{}, err + } if _, duplicate := actors[proposal.ActorID]; duplicate { return protocol.MutationResult{}, NewFieldError("duplicate_actor", "batch may contain at most one proposal per actor", "items", ErrConflict) } @@ -533,6 +622,17 @@ func (e *Engine) CommitBatch(request protocol.BatchCommitRequest) (protocol.Muta if item.Accepted && request.Tick > maxInt64-actor.ThinkEveryTicks { return protocol.MutationResult{}, NewFieldError("tick_overflow", "batch commit tick cannot be scheduled safely", "tick", ErrConflict) } + if item.Accepted && proposal.ProposedGoal != nil { + if err := validateProposedGoalReservation( + session.state, + proposal.ActorID, + proposal.ProposedGoal.ID, + proposal.ID, + fmt.Sprintf("items[%d].proposal_id", index), + ); err != nil { + return protocol.MutationResult{}, err + } + } for goalIndex, update := range item.GoalUpdates { if !goalExists(actor, update.GoalID) && (proposal.ProposedGoal == nil || proposal.ProposedGoal.ID != update.GoalID) { return protocol.MutationResult{}, NewFieldError("unknown_goal", "goal update references an unknown goal", fmt.Sprintf("items[%d].goal_updates[%d].goal_id", index, goalIndex), ErrNotFound) @@ -541,7 +641,7 @@ func (e *Engine) CommitBatch(request protocol.BatchCommitRequest) (protocol.Muta } event, err := newEvent(session.state, EventBatchCommitted, request.RequestID, batchCommittedPayload{Request: request}, e.now()) if err != nil { - return protocol.MutationResult{}, NewError("event_encode_failed", "could not encode batch commit", err) + return protocol.MutationResult{}, eventEncodeError(err, "could not encode batch commit") } if err := e.appendAndApply(session, event); err != nil { return protocol.MutationResult{}, err @@ -568,6 +668,9 @@ func (e *Engine) SetActorActivity(request protocol.SetActorActivityRequest) (pro } return protocol.MutationResult{}, requestConflict(request.RequestID) } + if err := worldRevisionAdvanceError(session.state); err != nil { + return protocol.MutationResult{}, err + } if request.Tick < session.state.Tick { return protocol.MutationResult{}, NewFieldError("tick_regressed", "activity tick is older than session state", "tick", ErrConflict) } @@ -578,7 +681,7 @@ func (e *Engine) SetActorActivity(request protocol.SetActorActivityRequest) (pro } event, err := newEvent(session.state, EventActivityUpdated, request.RequestID, activityUpdatedPayload{Request: request}, e.now()) if err != nil { - return protocol.MutationResult{}, NewError("event_encode_failed", "could not encode actor activity", err) + return protocol.MutationResult{}, eventEncodeError(err, "could not encode actor activity") } if err := e.appendAndApply(session, event); err != nil { return protocol.MutationResult{}, err @@ -648,7 +751,7 @@ func (e *Engine) Arbitrate(request protocol.ArbitrateRequest) (protocol.Arbitrat } event, err := newEvent(session.state, EventArbitrated, request.RequestID, arbitratedPayload{Record: record}, e.now()) if err != nil { - return protocol.ArbitrationRecord{}, false, NewError("event_encode_failed", "could not encode arbitration", err) + return protocol.ArbitrationRecord{}, false, eventEncodeError(err, "could not encode arbitration") } if err := e.appendAndApply(session, event); err != nil { return protocol.ArbitrationRecord{}, false, err @@ -706,7 +809,7 @@ func (e *Engine) Restore(request protocol.RestoreRequest) (protocol.MutationResu event, err := newEvent(protocol.SessionState{}, EventSessionRestored, request.RequestID, restoredPayload{Snapshot: request.Snapshot}, e.now()) if err != nil { e.mu.Unlock() - return protocol.MutationResult{}, NewError("event_encode_failed", "could not encode restore", err) + return protocol.MutationResult{}, eventEncodeError(err, "could not encode restore") } state, err := e.createAndConfirm(request.SessionID, event) if err != nil { @@ -732,7 +835,7 @@ func (e *Engine) Restore(request protocol.RestoreRequest) (protocol.MutationResu } event, err := newEvent(session.state, EventSessionRestored, request.RequestID, restoredPayload{Snapshot: request.Snapshot}, e.now()) if err != nil { - return protocol.MutationResult{}, NewError("event_encode_failed", "could not encode restore", err) + return protocol.MutationResult{}, eventEncodeError(err, "could not encode restore") } if err := e.appendAndApply(session, event); err != nil { return protocol.MutationResult{}, err @@ -1100,7 +1203,17 @@ func eventIDExists(state protocol.SessionState, eventID string) bool { return true } } + for _, receipt := range state.Receipts { + if receipt.Kind == EventObserved && receipt.EntityID == eventID { + return true + } + } for _, actor := range state.Actors { + for _, goal := range actor.Goals { + if goal.StatusSourceEventID == eventID { + return true + } + } for _, memory := range actor.Memories { if memory.EventID == eventID { return true @@ -1111,10 +1224,56 @@ func eventIDExists(state protocol.SessionState, eventID string) bool { return true } } + for _, proposal := range actor.RecentActions { + if proposal.OutcomeEventID == eventID { + return true + } + } + for _, fact := range actor.Beliefs { + if fact.SourceEventID == eventID { + return true + } + } + for _, set := range actor.BeliefSets { + for _, claim := range set.Claims { + if claim.Fact.SourceEventID == eventID { + return true + } + } + } } return false } +func validateFactVisibility(state protocol.SessionState, facts []protocol.Fact, field string) error { + for factIndex, fact := range facts { + for actorIndex, actorID := range fact.Visibility { + if _, exists := state.Actors[actorID]; !exists { + return NewFieldError( + "unknown_actor", + "fact visibility references an unregistered actor", + fmt.Sprintf("%s[%d].visibility[%d]", field, factIndex, actorIndex), + ErrNotFound, + ) + } + } + } + return nil +} + +func worldRevisionAdvanceError(state protocol.SessionState) error { + if protocol.HasFeature(state.Features, protocol.FeatureArbitration) && + state.WorldRevision == ^uint64(0) { + return NewFieldError( + "world_revision_overflow", + "world revision is exhausted", + "world_revision", + ErrConflict, + ) + } + return nil +} + func goalExists(actor protocol.ActorState, goalID string) bool { for _, goal := range actor.Goals { if goal.ID == goalID { @@ -1124,6 +1283,64 @@ func goalExists(actor protocol.ActorState, goalID string) bool { return false } +func pendingProposedGoalReserved( + state protocol.SessionState, + actorID string, + goalID string, + excludedProposalID string, +) bool { + for proposalID, proposal := range state.Proposals { + if proposalID == excludedProposalID || + proposal.ActorID != actorID || + proposal.Status != "pending" || + proposal.ProposedGoal == nil { + continue + } + if proposal.ProposedGoal.ID == goalID { + return true + } + } + return false +} + +func validateProposedGoalReservation( + state protocol.SessionState, + actorID string, + goalID string, + excludedProposalID string, + field string, +) error { + actor, exists := state.Actors[actorID] + if !exists { + return NewFieldError("unknown_actor", "proposal actor is not registered", field, ErrNotFound) + } + if goalExists(actor, goalID) { + return NewFieldError("goal_exists", "proposed goal is already part of actor state", field, ErrConflict) + } + reservedIDs := make(map[string]struct{}) + for proposalID, proposal := range state.Proposals { + if proposalID == excludedProposalID || + proposal.ActorID != actorID || + proposal.Status != "pending" || + proposal.ProposedGoal == nil { + continue + } + if proposal.ProposedGoal.ID == goalID { + return NewFieldError("goal_exists", "proposed goal is already reserved by a pending proposal", field, ErrConflict) + } + reservedIDs[proposal.ProposedGoal.ID] = struct{}{} + } + if len(actor.Goals)+len(reservedIDs)+1 > 32 { + return NewFieldError( + "goal_capacity", + "actor cannot retain more than 32 goals including pending goal reservations", + field, + ErrConflict, + ) + } + return nil +} + func canRetainAnotherProposal(state protocol.SessionState) bool { if len(state.Proposals) < maxProposals { return true @@ -1136,6 +1353,13 @@ func canRetainAnotherProposal(state protocol.SessionState) bool { return false } +func eventEncodeError(err error, message string) error { + if ErrorCode(err) == "revision_overflow" { + return err + } + return NewError("event_encode_failed", message, err) +} + func validateDraft(request protocol.ProposeRequest, actor protocol.ActorState, draft ProposalDraft) (protocol.ActionSpec, *protocol.Goal, error) { var selected protocol.ActionSpec found := false diff --git a/runtime/engine_state_invariants_test.go b/runtime/engine_state_invariants_test.go new file mode 100644 index 0000000..de450d4 --- /dev/null +++ b/runtime/engine_state_invariants_test.go @@ -0,0 +1,951 @@ +package runtime + +import ( + "context" + "errors" + "fmt" + "reflect" + goruntime "runtime" + "sort" + "sync" + "testing" + "time" + + "github.com/sunrioa/rin/protocol" +) + +type invariantStore struct { + mu sync.Mutex + events map[string][]protocol.EventRecord + appendCalls int + saveCalls int +} + +func newInvariantStore() *invariantStore { + return &invariantStore{events: make(map[string][]protocol.EventRecord)} +} + +func (s *invariantStore) Create(sessionID string, event protocol.EventRecord) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.events[sessionID]; exists { + return ErrConflict + } + event.Data = append([]byte(nil), event.Data...) + s.events[sessionID] = []protocol.EventRecord{event} + return nil +} + +func (s *invariantStore) Append(sessionID string, event protocol.EventRecord) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.events[sessionID]; !exists { + return ErrNotFound + } + s.appendCalls++ + event.Data = append([]byte(nil), event.Data...) + s.events[sessionID] = append(s.events[sessionID], event) + return nil +} + +func (s *invariantStore) Load(sessionID string) ([]protocol.EventRecord, error) { + s.mu.Lock() + defer s.mu.Unlock() + events, exists := s.events[sessionID] + if !exists { + return nil, ErrNotFound + } + result := make([]protocol.EventRecord, len(events)) + for index, event := range events { + event.Data = append([]byte(nil), event.Data...) + result[index] = event + } + return result, nil +} + +func (s *invariantStore) ListSessions() ([]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + ids := make([]string, 0, len(s.events)) + for id := range s.events { + ids = append(ids, id) + } + sort.Strings(ids) + return ids, nil +} + +func (s *invariantStore) SaveSnapshot(string, protocol.Snapshot) error { + s.mu.Lock() + defer s.mu.Unlock() + s.saveCalls++ + return nil +} + +func (s *invariantStore) counts() (int, int) { + s.mu.Lock() + defer s.mu.Unlock() + return s.appendCalls, s.saveCalls +} + +type invariantPolicy struct { + propose func(context.Context, PolicyContext) (ProposalDraft, error) +} + +func (p invariantPolicy) Propose(ctx context.Context, input PolicyContext) (ProposalDraft, error) { + if p.propose != nil { + return p.propose(ctx, input) + } + draft := ProposalDraft{ + ActionID: input.Request.CandidateActions[0].ID, + Stance: "wait", + Summary: "Wait and observe.", + Rationale: "The test policy selects a deterministic candidate.", + PolicySource: "test", + } + if len(input.Request.CandidateGoals) > 0 { + draft.GoalID = input.Request.CandidateGoals[0].ID + } + return draft, nil +} + +func invariantCreate(sessionID string, features []string, goals []protocol.Goal) protocol.CreateSessionRequest { + return protocol.CreateSessionRequest{ + ProtocolVersion: protocol.Version, + RequestID: "create." + sessionID, + SessionID: sessionID, + Binding: protocol.Binding{ + GameID: "game.invariants", + ContentID: "content.invariants", + ContentVersion: "1", + ContentHash: "sha256-invariants", + }, + Features: append([]string(nil), features...), + Actors: []protocol.ActorSeed{{ + ID: "npc.mira", + Kind: "npc", + DisplayName: "Mira", + Metadata: map[string]string{"origin": "live"}, + Goals: append([]protocol.Goal(nil), goals...), + ThinkEveryTicks: 5, + Enabled: true, + }}, + } +} + +func invariantEngine( + t *testing.T, + sessionID string, + features []string, + goals []protocol.Goal, + selectedPolicy Policy, +) (*Engine, *invariantStore) { + t.Helper() + eventStore := newInvariantStore() + engine, err := Open(eventStore, selectedPolicy) + if err != nil { + t.Fatal(err) + } + if _, err := engine.CreateSession(invariantCreate(sessionID, features, goals)); err != nil { + t.Fatal(err) + } + return engine, eventStore +} + +func invariantObserve(sessionID, requestID, eventID string, tick int64) protocol.ObserveRequest { + return protocol.ObserveRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: requestID, + EventID: eventID, + Tick: tick, + ObserverIDs: []string{"npc.mira"}, + Source: "game", + Kind: "test", + Summary: "An invariant test observation.", + Importance: 1, + } +} + +func invariantPropose(sessionID, requestID string, candidateGoals []protocol.Goal) protocol.ProposeRequest { + return protocol.ProposeRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: requestID, + ActorID: "npc.mira", + Tick: 0, + Intent: "Choose a test action.", + CandidateActions: []protocol.ActionSpec{{ + ID: "wait", + Kind: "wait", + Description: "Wait for more information.", + Parameters: map[string]string{"duration": "short"}, + }}, + CandidateGoals: append([]protocol.Goal(nil), candidateGoals...), + } +} + +func invariantGoal(id string) protocol.Goal { + return protocol.Goal{ + ID: id, + Description: "A bounded invariant-test goal.", + Priority: 1, + TargetProgress: 10, + Status: "active", + } +} + +func TestSnapshotOfValidatesBeforeHashingAndSaving(t *testing.T) { + const sessionID = "session.snapshot-invariants" + engine, eventStore := invariantEngine(t, sessionID, nil, nil, invariantPolicy{}) + state, err := engine.State(protocol.SessionRequest{ProtocolVersion: protocol.Version, SessionID: sessionID}) + if err != nil { + t.Fatal(err) + } + snapshot, err := SnapshotOf(state) + if err != nil { + t.Fatal(err) + } + if err := ValidateSnapshot(snapshot); err != nil { + t.Fatalf("successful SnapshotOf result must validate: %v", err) + } + + session := engine.sessions[sessionID] + session.mu.Lock() + session.state.Tick = -1 + session.mu.Unlock() + if _, err := engine.Snapshot(protocol.SessionRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + }); ErrorCode(err) != "snapshot_failed" { + t.Fatalf("invalid live state should fail snapshot creation, got %v", err) + } + _, saveCalls := eventStore.counts() + if saveCalls != 0 { + t.Fatalf("invalid snapshot reached Store.SaveSnapshot %d times", saveCalls) + } +} + +func TestRevisionOverflowDoesNotWrapOrAppend(t *testing.T) { + const sessionID = "session.revision-overflow" + engine, eventStore := invariantEngine(t, sessionID, nil, nil, invariantPolicy{}) + session := engine.sessions[sessionID] + session.mu.Lock() + session.state.Revision = ^uint64(0) + overflowState := session.state + session.mu.Unlock() + + request := invariantObserve(sessionID, "observe.overflow", "event.overflow", 0) + if _, err := engine.Observe(request); ErrorCode(err) != "revision_overflow" || + !errors.Is(err, ErrConflict) { + t.Fatalf("revision overflow should be explicit, got %v", err) + } + appendCalls, _ := eventStore.counts() + if appendCalls != 0 { + t.Fatalf("revision overflow reached Store.Append %d times", appendCalls) + } + if _, err := newEvent(overflowState, EventObserved, request.RequestID, observedPayload{Request: request}, time.Now()); ErrorCode(err) != "revision_overflow" { + t.Fatalf("newEvent should reject an exhausted revision, got %v", err) + } + if err := verifyEvent(overflowState, protocol.EventRecord{}); !errors.Is(err, ErrCorruptLog) { + t.Fatalf("verifyEvent should reject a successor after MaxUint64, got %v", err) + } +} + +func TestRevisionOverflowSkipsProposalPolicy(t *testing.T) { + const sessionID = "session.proposal-revision-overflow" + policyCalled := false + selectedPolicy := invariantPolicy{propose: func(context.Context, PolicyContext) (ProposalDraft, error) { + policyCalled = true + return ProposalDraft{}, errors.New("policy must not be called") + }} + engine, eventStore := invariantEngine(t, sessionID, nil, nil, selectedPolicy) + session := engine.sessions[sessionID] + session.mu.Lock() + session.state.Revision = ^uint64(0) + session.mu.Unlock() + + if _, _, err := engine.Propose( + context.Background(), + invariantPropose(sessionID, "propose.revision-overflow", nil), + ); ErrorCode(err) != "revision_overflow" || ErrorField(err) != "revision" { + t.Fatalf("proposal revision overflow should be explicit, got %v", err) + } + if policyCalled { + t.Fatal("revision exhaustion called the external policy") + } + appendCalls, _ := eventStore.counts() + if appendCalls != 0 { + t.Fatalf("proposal revision overflow reached Store.Append %d times", appendCalls) + } +} + +func TestWorldRevisionOverflowIsExplicitBeforeAppend(t *testing.T) { + assertOverflow := func( + t *testing.T, + engine *Engine, + eventStore *invariantStore, + sessionID string, + mutate func() error, + ) { + t.Helper() + session := engine.sessions[sessionID] + session.mu.Lock() + session.state.WorldRevision = ^uint64(0) + session.mu.Unlock() + before := mustEngineState(t, engine, sessionID) + beforeAppends, _ := eventStore.counts() + + err := mutate() + if ErrorCode(err) != "world_revision_overflow" || + ErrorField(err) != "world_revision" || + !errors.Is(err, ErrConflict) { + t.Fatalf("world revision overflow should be explicit, got %v", err) + } + afterAppends, _ := eventStore.counts() + if afterAppends != beforeAppends { + t.Fatalf("world revision overflow changed append count from %d to %d", beforeAppends, afterAppends) + } + after := mustEngineState(t, engine, sessionID) + if !reflect.DeepEqual(after, before) { + t.Fatal("world revision overflow changed live state") + } + } + + t.Run("observe", func(t *testing.T) { + const sessionID = "session.world-overflow-observe" + engine, eventStore := invariantEngine( + t, + sessionID, + []string{protocol.FeatureArbitration}, + nil, + invariantPolicy{}, + ) + assertOverflow(t, engine, eventStore, sessionID, func() error { + _, err := engine.Observe(invariantObserve( + sessionID, + "observe.world-overflow", + "event.world-overflow", + 0, + )) + return err + }) + }) + + t.Run("commit", func(t *testing.T) { + const sessionID = "session.world-overflow-commit" + engine, eventStore := invariantEngine( + t, + sessionID, + []string{protocol.FeatureArbitration, protocol.FeatureOutcomeReporting}, + nil, + invariantPolicy{}, + ) + proposal, _, err := engine.Propose( + context.Background(), + invariantPropose(sessionID, "propose.world-overflow", nil), + ) + if err != nil { + t.Fatal(err) + } + assertOverflow(t, engine, eventStore, sessionID, func() error { + _, err := engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "commit.world-overflow", + ProposalID: proposal.ID, + EventID: "event.world-overflow", + Accepted: true, + Outcome: "The action cannot advance an exhausted world revision.", + }) + return err + }) + }) + + t.Run("batch", func(t *testing.T) { + const sessionID = "session.world-overflow-batch" + engine, eventStore := invariantEngine( + t, + sessionID, + []string{protocol.FeatureArbitration, protocol.FeatureOutcomeReporting}, + nil, + invariantPolicy{}, + ) + proposal, _, err := engine.Propose( + context.Background(), + invariantPropose(sessionID, "propose.world-overflow", nil), + ) + if err != nil { + t.Fatal(err) + } + assertOverflow(t, engine, eventStore, sessionID, func() error { + _, err := engine.CommitBatch(protocol.BatchCommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "batch.world-overflow", + Items: []protocol.CommitItem{{ + ProposalID: proposal.ID, + EventID: "event.world-overflow", + Accepted: true, + Outcome: "The batch cannot advance an exhausted world revision.", + }}, + }) + return err + }) + }) + + t.Run("activity", func(t *testing.T) { + const sessionID = "session.world-overflow-activity" + engine, eventStore := invariantEngine( + t, + sessionID, + []string{protocol.FeatureArbitration, protocol.FeatureActorActivity}, + nil, + invariantPolicy{}, + ) + assertOverflow(t, engine, eventStore, sessionID, func() error { + _, err := engine.SetActorActivity(protocol.SetActorActivityRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "activity.world-overflow", + Updates: []protocol.ActorActivityUpdate{{ + ActorID: "npc.mira", + State: "awake", + }}, + }) + return err + }) + }) +} + +func TestPolicyContextMutationCannotReachLiveStateOrCaller(t *testing.T) { + const sessionID = "session.policy-isolation" + var actorCameFromStateCopy bool + injected := errors.New("injected policy failure") + selectedPolicy := invariantPolicy{propose: func(_ context.Context, input PolicyContext) (ProposalDraft, error) { + input.Actor.Metadata["policy"] = "mutated" + input.Actor.Goals[0].Description = "mutated through actor context" + actorCameFromStateCopy = input.State.Actors[input.Actor.ID].Metadata["policy"] == "mutated" + stateActor := input.State.Actors[input.Actor.ID] + stateActor.DisplayName = "Mutated State" + input.State.Actors[input.Actor.ID] = stateActor + input.Request.Tags[0] = "mutated" + input.Request.CandidateActions[0].Parameters["duration"] = "mutated" + return ProposalDraft{}, injected + }} + engine, _ := invariantEngine( + t, + sessionID, + nil, + []protocol.Goal{invariantGoal("goal.existing")}, + selectedPolicy, + ) + baseline, err := engine.State(protocol.SessionRequest{ProtocolVersion: protocol.Version, SessionID: sessionID}) + if err != nil { + t.Fatal(err) + } + request := invariantPropose(sessionID, "propose.isolation", nil) + request.Tags = []string{"original"} + originalRequest, err := clone(request) + if err != nil { + t.Fatal(err) + } + + if _, _, err := engine.Propose(context.Background(), request); ErrorCode(err) != "policy_failed" || + !errors.Is(err, injected) { + t.Fatalf("injected policy failure was not preserved: %v", err) + } + if !actorCameFromStateCopy { + t.Fatal("policy Actor did not share the isolated State actor backing data") + } + after, err := engine.State(protocol.SessionRequest{ProtocolVersion: protocol.Version, SessionID: sessionID}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(after, baseline) { + t.Fatalf("policy mutation escaped into live state:\nbefore=%+v\nafter=%+v", baseline, after) + } + if !reflect.DeepEqual(request, originalRequest) { + t.Fatalf("policy mutation escaped into caller request:\nbefore=%+v\nafter=%+v", originalRequest, request) + } +} + +func TestPolicyContextMutationIsRaceIsolated(t *testing.T) { + const sessionID = "session.policy-race-isolation" + started := make(chan struct{}) + release := make(chan struct{}) + selectedPolicy := invariantPolicy{propose: func(_ context.Context, input PolicyContext) (ProposalDraft, error) { + close(started) + <-release + for index := 0; index < 2_000; index++ { + input.Actor.Goals[0].Description = fmt.Sprintf("policy mutation %d", index) + input.Request.CandidateActions[0].Description = fmt.Sprintf("request mutation %d", index) + goruntime.Gosched() + } + return ProposalDraft{ + ActionID: input.Request.CandidateActions[0].ID, + GoalID: "goal.existing", + Stance: "wait", + Summary: "Wait after the concurrent update.", + Rationale: "Exercise successful draft validation against an isolated actor generation.", + PolicySource: "test", + }, nil + }} + engine, _ := invariantEngine( + t, + sessionID, + nil, + []protocol.Goal{invariantGoal("goal.existing")}, + selectedPolicy, + ) + request := invariantPropose(sessionID, "propose.race-isolation", nil) + originalDescription := request.CandidateActions[0].Description + result := make(chan error, 1) + go func() { + _, _, err := engine.Propose(context.Background(), request) + result <- err + }() + <-started + if _, err := engine.Observe(invariantObserve( + sessionID, + "observe.concurrent-policy", + "event.concurrent-policy", + 0, + )); err != nil { + t.Fatal(err) + } + close(release) + for index := 0; index < 200; index++ { + state, err := engine.State(protocol.SessionRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + }) + if err != nil { + t.Fatal(err) + } + if state.Actors["npc.mira"].Goals[0].Description != invariantGoal("unused").Description { + t.Fatal("concurrent policy mutation escaped into live state") + } + if request.CandidateActions[0].Description != originalDescription { + t.Fatal("concurrent policy mutation escaped into caller request") + } + goruntime.Gosched() + } + if err := <-result; ErrorCode(err) != "state_changed" || !errors.Is(err, ErrStale) { + t.Fatalf("unexpected concurrent policy result: %v", err) + } +} + +func TestFactVisibilityRejectsUnknownActorsBeforeAppend(t *testing.T) { + unknownFact := protocol.Fact{ + SubjectID: "relic", Predicate: "location", Object: "tower", + Visibility: []string{"npc.mira", "npc.ghost"}, Confidence: 80, + } + + t.Run("observe", func(t *testing.T) { + const sessionID = "session.visibility-observe" + engine, eventStore := invariantEngine(t, sessionID, nil, nil, invariantPolicy{}) + request := invariantObserve(sessionID, "observe.visibility", "event.visibility", 1) + request.Facts = []protocol.Fact{unknownFact} + if _, err := engine.Observe(request); ErrorCode(err) != "unknown_actor" || + ErrorField(err) != "facts[0].visibility[1]" { + t.Fatalf("unknown visibility actor should fail precisely, got %v", err) + } + appendCalls, _ := eventStore.counts() + if appendCalls != 0 { + t.Fatalf("invalid observation reached Store.Append %d times", appendCalls) + } + }) + + t.Run("commit", func(t *testing.T) { + const sessionID = "session.visibility-commit" + engine, eventStore := invariantEngine(t, sessionID, nil, nil, invariantPolicy{}) + proposal, _, err := engine.Propose(context.Background(), invariantPropose(sessionID, "propose.visibility", nil)) + if err != nil { + t.Fatal(err) + } + before, _ := eventStore.counts() + _, err = engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "commit.visibility", + ProposalID: proposal.ID, + EventID: "event.visibility", + Accepted: true, + Outcome: "The actor waited.", + Facts: []protocol.Fact{unknownFact}, + }) + if ErrorCode(err) != "unknown_actor" || ErrorField(err) != "facts[0].visibility[1]" { + t.Fatalf("unknown commit visibility actor should fail precisely, got %v", err) + } + after, _ := eventStore.counts() + if after != before { + t.Fatalf("invalid commit changed append count from %d to %d", before, after) + } + }) + + t.Run("batch", func(t *testing.T) { + const sessionID = "session.visibility-batch" + engine, eventStore := invariantEngine( + t, + sessionID, + []string{protocol.FeatureOutcomeReporting, protocol.FeatureArbitration}, + nil, + invariantPolicy{}, + ) + proposal, _, err := engine.Propose(context.Background(), invariantPropose(sessionID, "propose.visibility", nil)) + if err != nil { + t.Fatal(err) + } + before, _ := eventStore.counts() + _, err = engine.CommitBatch(protocol.BatchCommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "batch.visibility", + Items: []protocol.CommitItem{{ + ProposalID: proposal.ID, + EventID: "event.visibility", + Accepted: true, + Outcome: "The actor waited.", + Facts: []protocol.Fact{unknownFact}, + }}, + }) + if ErrorCode(err) != "unknown_actor" || ErrorField(err) != "items[0].facts[0].visibility[1]" { + t.Fatalf("unknown batch visibility actor should fail precisely, got %v", err) + } + after, _ := eventStore.counts() + if after != before { + t.Fatalf("invalid batch changed append count from %d to %d", before, after) + } + }) +} + +func TestPendingProposedGoalsReserveActorCapacity(t *testing.T) { + const sessionID = "session.goal-reservations" + goals := make([]protocol.Goal, 31) + for index := range goals { + goals[index] = invariantGoal(fmt.Sprintf("goal.%02d", index)) + } + engine, eventStore := invariantEngine( + t, + sessionID, + []string{ + protocol.FeatureOutcomeReporting, + protocol.FeatureArbitration, + protocol.FeatureGoalCandidates, + }, + goals, + invariantPolicy{}, + ) + reservedGoal := invariantGoal("goal.reserved") + proposal, _, err := engine.Propose( + context.Background(), + invariantPropose(sessionID, "propose.reserved", []protocol.Goal{reservedGoal}), + ) + if err != nil { + t.Fatal(err) + } + before, _ := eventStore.counts() + + sameGoal := invariantPropose(sessionID, "propose.same-reservation", []protocol.Goal{reservedGoal}) + if _, _, err := engine.Propose(context.Background(), sameGoal); ErrorCode(err) != "goal_exists" { + t.Fatalf("duplicate pending goal reservation should fail, got %v", err) + } + differentGoal := invariantPropose( + sessionID, + "propose.over-capacity", + []protocol.Goal{invariantGoal("goal.over-capacity")}, + ) + if _, _, err := engine.Propose(context.Background(), differentGoal); ErrorCode(err) != "goal_capacity" { + t.Fatalf("33rd committed-or-reserved goal should fail, got %v", err) + } + after, _ := eventStore.counts() + if after != before { + t.Fatalf("rejected goal proposals changed append count from %d to %d", before, after) + } + + if _, err := engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "commit.reserved", + ProposalID: proposal.ID, + EventID: "event.reserved", + Accepted: true, + Outcome: "The reserved goal was accepted.", + }); err != nil { + t.Fatal(err) + } + state, err := engine.State(protocol.SessionRequest{ProtocolVersion: protocol.Version, SessionID: sessionID}) + if err != nil { + t.Fatal(err) + } + if got := len(state.Actors["npc.mira"].Goals); got != 32 { + t.Fatalf("accepted reserved goal count = %d, want 32", got) + } +} + +func TestCommitPathsDefendProposedGoalCapacity(t *testing.T) { + for _, batch := range []bool{false, true} { + name := "commit" + if batch { + name = "batch" + } + t.Run(name, func(t *testing.T) { + sessionID := "session.goal-defense-" + name + goals := make([]protocol.Goal, 31) + for index := range goals { + goals[index] = invariantGoal(fmt.Sprintf("goal.%02d", index)) + } + features := []string{protocol.FeatureOutcomeReporting, protocol.FeatureGoalCandidates} + if batch { + features = append(features, protocol.FeatureArbitration) + } + engine, eventStore := invariantEngine(t, sessionID, features, goals, invariantPolicy{}) + proposal, _, err := engine.Propose( + context.Background(), + invariantPropose( + sessionID, + "propose.capacity-defense", + []protocol.Goal{invariantGoal("goal.reserved")}, + ), + ) + if err != nil { + t.Fatal(err) + } + + session := engine.sessions[sessionID] + session.mu.Lock() + actor := session.state.Actors["npc.mira"] + actor.Goals = append(actor.Goals, invariantGoal("goal.injected")) + session.state.Actors[actor.ID] = actor + session.mu.Unlock() + before, _ := eventStore.counts() + + if batch { + _, err = engine.CommitBatch(protocol.BatchCommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "batch.capacity-defense", + Items: []protocol.CommitItem{{ + ProposalID: proposal.ID, + EventID: "event.capacity-defense", + Accepted: true, + Outcome: "Would exceed goal capacity.", + }}, + }) + } else { + _, err = engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "commit.capacity-defense", + ProposalID: proposal.ID, + EventID: "event.capacity-defense", + Accepted: true, + Outcome: "Would exceed goal capacity.", + }) + } + if ErrorCode(err) != "goal_capacity" { + t.Fatalf("%s should reject a stale over-capacity reservation, got %v", name, err) + } + after, _ := eventStore.counts() + if after != before { + t.Fatalf("rejected %s changed append count from %d to %d", name, before, after) + } + }) + } +} + +func TestEventIDExistsIncludesRetainedBeliefSources(t *testing.T) { + for _, conflicts := range []bool{false, true} { + name := "selected-belief" + if conflicts { + name = "nonselected-belief-claim" + } + t.Run(name, func(t *testing.T) { + sessionID := "session.event-id-" + name + features := []string{protocol.FeatureOutcomeReporting} + if conflicts { + features = append(features, protocol.FeatureBeliefConflicts) + } + engine, eventStore := invariantEngine(t, sessionID, features, nil, invariantPolicy{}) + first := invariantObserve(sessionID, "observe.1", "event.1", 1) + first.Facts = []protocol.Fact{{ + SubjectID: "relic", Predicate: "location", Object: "harbor", Confidence: 80, + }} + if _, err := engine.Observe(first); err != nil { + t.Fatal(err) + } + lastIndex := 129 + if conflicts { + second := invariantObserve(sessionID, "observe.2", "event.2", 2) + second.Facts = []protocol.Fact{{ + SubjectID: "relic", Predicate: "location", Object: "tower", Confidence: 90, + }} + if _, err := engine.Observe(second); err != nil { + t.Fatal(err) + } + lastIndex = 130 + } + start := 2 + if conflicts { + start = 3 + } + for index := start; index <= lastIndex; index++ { + if _, err := engine.Observe(invariantObserve( + sessionID, + fmt.Sprintf("observe.%d", index), + fmt.Sprintf("event.%d", index), + int64(index), + )); err != nil { + t.Fatal(err) + } + } + state, err := engine.State(protocol.SessionRequest{ProtocolVersion: protocol.Version, SessionID: sessionID}) + if err != nil { + t.Fatal(err) + } + for _, memory := range state.Actors["npc.mira"].Memories { + if memory.EventID == "event.1" { + t.Fatal("test setup did not evict event.1 from detailed memory") + } + } + before, _ := eventStore.counts() + reuse := invariantObserve(sessionID, "observe.reuse", "event.1", int64(lastIndex)) + if _, err := engine.Observe(reuse); ErrorCode(err) != "event_exists" { + t.Fatalf("retained belief source event id should remain reserved, got %v", err) + } + after, _ := eventStore.counts() + if after != before { + t.Fatalf("duplicate belief source changed append count from %d to %d", before, after) + } + }) + } +} + +func TestEventIDExistsIncludesRecentActionOutcomeAfterProposalAndMemoryEviction(t *testing.T) { + const sessionID = "session.event-id-recent-action" + engine, eventStore := invariantEngine( + t, + sessionID, + []string{protocol.FeatureOutcomeReporting, protocol.FeatureArbitration}, + nil, + invariantPolicy{}, + ) + oldest, _, err := engine.Propose( + context.Background(), + invariantPropose(sessionID, "propose.oldest", nil), + ) + if err != nil { + t.Fatal(err) + } + for index := 1; index < maxProposals; index++ { + proposal, _, err := engine.Propose( + context.Background(), + invariantPropose(sessionID, fmt.Sprintf("propose.rejected.%02d", index), nil), + ) + if err != nil { + t.Fatal(err) + } + if _, err := engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: fmt.Sprintf("commit.rejected.%02d", index), + ProposalID: proposal.ID, + EventID: fmt.Sprintf("event.rejected.%02d", index), + Accepted: false, + }); err != nil { + t.Fatal(err) + } + } + if _, err := engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "commit.oldest", + ProposalID: oldest.ID, + EventID: "event.recent-action", + Accepted: true, + Outcome: "This memory will be evicted while the recent action remains.", + }); err != nil { + t.Fatal(err) + } + for index := 1; index <= maxMemories; index++ { + if _, err := engine.Observe(invariantObserve( + sessionID, + fmt.Sprintf("observe.after-action.%03d", index), + fmt.Sprintf("event.after-action.%03d", index), + int64(index), + )); err != nil { + t.Fatal(err) + } + } + next := invariantPropose(sessionID, "propose.trim-oldest", nil) + next.Tick = maxMemories + if _, _, err := engine.Propose(context.Background(), next); err != nil { + t.Fatal(err) + } + state, err := engine.State(protocol.SessionRequest{ProtocolVersion: protocol.Version, SessionID: sessionID}) + if err != nil { + t.Fatal(err) + } + if _, retained := state.Proposals[oldest.ID]; retained { + t.Fatal("test setup did not evict the oldest resolved proposal") + } + for _, memory := range state.Actors["npc.mira"].Memories { + if memory.EventID == "event.recent-action" { + t.Fatal("test setup did not evict the recent action outcome memory") + } + } + if len(state.Actors["npc.mira"].RecentActions) != 1 || + state.Actors["npc.mira"].RecentActions[0].OutcomeEventID != "event.recent-action" { + t.Fatalf("recent action did not retain the outcome event id: %+v", state.Actors["npc.mira"].RecentActions) + } + + before, _ := eventStore.counts() + reuse := invariantObserve( + sessionID, + "observe.reuse-recent-action", + "event.recent-action", + maxMemories, + ) + if _, err := engine.Observe(reuse); ErrorCode(err) != "event_exists" { + t.Fatalf("recent action outcome event id should remain reserved, got %v", err) + } + after, _ := eventStore.counts() + if after != before { + t.Fatalf("duplicate recent action outcome changed append count from %d to %d", before, after) + } +} + +func TestEventIDExistsIncludesGoalAndObservationReceiptSources(t *testing.T) { + state := protocol.SessionState{ + Actors: map[string]protocol.ActorState{ + "npc.mira": { + ActorSeed: protocol.ActorSeed{ + ID: "npc.mira", + Goals: []protocol.Goal{{ + ID: "goal.retained-source", + StatusSourceEventID: "event.goal-source", + }}, + }, + }, + }, + Receipts: map[string]protocol.RequestReceipt{ + "observe.retained-source": { + Kind: EventObserved, + EntityID: "event.receipt-source", + }, + "commit.not-an-event-source": { + Kind: EventCommitted, + EntityID: "proposal.entity", + }, + }, + } + if !eventIDExists(state, "event.goal-source") { + t.Fatal("goal status source event id was not retained") + } + if !eventIDExists(state, "event.receipt-source") { + t.Fatal("observation receipt event id was not retained") + } + if eventIDExists(state, "proposal.entity") { + t.Fatal("non-observation receipt entity id was treated as an event id") + } +} diff --git a/runtime/engine_test.go b/runtime/engine_test.go index 5f4b6ad..be2e66c 100644 --- a/runtime/engine_test.go +++ b/runtime/engine_test.go @@ -383,12 +383,8 @@ func TestSnapshotTamperAndFreshRestore(t *testing.T) { actor := invalidState.Actors["npc.mira"] actor.Memories[0].Summary = "" invalidState.Actors["npc.mira"] = actor - invalidSnapshot, err := rinruntime.SnapshotOf(invalidState) - if err != nil { - t.Fatal(err) - } - if err := rinruntime.ValidateSnapshot(invalidSnapshot); err == nil { - t.Fatal("internally hashed but structurally invalid snapshot should be rejected") + if _, err := rinruntime.SnapshotOf(invalidState); err == nil { + t.Fatal("SnapshotOf should reject structurally invalid state before hashing") } snapshot, err = engine.Snapshot(sessionRequest("session.snapshot")) if err != nil { @@ -470,7 +466,7 @@ func TestFreshRestoreRetainsPendingProposalForSavedOutcomeOutbox(t *testing.T) { snapshot.State.Receipts[requestID] = protocol.RequestReceipt{ Kind: rinruntime.EventObserved, EntityID: fmt.Sprintf("legacy.event.%04d", index), - Revision: snapshot.State.Revision + uint64(index), + Revision: snapshot.State.Revision, } } snapshot, err = rinruntime.SnapshotOf(snapshot.State) diff --git a/runtime/hash.go b/runtime/hash.go index d01cc74..8e0b8e8 100644 --- a/runtime/hash.go +++ b/runtime/hash.go @@ -29,6 +29,14 @@ func hashJSON(value any) (string, error) { } func newEvent(state protocol.SessionState, eventType, requestID string, payload any, now time.Time) (protocol.EventRecord, error) { + if state.Revision == ^uint64(0) { + return protocol.EventRecord{}, NewFieldError( + "revision_overflow", + "session revision is exhausted", + "revision", + ErrConflict, + ) + } data, err := json.Marshal(payload) if err != nil { return protocol.EventRecord{}, err @@ -60,6 +68,9 @@ func eventHash(event protocol.EventRecord) (string, error) { } func verifyEvent(previous protocol.SessionState, event protocol.EventRecord) error { + if previous.Revision == ^uint64(0) { + return fmt.Errorf("%w: revision %d cannot be followed by another event", ErrCorruptLog, previous.Revision) + } if event.Sequence != previous.Revision+1 { return fmt.Errorf("%w: sequence %d follows revision %d", ErrCorruptLog, event.Sequence, previous.Revision) } @@ -81,6 +92,9 @@ func SnapshotOf(state protocol.SessionState) (protocol.Snapshot, error) { if err != nil { return protocol.Snapshot{}, err } + if err := protocol.ValidateSessionState(copyState); err != nil { + return protocol.Snapshot{}, fmt.Errorf("validate snapshot state: %w", err) + } hash, err := hashJSON(copyState) if err != nil { return protocol.Snapshot{}, err diff --git a/runtime/memory.go b/runtime/memory.go index 75f5af8..e64aebe 100644 --- a/runtime/memory.go +++ b/runtime/memory.go @@ -13,9 +13,19 @@ const ( maxMemorySummaries = 32 summaryMergeBatch = 4 maxSummarySources = 64 + maxMemorySummaryLevel = 16 ) -func compactActorMemories(sessionID string, actor *protocol.ActorState, revision uint64) error { +func compactActorMemories( + sessionID string, + actor *protocol.ActorState, + revision uint64, + states ...*protocol.SessionState, +) error { + var state *protocol.SessionState + if len(states) > 0 { + state = states[0] + } for len(actor.Memories) > maxMemories { window := len(actor.Memories) / 2 if window < memoryCompactionBatch { @@ -67,14 +77,25 @@ func compactActorMemories(sessionID string, actor *protocol.ActorState, revision } actor.Memories = retained actor.MemorySummaries = append(actor.MemorySummaries, summary) + replacements := make(map[string]string, len(selected)) + for _, memory := range selected { + replacements[memory.ID] = summary.ID + } + rewriteRecalledMemoryReferences(state, actor, replacements) } for len(actor.MemorySummaries) > maxMemorySummaries { sortMemorySummaries(actor.MemorySummaries) - merged, err := mergeMemorySummaries(sessionID, actor.ID, actor.MemorySummaries[:summaryMergeBatch], revision) + selected := actor.MemorySummaries[:summaryMergeBatch] + merged, err := mergeMemorySummaries(sessionID, actor.ID, selected, revision) if err != nil { return err } actor.MemorySummaries = append([]protocol.MemorySummary{merged}, actor.MemorySummaries[summaryMergeBatch:]...) + replacements := make(map[string]string, len(selected)) + for _, summary := range selected { + replacements[summary.ID] = merged.ID + } + rewriteRecalledMemoryReferences(state, actor, replacements) } sortMemorySummaries(actor.MemorySummaries) return nil @@ -96,10 +117,7 @@ func summarizeMemories(sessionID, actorID string, memories []protocol.Memory, re if memory.Importance > importance { importance = memory.Importance } - recallCount += memory.RecallCount - if recallCount > 1_000_000 { - recallCount = 1_000_000 - } + recallCount = saturatingRecallAdd(recallCount, memory.RecallCount) if memory.LastRecalledTick > lastRecalled { lastRecalled = memory.LastRecalledTick } @@ -124,29 +142,34 @@ func mergeMemorySummaries(sessionID, actorID string, summaries []protocol.Memory tags := make([]string, 0) texts := make([]string, 0, len(summaries)) identityIDs := make([]string, 0, len(summaries)) - level := 1 + maxLevel := 1 importance := 1 recallCount := 0 lastRecalled := int64(0) + endTick := int64(0) for _, summary := range summaries { identityIDs = append(identityIDs, summary.ID) sourceMemoryIDs = append(sourceMemoryIDs, summary.SourceMemoryIDs...) sourceEventIDs = append(sourceEventIDs, summary.SourceEventIDs...) tags = append(tags, summary.Tags...) texts = append(texts, summary.Summary) - if summary.Level >= level { - level = summary.Level + 1 + if summary.Level > maxLevel { + maxLevel = summary.Level } if summary.Importance > importance { importance = summary.Importance } - recallCount += summary.RecallCount - if recallCount > 1_000_000 { - recallCount = 1_000_000 - } + recallCount = saturatingRecallAdd(recallCount, summary.RecallCount) if summary.LastRecalledTick > lastRecalled { lastRecalled = summary.LastRecalledTick } + if summary.EndTick > endTick { + endTick = summary.EndTick + } + } + level := maxLevel + 1 + if maxLevel >= maxMemorySummaryLevel { + level = maxMemorySummaryLevel } id, err := memorySummaryID(sessionID, actorID, level, identityIDs) if err != nil { @@ -156,11 +179,18 @@ func mergeMemorySummaries(sessionID, actorID string, summaries []protocol.Memory ID: "summary." + id[:24], Level: level, Summary: joinSummaryText(texts), Tags: boundedUnique(tags, 32), SourceMemoryIDs: boundedUnique(sourceMemoryIDs, maxSummarySources), SourceEventIDs: boundedUnique(sourceEventIDs, maxSummarySources), StartTick: summaries[0].StartTick, - EndTick: summaries[len(summaries)-1].EndTick, Importance: importance, Reason: "archive_capacity", + EndTick: endTick, Importance: importance, Reason: "archive_capacity", CreatedRevision: revision, RecallCount: recallCount, LastRecalledTick: lastRecalled, }, nil } +func saturatingRecallAdd(total, value int) int { + if total >= maxRecallCount || value >= maxRecallCount-total { + return maxRecallCount + } + return total + value +} + func memorySummaryID(sessionID, actorID string, level int, sourceIDs []string) (string, error) { return hashJSON(struct { SessionID string `json:"session_id"` diff --git a/runtime/memory_state_invariants_test.go b/runtime/memory_state_invariants_test.go new file mode 100644 index 0000000..5a578e2 --- /dev/null +++ b/runtime/memory_state_invariants_test.go @@ -0,0 +1,647 @@ +package runtime + +import ( + "errors" + "fmt" + "reflect" + "testing" + "time" + + "github.com/sunrioa/rin/protocol" +) + +func TestMemoryEvictionRewritesActorReferences(t *testing.T) { + t.Run("archive compaction", func(t *testing.T) { + state := invariantSessionState(t, protocol.FeatureOutcomeReporting, protocol.FeatureMemoryArchive) + actor := state.Actors["npc.mira"] + for index := 0; index < maxMemories+1; index++ { + actor.Memories = append(actor.Memories, invariantMemory(index)) + } + state.Tick = maxMemories + recalled := []string{actor.Memories[0].ID, actor.Memories[1].ID} + pending := invariantProposal(state, "proposal.pending.archive", "pending", recalled) + recent := invariantProposal(state, "proposal.recent.archive", "accepted", recalled) + state.Proposals[pending.ID] = pending + actor.RecentActions = []protocol.ActionProposal{recent} + + if err := compactActorMemories(state.SessionID, &actor, state.Revision, &state); err != nil { + t.Fatal(err) + } + state.Actors[actor.ID] = actor + if err := protocol.ValidateSessionState(state); err != nil { + t.Fatalf("compacted state is invalid: %v", err) + } + if len(actor.MemorySummaries) != 1 { + t.Fatalf("summary count = %d, want 1", len(actor.MemorySummaries)) + } + summaryID := actor.MemorySummaries[0].ID + if got := state.Proposals[pending.ID].RecalledMemoryIDs; !reflect.DeepEqual(got, []string{summaryID}) { + t.Fatalf("pending proposal references = %v, want [%s]", got, summaryID) + } + if got := actor.RecentActions[0].RecalledMemoryIDs; !reflect.DeepEqual(got, []string{summaryID}) { + t.Fatalf("recent action references = %v, want [%s]", got, summaryID) + } + }) + + t.Run("archive summary merge", func(t *testing.T) { + state := invariantSessionState(t, protocol.FeatureOutcomeReporting, protocol.FeatureMemoryArchive) + actor := state.Actors["npc.mira"] + for index := 0; index < maxMemorySummaries+1; index++ { + actor.MemorySummaries = append(actor.MemorySummaries, invariantSummary(index, 1, 1)) + } + state.Tick = maxMemorySummaries + recalled := []string{actor.MemorySummaries[0].ID, actor.MemorySummaries[1].ID} + pending := invariantProposal(state, "proposal.pending.merge", "pending", recalled) + recent := invariantProposal(state, "proposal.recent.merge", "accepted", recalled) + state.Proposals[pending.ID] = pending + actor.RecentActions = []protocol.ActionProposal{recent} + + if err := compactActorMemories(state.SessionID, &actor, state.Revision, &state); err != nil { + t.Fatal(err) + } + state.Actors[actor.ID] = actor + if err := protocol.ValidateSessionState(state); err != nil { + t.Fatalf("merged state is invalid: %v", err) + } + if len(actor.MemorySummaries) != maxMemorySummaries-summaryMergeBatch+2 { + t.Fatalf("summary count = %d", len(actor.MemorySummaries)) + } + mergedID := actor.MemorySummaries[0].ID + if got := state.Proposals[pending.ID].RecalledMemoryIDs; !reflect.DeepEqual(got, []string{mergedID}) { + t.Fatalf("pending proposal references = %v, want [%s]", got, mergedID) + } + if got := actor.RecentActions[0].RecalledMemoryIDs; !reflect.DeepEqual(got, []string{mergedID}) { + t.Fatalf("recent action references = %v, want [%s]", got, mergedID) + } + }) + + t.Run("non archive eviction", func(t *testing.T) { + state := invariantSessionState(t, protocol.FeatureOutcomeReporting) + actor := state.Actors["npc.mira"] + for index := 0; index < maxMemories+1; index++ { + actor.Memories = append(actor.Memories, invariantMemory(index)) + } + state.Tick = maxMemories + recalled := []string{actor.Memories[0].ID, actor.Memories[1].ID} + pending := invariantProposal(state, "proposal.pending.evict", "pending", recalled) + recent := invariantProposal(state, "proposal.recent.evict", "accepted", recalled) + state.Proposals[pending.ID] = pending + actor.RecentActions = []protocol.ActionProposal{recent} + retainedID := actor.Memories[1].ID + + trimActorMemories(&state, &actor) + state.Actors[actor.ID] = actor + if err := protocol.ValidateSessionState(state); err != nil { + t.Fatalf("trimmed state is invalid: %v", err) + } + if got := state.Proposals[pending.ID].RecalledMemoryIDs; !reflect.DeepEqual(got, []string{retainedID}) { + t.Fatalf("pending proposal references = %v, want [%s]", got, retainedID) + } + if got := actor.RecentActions[0].RecalledMemoryIDs; !reflect.DeepEqual(got, []string{retainedID}) { + t.Fatalf("recent action references = %v, want [%s]", got, retainedID) + } + }) +} + +func TestReducerMaintainsBoundsAcross1361Observations(t *testing.T) { + state := invariantSessionState( + t, + protocol.FeatureOutcomeReporting, + protocol.FeatureMemoryArchive, + protocol.FeatureBeliefConflicts, + protocol.FeatureArbitration, + ) + for index := 0; index < 1361; index++ { + request := protocol.ObserveRequest{ + ProtocolVersion: protocol.Version, + SessionID: state.SessionID, + RequestID: fmt.Sprintf("observe.%04d", index), + EventID: fmt.Sprintf("event.%04d", index), + Tick: int64(index + 1), + ObserverIDs: []string{"npc.mira"}, + Source: "game", + Kind: "world", + Summary: "A deterministic long-run observation.", + Importance: 2, + Facts: []protocol.Fact{{ + SubjectID: fmt.Sprintf("subject.%04d", index), + Predicate: "state", + Object: "known", + Visibility: []string{"npc.mira"}, + Confidence: 80, + }}, + } + event := invariantEvent(t, state, EventObserved, request.RequestID, observedPayload{Request: request}, index+2) + var err error + state, err = applyEvent(state, event) + if err != nil { + t.Fatalf("observation %d failed: %v", index, err) + } + } + + actor := state.Actors["npc.mira"] + if len(actor.Memories) > maxMemories || len(actor.MemorySummaries) > maxMemorySummaries { + t.Fatalf("memory bounds exceeded: memories=%d summaries=%d", len(actor.Memories), len(actor.MemorySummaries)) + } + for _, summary := range actor.MemorySummaries { + if summary.Level > maxMemorySummaryLevel { + t.Fatalf("summary %s level = %d", summary.ID, summary.Level) + } + } + if len(actor.Beliefs) != maxBeliefs || len(actor.BeliefSets) != maxBeliefs { + t.Fatalf("belief bounds = %d/%d, want %d/%d", len(actor.Beliefs), len(actor.BeliefSets), maxBeliefs, maxBeliefs) + } + if _, exists := actor.Beliefs["subject.0000:state"]; exists { + t.Fatal("oldest occurrence survived deterministic capacity eviction") + } + if _, exists := actor.Beliefs["subject.1360:state"]; !exists { + t.Fatal("newest occurrence was evicted") + } + if len(state.Receipts) != maxReceipts { + t.Fatalf("receipt count = %d, want %d", len(state.Receipts), maxReceipts) + } + if state.WorldRevision != 1362 { + t.Fatalf("world revision = %d, want 1362", state.WorldRevision) + } + if err := protocol.ValidateSessionState(state); err != nil { + t.Fatalf("long-run state is invalid: %v", err) + } +} + +func TestRecallAndSummaryBoundsSaturate(t *testing.T) { + actor := protocol.ActorState{ + Memories: []protocol.Memory{{ + ID: "memory.max", RecallCount: maxRecallCount, + }}, + MemorySummaries: []protocol.MemorySummary{{ + ID: "summary.max", RecallCount: maxRecallCount, + }}, + } + markRecalled(&actor, []string{"memory.max", "summary.max"}, 9, true) + if actor.Memories[0].RecallCount != maxRecallCount || actor.MemorySummaries[0].RecallCount != maxRecallCount { + t.Fatalf("recall counts exceeded the bound: memory=%d summary=%d", + actor.Memories[0].RecallCount, + actor.MemorySummaries[0].RecallCount, + ) + } + if actor.Memories[0].LastRecalledTick != 9 || actor.MemorySummaries[0].LastRecalledTick != 9 { + t.Fatal("saturated recall count must still record the latest occurrence") + } + + summaries := make([]protocol.MemorySummary, summaryMergeBatch) + for index := range summaries { + summaries[index] = invariantSummary(index, maxMemorySummaryLevel, 300_000) + } + merged, err := mergeMemorySummaries("session.bounds", "npc.mira", summaries, 1) + if err != nil { + t.Fatal(err) + } + if merged.Level != maxMemorySummaryLevel { + t.Fatalf("merged level = %d, want %d", merged.Level, maxMemorySummaryLevel) + } + if merged.RecallCount != maxRecallCount { + t.Fatalf("merged recall count = %d, want %d", merged.RecallCount, maxRecallCount) + } +} + +func TestMergeMemorySummariesPreservesFullTickRange(t *testing.T) { + summaries := make([]protocol.MemorySummary, summaryMergeBatch) + ranges := [][2]int64{{1, 100}, {2, 20}, {3, 30}, {4, 40}} + for index := range summaries { + summaries[index] = invariantSummary(index, 1, 0) + summaries[index].StartTick = ranges[index][0] + summaries[index].EndTick = ranges[index][1] + } + merged, err := mergeMemorySummaries("session.range", "npc.mira", summaries, 1) + if err != nil { + t.Fatal(err) + } + if merged.StartTick != 1 || merged.EndTick != 100 { + t.Fatalf("merged tick range = %d..%d, want 1..100", merged.StartTick, merged.EndTick) + } +} + +func TestRestoreRebasesAllFeaturesAndKeepsReceiptWhenFull(t *testing.T) { + source := invariantSessionState(t) + actor := source.Actors["npc.mira"] + actor.Memories = []protocol.Memory{invariantMemory(0)} + actor.RecentActions = []protocol.ActionProposal{ + invariantProposal(source, "proposal.recent.restore", "accepted", []string{actor.Memories[0].ID}), + } + source.Actors[actor.ID] = actor + source.Receipts = make(map[string]protocol.RequestReceipt, maxReceipts) + for index := 0; index < maxReceipts; index++ { + source.Receipts[fmt.Sprintf("receipt.%04d", index)] = protocol.RequestReceipt{ + Kind: EventObserved, + EntityID: fmt.Sprintf("entity.%04d", index), + Revision: source.Revision, + } + } + if err := protocol.ValidateSessionState(source); err != nil { + t.Fatalf("source state is invalid: %v", err) + } + snapshot, err := SnapshotOf(source) + if err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + current protocol.SessionState + request string + revision uint64 + }{ + {name: "fresh", request: "restore.fresh.full", revision: 1}, + {name: "existing", current: source, request: "restore.existing.full", revision: 2}, + } + for caseIndex, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + event := invariantEvent( + t, + testCase.current, + EventSessionRestored, + testCase.request, + restoredPayload{Snapshot: snapshot}, + 2000+caseIndex, + ) + restored, err := applyEvent(testCase.current, event) + if err != nil { + t.Fatal(err) + } + if len(restored.Receipts) != maxReceipts { + t.Fatalf("receipt count = %d, want %d", len(restored.Receipts), maxReceipts) + } + if receipt, exists := restored.Receipts[testCase.request]; !exists || receipt.Revision != testCase.revision { + t.Fatalf("restore receipt was not retained: %+v exists=%v", receipt, exists) + } + for requestID, receipt := range restored.Receipts { + if requestID != testCase.request && receipt.Revision != 0 { + t.Fatalf("old receipt %s retained revision %d", requestID, receipt.Revision) + } + } + restoredActor := restored.Actors["npc.mira"] + if restoredActor.Memories[0].CreatedRevision != testCase.revision { + t.Fatalf("memory revision = %d, want %d", restoredActor.Memories[0].CreatedRevision, testCase.revision) + } + recent := restoredActor.RecentActions[0] + if recent.CreatedRevision != testCase.revision || + recent.BasedOnRevision != testCase.revision-1 || + recent.BasedOnHeadHash != event.PrevHash { + t.Fatalf("recent action generation was not rebased: %+v", recent) + } + }) + } +} + +func TestRestoreRebasesPendingProposalGenerationRoundTrip(t *testing.T) { + source := invariantSessionState(t, protocol.FeatureOutcomeReporting) + proposal := invariantProposal(source, "proposal.pending.restore", "pending", nil) + source.Proposals[proposal.ID] = proposal + if err := protocol.ValidateSessionState(source); err != nil { + t.Fatalf("source state is invalid: %v", err) + } + snapshot, err := SnapshotOf(source) + if err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + current protocol.SessionState + request string + revision uint64 + }{ + {name: "fresh", request: "restore.pending.fresh", revision: 1}, + {name: "existing", current: source, request: "restore.pending.existing", revision: 2}, + } + for caseIndex, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + event := invariantEvent( + t, + testCase.current, + EventSessionRestored, + testCase.request, + restoredPayload{Snapshot: snapshot}, + 2500+caseIndex, + ) + restored, err := applyEvent(testCase.current, event) + if err != nil { + t.Fatal(err) + } + pending, exists := restored.Proposals[proposal.ID] + if !exists { + t.Fatal("pending proposal was not retained") + } + if pending.CreatedRevision != testCase.revision || + pending.BasedOnRevision != testCase.revision-1 || + pending.BasedOnHeadHash != event.PrevHash { + t.Fatalf("pending proposal generation was not rebased: %+v", pending) + } + roundTrip, err := SnapshotOf(restored) + if err != nil { + t.Fatal(err) + } + if err := ValidateSnapshot(roundTrip); err != nil { + t.Fatalf("rebased proposal did not round-trip through a snapshot: %v", err) + } + }) + } +} + +func TestWorldRevisionOverflowAndRestoreSaturation(t *testing.T) { + state := invariantSessionState(t, protocol.FeatureOutcomeReporting, protocol.FeatureArbitration) + state.WorldRevision = ^uint64(0) + pending := invariantProposal(state, "proposal.pending.world-max", "pending", nil) + state.Proposals[pending.ID] = pending + snapshot, err := SnapshotOf(state) + if err != nil { + t.Fatal(err) + } + request := protocol.ObserveRequest{ + ProtocolVersion: protocol.Version, + SessionID: state.SessionID, + RequestID: "observe.world-overflow", + EventID: "event.world-overflow", + Tick: 1, + ObserverIDs: []string{"npc.mira"}, + Source: "game", + Kind: "world", + Summary: "World revision cannot wrap.", + Importance: 1, + } + event := invariantEvent(t, state, EventObserved, request.RequestID, observedPayload{Request: request}, 3000) + if _, err := applyEvent(state, event); !errors.Is(err, ErrCorruptLog) { + t.Fatalf("observation overflow error = %v, want ErrCorruptLog", err) + } + + restore := invariantEvent( + t, + protocol.SessionState{}, + EventSessionRestored, + "restore.world-overflow", + restoredPayload{Snapshot: snapshot}, + 3001, + ) + restored, err := applyEvent(protocol.SessionState{}, restore) + if err != nil { + t.Fatalf("max world snapshot must remain restorable: %v", err) + } + if restored.WorldRevision != ^uint64(0) { + t.Fatalf("restored world revision = %d, want saturation at max", restored.WorldRevision) + } + restoredPending, exists := restored.Proposals[pending.ID] + if !exists || restoredPending.BasedOnWorldRevision != ^uint64(0) { + t.Fatalf("pending proposal was not retained at the saturated world generation: %+v", restoredPending) + } + roundTrip, err := SnapshotOf(restored) + if err != nil { + t.Fatal(err) + } + if err := ValidateSnapshot(roundTrip); err != nil { + t.Fatalf("max world restore did not round-trip: %v", err) + } +} + +func TestGoalCandidateAtCapacityFailsClosed(t *testing.T) { + state := invariantSessionState(t, protocol.FeatureGoalCandidates) + actor := state.Actors["npc.mira"] + for index := 0; index < maxGoals; index++ { + actor.Goals = append(actor.Goals, protocol.Goal{ + ID: fmt.Sprintf("goal.%02d", index), + Description: "A bounded existing goal.", + Priority: 1, + TargetProgress: 1, + Status: "active", + }) + } + state.Actors[actor.ID] = actor + proposal := invariantProposal(state, "proposal.goal-overflow", "pending", nil) + proposal.GoalID = "goal.new" + proposal.ProposedGoal = &protocol.Goal{ + ID: proposal.GoalID, + Description: "This goal cannot be appended.", + Priority: 1, + TargetProgress: 1, + Status: "active", + } + state.Proposals[proposal.ID] = proposal + if err := protocol.ValidateSessionState(state); err == nil { + t.Fatal("test setup must violate the pending goal reservation invariant") + } + beforeHash, err := hashJSON(state) + if err != nil { + t.Fatal(err) + } + request := protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: state.SessionID, + RequestID: "commit.goal-overflow", + ProposalID: proposal.ID, + EventID: "event.goal-overflow", + Tick: 1, + Accepted: true, + Outcome: "The action occurred.", + } + event := invariantEvent(t, state, EventCommitted, request.RequestID, committedPayload{Request: request}, 4000) + store := &invariantSpyStore{} + engine := &Engine{store: store} + session := &managedSession{state: state} + if err := engine.appendAndApply(session, event); !errors.Is(err, ErrCorruptLog) { + t.Fatalf("goal capacity error = %v, want ErrCorruptLog", err) + } + afterHash, err := hashJSON(session.state) + if err != nil { + t.Fatal(err) + } + if store.appendCalls != 0 || afterHash != beforeHash { + t.Fatalf("failed goal append escaped isolation: appends=%d state_changed=%v", store.appendCalls, afterHash != beforeHash) + } +} + +func TestInvalidReducedStateIsNotAppendedOrPublished(t *testing.T) { + state := invariantSessionState(t) + request := protocol.ObserveRequest{ + ProtocolVersion: protocol.Version, + SessionID: state.SessionID, + RequestID: "observe.invalid-visibility", + EventID: "event.invalid-visibility", + Tick: 1, + ObserverIDs: []string{"npc.mira"}, + Source: "game", + Kind: "world", + Summary: "The fact includes an unknown visibility target.", + Importance: 1, + Facts: []protocol.Fact{{ + SubjectID: "door", + Predicate: "state", + Object: "open", + Visibility: []string{"npc.mira", "npc.unknown"}, + Confidence: 100, + }}, + } + event := invariantEvent(t, state, EventObserved, request.RequestID, observedPayload{Request: request}, 5000) + beforeHash, err := hashJSON(state) + if err != nil { + t.Fatal(err) + } + store := &invariantSpyStore{} + engine := &Engine{store: store} + session := &managedSession{state: state} + err = engine.appendAndApply(session, event) + if !errors.Is(err, ErrCorruptLog) { + t.Fatalf("appendAndApply error = %v, want ErrCorruptLog", err) + } + if store.appendCalls != 0 { + t.Fatalf("invalid state reached Store.Append %d times", store.appendCalls) + } + afterHash, err := hashJSON(session.state) + if err != nil { + t.Fatal(err) + } + if afterHash != beforeHash { + t.Fatal("invalid state was published to the managed session") + } +} + +func invariantSessionState(t *testing.T, features ...string) protocol.SessionState { + t.Helper() + request := protocol.CreateSessionRequest{ + ProtocolVersion: protocol.Version, + RequestID: "create.invariant", + SessionID: "session.invariant", + Binding: protocol.Binding{ + GameID: "game", + ContentID: "content", + ContentVersion: "1", + ContentHash: "content-hash", + }, + Features: features, + Actors: []protocol.ActorSeed{{ + ID: "npc.mira", + Kind: "npc", + DisplayName: "Mira", + ThinkEveryTicks: 5, + Enabled: true, + }}, + } + event := invariantEvent( + t, + protocol.SessionState{}, + EventSessionCreated, + request.RequestID, + createdPayload{Request: request}, + 1, + ) + state, err := applyEvent(protocol.SessionState{}, event) + if err != nil { + t.Fatal(err) + } + return state +} + +func invariantEvent( + t *testing.T, + state protocol.SessionState, + eventType string, + requestID string, + payload any, + second int, +) protocol.EventRecord { + t.Helper() + event, err := newEvent(state, eventType, requestID, payload, time.Unix(int64(second), 0)) + if err != nil { + t.Fatal(err) + } + return event +} + +func invariantMemory(index int) protocol.Memory { + return protocol.Memory{ + ID: "memory." + fixedID(index), + EventID: "event.memory." + fixedID(index), + Tick: int64(index), + Summary: "A bounded memory.", + Importance: 2, + CreatedRevision: 1, + } +} + +func invariantSummary(index, level, recallCount int) protocol.MemorySummary { + return protocol.MemorySummary{ + ID: "summary." + fixedID(index), + Level: level, + Summary: "A bounded summary.", + SourceMemoryIDs: []string{"source.memory." + fixedID(index)}, + SourceEventIDs: []string{"source.event." + fixedID(index)}, + StartTick: int64(index), + EndTick: int64(index), + Importance: 1, + Reason: "episodic_capacity", + CreatedRevision: 1, + RecallCount: recallCount, + } +} + +func invariantProposal( + state protocol.SessionState, + id string, + status string, + recalled []string, +) protocol.ActionProposal { + proposal := protocol.ActionProposal{ + ID: id, + SessionID: state.SessionID, + RequestID: "request." + id, + ActorID: "npc.mira", + BasedOnRevision: state.Revision - 1, + CreatedRevision: state.Revision, + BasedOnWorldRevision: state.WorldRevision, + Action: protocol.ActionSpec{ + ID: "action.wait", + Kind: "wait", + Description: "Wait carefully.", + }, + Stance: "wait", + Summary: "Wait carefully.", + Rationale: "A deterministic test action.", + PolicySource: "test", + RecalledMemoryIDs: append([]string(nil), recalled...), + Status: status, + } + if proposal.BasedOnRevision > 0 { + proposal.BasedOnHeadHash = state.HeadHash + } + if !protocol.HasFeature(state.Features, protocol.FeatureArbitration) { + proposal.BasedOnWorldRevision = 0 + } + if status != "pending" && protocol.HasFeature(state.Features, protocol.FeatureOutcomeReporting) { + proposal.OutcomeEventID = "outcome." + id + proposal.OutcomeTick = state.Tick + } + return proposal +} + +type invariantSpyStore struct { + appendCalls int +} + +func (store *invariantSpyStore) Create(string, protocol.EventRecord) error { + return nil +} + +func (store *invariantSpyStore) Append(string, protocol.EventRecord) error { + store.appendCalls++ + return nil +} + +func (store *invariantSpyStore) Load(string) ([]protocol.EventRecord, error) { + return nil, ErrNotFound +} + +func (store *invariantSpyStore) ListSessions() ([]string, error) { + return nil, nil +} + +func (store *invariantSpyStore) SaveSnapshot(string, protocol.Snapshot) error { + return nil +} diff --git a/runtime/reducer.go b/runtime/reducer.go index d6581e9..8f18898 100644 --- a/runtime/reducer.go +++ b/runtime/reducer.go @@ -11,6 +11,9 @@ import ( const ( maxMemories = 128 maxRecentActions = 32 + maxGoals = 32 + maxBeliefs = 256 + maxRecallCount = 1_000_000 maxProposals = 64 maxReceipts = 1024 maxArbitrations = 32 @@ -82,6 +85,14 @@ func applyEvent(state protocol.SessionState, event protocol.EventRecord) (protoc state.Revision = event.Sequence state.HeadHash = event.Hash trimReceipts(&state) + if err := protocol.ValidateSessionState(state); err != nil { + return protocol.SessionState{}, fmt.Errorf( + "%w: invalid state after %s: %v", + ErrCorruptLog, + event.Type, + err, + ) + } return state, nil } @@ -164,11 +175,11 @@ func applyObserved(state *protocol.SessionState, event protocol.EventRecord) err sortActorMemories(&actor) } if protocol.HasFeature(state.Features, protocol.FeatureMemoryArchive) { - if err := compactActorMemories(state.SessionID, &actor, event.Sequence); err != nil { + if err := compactActorMemories(state.SessionID, &actor, event.Sequence, state); err != nil { return err } } else if len(actor.Memories) > maxMemories { - actor.Memories = append([]protocol.Memory(nil), actor.Memories[len(actor.Memories)-maxMemories:]...) + trimActorMemories(state, &actor) } applyFacts( &actor, @@ -185,8 +196,7 @@ func applyObserved(state *protocol.SessionState, event protocol.EventRecord) err state.Tick = request.Tick } state.Receipts[request.RequestID] = protocol.RequestReceipt{Kind: EventObserved, EntityID: request.EventID, Revision: event.Sequence} - advanceWorldRevision(state) - return nil + return advanceWorldRevision(state) } func applyProposed(state *protocol.SessionState, event protocol.EventRecord) error { @@ -226,8 +236,7 @@ func applyCommitted(state *protocol.SessionState, event protocol.EventRecord) er state.Tick = request.Tick } state.Receipts[request.RequestID] = protocol.RequestReceipt{Kind: EventCommitted, EntityID: request.ProposalID, Revision: event.Sequence} - advanceWorldRevision(state) - return nil + return advanceWorldRevision(state) } func applyBatchCommitted(state *protocol.SessionState, event protocol.EventRecord) error { @@ -246,8 +255,7 @@ func applyBatchCommitted(state *protocol.SessionState, event protocol.EventRecor state.Receipts[payload.Request.RequestID] = protocol.RequestReceipt{ Kind: EventBatchCommitted, EntityID: payload.Request.SessionID, Revision: event.Sequence, } - advanceWorldRevision(state) - return nil + return advanceWorldRevision(state) } func applyCommitItem(state *protocol.SessionState, item protocol.CommitItem, tick int64, revision uint64) error { @@ -271,6 +279,9 @@ func applyCommitItem(state *protocol.SessionState, item protocol.CommitItem, tic } actor := state.Actors[proposal.ActorID] if proposal.ProposedGoal != nil && !goalExists(actor, proposal.ProposedGoal.ID) { + if len(actor.Goals) >= maxGoals { + return fmt.Errorf("%w: actor goal capacity exceeded", ErrCorruptLog) + } goal := *proposal.ProposedGoal if outcomeReporting { goal.UpdatedTick = tick @@ -321,11 +332,11 @@ func applyCommitItem(state *protocol.SessionState, item protocol.CommitItem, tic sortActorMemories(&actor) } if protocol.HasFeature(state.Features, protocol.FeatureMemoryArchive) { - if err := compactActorMemories(state.SessionID, &actor, revision); err != nil { + if err := compactActorMemories(state.SessionID, &actor, revision, state); err != nil { return err } } else if len(actor.Memories) > maxMemories { - actor.Memories = append([]protocol.Memory(nil), actor.Memories[len(actor.Memories)-maxMemories:]...) + trimActorMemories(state, &actor) } } applyFacts( @@ -378,8 +389,7 @@ func applyActivityUpdated(state *protocol.SessionState, event protocol.EventReco state.Receipts[payload.Request.RequestID] = protocol.RequestReceipt{ Kind: EventActivityUpdated, EntityID: payload.Request.SessionID, Revision: event.Sequence, } - advanceWorldRevision(state) - return nil + return advanceWorldRevision(state) } func applyArbitrated(state *protocol.SessionState, event protocol.EventRecord) error { @@ -429,67 +439,104 @@ func applyRestored(current protocol.SessionState, event protocol.EventRecord) (p if restored.Proposals == nil { restored.Proposals = make(map[string]protocol.ActionProposal) } - rebaseRestoredRevisions(&restored, event.Sequence) - } - if protocol.HasFeature(restored.Features, protocol.FeatureArbitration) { - advanceWorldRevision(&restored) } + advanceRestoredWorldRevision(&restored) + rebaseRestoredRevisions(&restored, event.Sequence, event.Sequence-1, event.PrevHash) if restored.Receipts == nil { restored.Receipts = make(map[string]protocol.RequestReceipt) } - if outcomeReporting { - // Receipt revisions belong to the event chain that produced the - // Snapshot. Rebase the restored generation so capacity trimming keeps - // the new restore receipt and later Outbox acknowledgements first. - for requestID, receipt := range restored.Receipts { - receipt.Revision = 0 - restored.Receipts[requestID] = receipt - } + // Receipt revisions belong to the event chain that produced the Snapshot. + // Mark imported entries as historical so capacity trimming keeps the new + // restore receipt and later acknowledgements first. + for requestID, receipt := range restored.Receipts { + receipt.Revision = 0 + restored.Receipts[requestID] = receipt } restored.Receipts[event.RequestID] = protocol.RequestReceipt{Kind: EventSessionRestored, EntityID: restored.SessionID, Revision: event.Sequence} return restored, nil } -func rebaseRestoredRevisions(state *protocol.SessionState, revision uint64) { +func rebaseRestoredRevisions( + state *protocol.SessionState, + createdRevision uint64, + basedOnRevision uint64, + basedOnHeadHash string, +) { for actorID, actor := range state.Actors { for index := range actor.Memories { - actor.Memories[index].CreatedRevision = revision + actor.Memories[index].CreatedRevision = createdRevision } for index := range actor.MemorySummaries { - actor.MemorySummaries[index].CreatedRevision = revision + actor.MemorySummaries[index].CreatedRevision = createdRevision } for key, set := range actor.BeliefSets { for index := range set.Claims { - set.Claims[index].ObservedRevision = revision + set.Claims[index].ObservedRevision = createdRevision } actor.BeliefSets[key] = set } for index := range actor.RecentActions { - actor.RecentActions[index].CreatedRevision = revision + rebaseProposal( + &actor.RecentActions[index], + state, + createdRevision, + basedOnRevision, + basedOnHeadHash, + ) } if actor.Activity != nil { activity := *actor.Activity - activity.UpdatedRevision = revision + activity.UpdatedRevision = createdRevision actor.Activity = &activity } state.Actors[actorID] = actor } for proposalID, proposal := range state.Proposals { - proposal.CreatedRevision = revision + rebaseProposal(&proposal, state, createdRevision, basedOnRevision, basedOnHeadHash) state.Proposals[proposalID] = proposal } for index := range state.Arbitrations { - state.Arbitrations[index].CreatedRevision = revision + state.Arbitrations[index].CreatedRevision = createdRevision } } -func advanceWorldRevision(state *protocol.SessionState) { +func rebaseProposal( + proposal *protocol.ActionProposal, + state *protocol.SessionState, + createdRevision uint64, + basedOnRevision uint64, + basedOnHeadHash string, +) { + proposal.BasedOnRevision = basedOnRevision + proposal.BasedOnHeadHash = basedOnHeadHash + proposal.CreatedRevision = createdRevision + if protocol.HasFeature(state.Features, protocol.FeatureArbitration) { + proposal.BasedOnWorldRevision = state.WorldRevision + } else { + proposal.BasedOnWorldRevision = 0 + } +} + +func advanceWorldRevision(state *protocol.SessionState) error { if !protocol.HasFeature(state.Features, protocol.FeatureArbitration) { - return + return nil + } + if state.WorldRevision == ^uint64(0) { + return fmt.Errorf("%w: world revision overflow", ErrCorruptLog) } state.WorldRevision++ - if state.WorldRevision == 0 { - state.WorldRevision = 1 + return nil +} + +func advanceRestoredWorldRevision(state *protocol.SessionState) { + if !protocol.HasFeature(state.Features, protocol.FeatureArbitration) { + return + } + // A successfully exported Snapshot must remain restorable even at the + // uint64 ceiling. The new event-chain generation still invalidates stale + // proposal bases; only the imported world counter saturates here. + if state.WorldRevision < ^uint64(0) { + state.WorldRevision++ } } @@ -551,6 +598,34 @@ func applyFacts( actor.BeliefSets[key] = set actor.Beliefs[key] = selected.Fact } + trimBeliefs(actor, preserveConflicts) +} + +func trimBeliefs(actor *protocol.ActorState, preserveConflicts bool) { + if len(actor.Beliefs) <= maxBeliefs { + return + } + keys := make([]string, 0, len(actor.Beliefs)) + for key := range actor.Beliefs { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + left := actor.Beliefs[keys[i]] + right := actor.Beliefs[keys[j]] + if left.ObservedTick != right.ObservedTick { + return left.ObservedTick < right.ObservedTick + } + if left.SourceEventID != right.SourceEventID { + return left.SourceEventID < right.SourceEventID + } + return keys[i] < keys[j] + }) + for _, key := range keys[:len(keys)-maxBeliefs] { + delete(actor.Beliefs, key) + if preserveConflicts { + delete(actor.BeliefSets, key) + } + } } func trimBeliefClaims(set *protocol.BeliefSet, outcomeReporting bool) { @@ -716,7 +791,9 @@ func markRecalled(actor *protocol.ActorState, ids []string, tick int64, outcomeR } for index := range actor.Memories { if _, exists := selected[actor.Memories[index].ID]; exists { - actor.Memories[index].RecallCount++ + if actor.Memories[index].RecallCount < maxRecallCount { + actor.Memories[index].RecallCount++ + } if !outcomeReporting || tick > actor.Memories[index].LastRecalledTick { actor.Memories[index].LastRecalledTick = tick } @@ -724,7 +801,9 @@ func markRecalled(actor *protocol.ActorState, ids []string, tick int64, outcomeR } for index := range actor.MemorySummaries { if _, exists := selected[actor.MemorySummaries[index].ID]; exists { - actor.MemorySummaries[index].RecallCount++ + if actor.MemorySummaries[index].RecallCount < maxRecallCount { + actor.MemorySummaries[index].RecallCount++ + } if !outcomeReporting || tick > actor.MemorySummaries[index].LastRecalledTick { actor.MemorySummaries[index].LastRecalledTick = tick } @@ -732,6 +811,70 @@ func markRecalled(actor *protocol.ActorState, ids []string, tick int64, outcomeR } } +func trimActorMemories(state *protocol.SessionState, actor *protocol.ActorState) { + if len(actor.Memories) <= maxMemories { + return + } + removedCount := len(actor.Memories) - maxMemories + replacements := make(map[string]string, removedCount) + for _, memory := range actor.Memories[:removedCount] { + replacements[memory.ID] = "" + } + actor.Memories = append([]protocol.Memory(nil), actor.Memories[removedCount:]...) + rewriteRecalledMemoryReferences(state, actor, replacements) +} + +func rewriteRecalledMemoryReferences( + state *protocol.SessionState, + actor *protocol.ActorState, + replacements map[string]string, +) { + for index := range actor.RecentActions { + actor.RecentActions[index].RecalledMemoryIDs = rewriteMemoryIDs( + actor.RecentActions[index].RecalledMemoryIDs, + replacements, + ) + } + if state == nil { + return + } + for proposalID, proposal := range state.Proposals { + if proposal.ActorID != actor.ID { + continue + } + proposal.RecalledMemoryIDs = rewriteMemoryIDs(proposal.RecalledMemoryIDs, replacements) + state.Proposals[proposalID] = proposal + } +} + +func rewriteMemoryIDs(ids []string, replacements map[string]string) []string { + if len(ids) == 0 { + return nil + } + rewritten := make([]string, 0, min(len(ids), 8)) + seen := make(map[string]struct{}, len(ids)) + for _, id := range ids { + if replacement, exists := replacements[id]; exists { + id = replacement + } + if id == "" { + continue + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + rewritten = append(rewritten, id) + if len(rewritten) == 8 { + break + } + } + if len(rewritten) == 0 { + return nil + } + return rewritten +} + func sortActorMemories(actor *protocol.ActorState) { sort.SliceStable(actor.Memories, func(i, j int) bool { if actor.Memories[i].Tick == actor.Memories[j].Tick { diff --git a/runtime/state_closure_property_test.go b/runtime/state_closure_property_test.go new file mode 100644 index 0000000..bf1dfb9 --- /dev/null +++ b/runtime/state_closure_property_test.go @@ -0,0 +1,382 @@ +package runtime + +import ( + "context" + "fmt" + "reflect" + "testing" + + "github.com/sunrioa/rin/protocol" +) + +func TestMutationStateClosureProperty(t *testing.T) { + for seed := byte(0); seed < 8; seed++ { + operations := make([]byte, 64) + value := uint32(seed) + 1 + for index := range operations { + value = value*1664525 + 1013904223 + operations[index] = byte(value >> 24) + } + t.Run(fmt.Sprintf("seed-%d", seed), func(t *testing.T) { + runMutationStateClosureSequence(t, operations) + }) + } +} + +func FuzzMutationStateClosure(f *testing.F) { + f.Add([]byte{0, 1, 2, 3, 4, 5, 6, 7}) + f.Add([]byte{1, 1, 1, 2, 2, 3, 4, 5, 6}) + f.Add([]byte{6, 5, 4, 3, 2, 1, 0}) + f.Fuzz(func(t *testing.T, operations []byte) { + if len(operations) > 64 { + operations = operations[:64] + } + runMutationStateClosureSequence(t, operations) + }) +} + +func runMutationStateClosureSequence(t *testing.T, operations []byte) { + t.Helper() + const sessionID = "session.state-closure" + engine, eventStore := invariantEngine( + t, + sessionID, + protocol.SupportedFeatures(), + nil, + invariantPolicy{}, + ) + assertEngineStateClosure(t, engine, sessionID) + + for index, operation := range operations { + suffix := fmt.Sprintf("%03d", index) + switch operation % 8 { + case 0: + before := mustEngineState(t, engine, sessionID) + tick := before.Tick + 1 + request := invariantObserve( + sessionID, + "observe.property."+suffix, + "event.observe.property."+suffix, + tick, + ) + request.Facts = []protocol.Fact{{ + SubjectID: "subject." + suffix, + Predicate: "state", + Object: fmt.Sprintf("value-%d", operation), + Visibility: []string{"npc.mira"}, + Confidence: 80, + }} + _, err := engine.Observe(request) + assertEngineTransitionClosure(t, engine, sessionID, before, err) + + case 1: + before := mustEngineState(t, engine, sessionID) + tick := before.Tick + 1 + request := invariantPropose(sessionID, "propose.commit.property."+suffix, nil) + request.Tick = tick + request.Urgent = true + proposal, _, err := engine.Propose(context.Background(), request) + assertEngineTransitionClosure(t, engine, sessionID, before, err) + if err != nil { + continue + } + before = mustEngineState(t, engine, sessionID) + _, err = engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "commit.property." + suffix, + ProposalID: proposal.ID, + EventID: "event.commit.property." + suffix, + Tick: tick, + Accepted: true, + Outcome: "The property-test action occurred.", + Facts: []protocol.Fact{{ + SubjectID: "action." + suffix, + Predicate: "outcome", + Object: "accepted", + Visibility: []string{"npc.mira"}, + Confidence: 100, + }}, + }) + assertEngineTransitionClosure(t, engine, sessionID, before, err) + + case 2: + before := mustEngineState(t, engine, sessionID) + tick := before.Tick + 1 + request := invariantPropose(sessionID, "propose.batch.property."+suffix, nil) + request.Tick = tick + request.Urgent = true + proposal, _, err := engine.Propose(context.Background(), request) + assertEngineTransitionClosure(t, engine, sessionID, before, err) + if err != nil { + continue + } + before = mustEngineState(t, engine, sessionID) + accepted := (operation>>3)&1 == 0 + outcome := "" + if accepted { + outcome = "The property-test batch action occurred." + } + _, err = engine.CommitBatch(protocol.BatchCommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "batch.property." + suffix, + Tick: tick, + Items: []protocol.CommitItem{{ + ProposalID: proposal.ID, + EventID: "event.batch.property." + suffix, + Accepted: accepted, + Outcome: outcome, + }}, + }) + assertEngineTransitionClosure(t, engine, sessionID, before, err) + + case 3: + before := mustEngineState(t, engine, sessionID) + tick := before.Tick + 1 + request := invariantPropose(sessionID, "propose.arbitrate.property."+suffix, nil) + request.Tick = tick + request.Urgent = true + proposal, _, err := engine.Propose(context.Background(), request) + assertEngineTransitionClosure(t, engine, sessionID, before, err) + if err != nil { + continue + } + before = mustEngineState(t, engine, sessionID) + _, _, err = engine.Arbitrate(protocol.ArbitrateRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "arbitrate.property." + suffix, + Tick: tick, + ProposalIDs: []string{proposal.ID}, + }) + assertEngineTransitionClosure(t, engine, sessionID, before, err) + if err != nil { + continue + } + before = mustEngineState(t, engine, sessionID) + _, err = engine.CommitBatch(protocol.BatchCommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "batch.arbitrated.property." + suffix, + Tick: tick, + Items: []protocol.CommitItem{{ + ProposalID: proposal.ID, + EventID: "event.arbitrated.property." + suffix, + Accepted: true, + Outcome: "The arbitrated property-test action occurred.", + }}, + }) + assertEngineTransitionClosure(t, engine, sessionID, before, err) + + case 4: + before := mustEngineState(t, engine, sessionID) + _, err := engine.SetActorActivity(protocol.SetActorActivityRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "activity.property." + suffix, + Tick: before.Tick + 1, + Updates: []protocol.ActorActivityUpdate{{ + ActorID: "npc.mira", + RegionID: "region.test", + State: []string{"awake", "dormant"}[int((operation>>3)&1)], + Reason: "Property-test lifecycle update.", + }}, + }) + assertEngineTransitionClosure(t, engine, sessionID, before, err) + + case 5: + before := mustEngineState(t, engine, sessionID) + snapshot, err := SnapshotOf(before) + if err != nil { + t.Fatal(err) + } + _, err = engine.Restore(protocol.RestoreRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "restore.property." + suffix, + Snapshot: snapshot, + }) + assertEngineTransitionClosure(t, engine, sessionID, before, err) + + case 6: + before := mustEngineState(t, engine, sessionID) + tick := before.Tick + 1 + request := invariantPropose(sessionID, "propose.reject.property."+suffix, nil) + request.Tick = tick + request.Urgent = true + proposal, _, err := engine.Propose(context.Background(), request) + assertEngineTransitionClosure(t, engine, sessionID, before, err) + if err != nil { + continue + } + before = mustEngineState(t, engine, sessionID) + _, err = engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "commit.reject.property." + suffix, + ProposalID: proposal.ID, + EventID: "event.reject.property." + suffix, + Tick: tick, + Accepted: false, + }) + assertEngineTransitionClosure(t, engine, sessionID, before, err) + + case 7: + before := mustEngineState(t, engine, sessionID) + beforeAppends, _ := eventStore.counts() + var err error + switch (operation >> 3) & 3 { + case 0: + request := invariantObserve( + sessionID, + "observe.invalid.property."+suffix, + "event.invalid.property."+suffix, + before.Tick, + ) + request.Facts = []protocol.Fact{{ + SubjectID: "invalid." + suffix, + Predicate: "visibility", + Object: "unknown", + Visibility: []string{"npc.unknown"}, + Confidence: 100, + }} + _, err = engine.Observe(request) + case 1: + _, err = engine.SetActorActivity(protocol.SetActorActivityRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "activity.invalid.property." + suffix, + Tick: before.Tick, + Updates: []protocol.ActorActivityUpdate{{ + ActorID: "npc.unknown", + State: "awake", + }}, + }) + case 2: + _, err = engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "commit.invalid.property." + suffix, + ProposalID: "proposal.unknown." + suffix, + EventID: "event.invalid.property." + suffix, + Tick: before.Tick, + Accepted: false, + }) + case 3: + _, err = engine.CommitBatch(protocol.BatchCommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "batch.invalid.property." + suffix, + Tick: before.Tick, + Items: []protocol.CommitItem{{ + ProposalID: "proposal.unknown." + suffix, + EventID: "event.invalid.property." + suffix, + Accepted: false, + }}, + }) + } + if err == nil { + t.Fatal("property sequence expected an invalid mutation to fail") + } + assertEngineTransitionClosure(t, engine, sessionID, before, err) + afterAppends, _ := eventStore.counts() + if afterAppends != beforeAppends { + t.Fatalf("failed mutation changed append count from %d to %d", beforeAppends, afterAppends) + } + } + } + + assertSnapshotRestoreRoundTrip(t, engine, sessionID) +} + +func assertEngineTransitionClosure( + t *testing.T, + engine *Engine, + sessionID string, + before protocol.SessionState, + mutationErr error, +) { + t.Helper() + after := mustEngineState(t, engine, sessionID) + if mutationErr != nil && !reflect.DeepEqual(after, before) { + t.Fatalf("failed mutation changed state: %v", mutationErr) + } + assertStateAndSnapshotClosure(t, after) +} + +func assertEngineStateClosure(t *testing.T, engine *Engine, sessionID string) { + t.Helper() + assertStateAndSnapshotClosure(t, mustEngineState(t, engine, sessionID)) +} + +func assertStateAndSnapshotClosure(t *testing.T, state protocol.SessionState) { + t.Helper() + if err := protocol.ValidateSessionState(state); err != nil { + t.Fatalf("successful mutation produced invalid state: %v", err) + } + snapshot, err := SnapshotOf(state) + if err != nil { + t.Fatalf("valid state could not be snapshotted: %v", err) + } + if err := ValidateSnapshot(snapshot); err != nil { + t.Fatalf("SnapshotOf result did not validate: %v", err) + } +} + +func mustEngineState(t *testing.T, engine *Engine, sessionID string) protocol.SessionState { + t.Helper() + state, err := engine.State(protocol.SessionRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + }) + if err != nil { + t.Fatal(err) + } + return state +} + +func assertSnapshotRestoreRoundTrip(t *testing.T, source *Engine, sessionID string) { + t.Helper() + sourceState := mustEngineState(t, source, sessionID) + snapshot, err := SnapshotOf(sourceState) + if err != nil { + t.Fatal(err) + } + + fresh, err := Open(newInvariantStore(), invariantPolicy{}) + if err != nil { + t.Fatal(err) + } + if _, err := fresh.Restore(protocol.RestoreRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "restore.fresh.roundtrip", + Snapshot: snapshot, + }); err != nil { + t.Fatalf("fresh restore failed: %v", err) + } + assertEngineStateClosure(t, fresh, sessionID) + + restoredSnapshot, err := fresh.Snapshot(protocol.SessionRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + }) + if err != nil { + t.Fatalf("snapshot after fresh restore failed: %v", err) + } + if err := ValidateSnapshot(restoredSnapshot); err != nil { + t.Fatalf("snapshot after fresh restore is invalid: %v", err) + } + before := mustEngineState(t, fresh, sessionID) + _, err = fresh.Restore(protocol.RestoreRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "restore.existing.roundtrip", + Snapshot: restoredSnapshot, + }) + assertEngineTransitionClosure(t, fresh, sessionID, before, err) + if err != nil { + t.Fatalf("existing restore failed: %v", err) + } +}