# NEO-135 — E7M3-03: Faction standing store + reputation delta audit store **Linear:** [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135) **Branch:** `NEO-135-e7m3-faction-standing-reputation-stores` **Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-03** **Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md) **Pattern:** [NEO-116-implementation-plan.md](NEO-116-implementation-plan.md) (in-memory + Postgres when configured); [NEO-126](NEO-126-implementation-plan.md) (append-only audit/event store shape) **Precursor:** [NEO-134](https://linear.app/neon-sprawl/issue/NEO-134) **`Done`** — `IFactionDefinitionRegistry`, fail-fast faction catalog (**landed on `main`**) **Blocks:** [NEO-136](https://linear.app/neon-sprawl/issue/NEO-136) (ReputationOperations), [NEO-137](https://linear.app/neon-sprawl/issue/NEO-137) (gate eval reads standing), [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) (HTTP read) ## Goal Durable per-player **`FactionStanding`** snapshot and append-only **`ReputationDelta`** audit log. Stores validate faction ids fail-closed at the boundary; missing standing reads as **0** (neutral) clamped to faction min/max. ## Kickoff clarifications **No clarifications needed.** Linear AC, [E7M3-03 backlog scope](E7M3-pre-production-backlog.md#e7m3-03--faction-standing-store--reputation-delta-audit-store), kickoff decisions table, and NEO-116/NEO-134 precedents settle: | Topic | Decision | Evidence | |-------|----------|----------| | Postgres persistence | **Include when configured** — in-memory fallback otherwise | Backlog “NEO-116-style split”; NEO-116 adopted same | | Store split | **`IFactionStandingStore`** + **`IReputationDeltaStore`** | Backlog in-scope list; audit append-only separate from snapshot | | Public apply API | **Out of scope** — store-level mutation primitives only | Backlog defers **`ReputationOperations`** to E7M3-04 / NEO-136 | | Unknown faction deny | **Structured outcome** with stable reason code at store boundary | Linear AC; kickoff “Fail-closed \| Unknown factionId → deny” | | Default standing | **0** when row missing; **clamp on read** to registry min/max | Backlog + kickoff standing band **-100…100**, neutral **0** | | Clamp on write | **Store primitive clamps** resulting standing before persist | Backlog unit tests “clamp at bounds”; NEO-136 AC inherits same bounds | | Player write gate | **`CanWritePlayer`** on standing store (dev bucket / Postgres `player_position`) | NEO-116 quest store precedent | | Faction validation | Stores inject **`IFactionDefinitionRegistry`** — not raw string trust | Startup catalog + AC “unknown faction id” at store boundary | | Audit row shape | player, faction, delta, resulting standing, source kind, source id, timestamp, row id | Backlog E7M3-03 in-scope bullet | | Source kind (prototype) | **`quest_completion`** string constant only | Backlog example; E7M3-04/06 wire quest id as source id | | DI registration | **`AddFactionStandingStores`** extension separate from catalog | NEO-116 `AddPlayerQuestStateStore` precedent | | HTTP / Godot / gate eval | **Out of scope** | Backlog E7M3-03; NEO-137+ | ## Scope and out-of-scope **In scope (from Linear + backlog):** - Snapshot + audit types, id normalization, reason codes. - **`IFactionStandingStore`** + in-memory + Postgres implementations. - **`IReputationDeltaStore`** + in-memory + Postgres implementations. - **`V009__player_faction_standing.sql`**, **`V010__reputation_delta_audit.sql`** when `ConnectionStrings:NeonSprawl` is set. - **`FactionStandingServiceCollectionExtensions.AddFactionStandingStores`** wired from **`Program.cs`**. - Unit tests (AAA): default read **0**, read clamp, first standing delta apply, clamp at min/max bounds, unknown faction deny, audit append + query, append-only semantics. - Postgres persistence integration tests (`RequirePostgresFact`) for both stores. - `server/README.md` faction standing + reputation delta store sections. **Out of scope:** - **`ReputationOperations.TryApplyDelta`** public orchestration (NEO-136 / E7M3-04). - Quest accept gate evaluation (NEO-137), reward bundle rep wiring (NEO-138), HTTP GET (NEO-139), Godot (NEO-142+). - Telemetry hook comments (NEO-141). **Client counterpart:** none (infrastructure-only). ## Acceptance criteria checklist - [x] Standing readable per player+faction; deltas append-only and queryable for audit tests. - [x] Unknown faction id returns structured deny at store boundary. - [x] `dotnet test` covers store gates. ## Implementation reconciliation (shipped) - **Types:** `FactionStandingIds`, reason codes, read/mutation outcomes, `ReputationDeltaRow`, `ReputationDeltaSourceKinds`. - **Stores:** `IFactionStandingStore` / `IReputationDeltaStore`; in-memory + Postgres (`V009`, `V010`) when connection string set. - **DI:** `AddFactionStandingStores` in `Program.cs`; in-memory override in `InMemoryWebApplicationFactory`. - **Tests:** `InMemoryFactionStandingStoreTests`, `InMemoryReputationDeltaStoreTests`, Postgres persistence integration tests; host DI smoke (`768` tests green). - **Docs:** `server/README.md` faction standing + reputation delta store sections. ## Technical approach ### 1. Types (`Game/Factions/`) | Type | Role | |------|------| | `FactionStandingIds` | Normalize `playerId` / `factionId`; composite key helper (mirror `QuestProgressIds`) | | `FactionStandingReasonCodes` | `unknown_faction`, `player_not_writable` (`invalid_delta` deferred to NEO-136 `ReputationOperations`) | | `FactionStandingReadOutcome` | `Success`, optional `ReasonCode`, `Standing` (clamped) | | `FactionStandingMutationOutcome` | `Success`, optional `ReasonCode`, `PreviousStanding`, `NewStanding` | | `ReputationDeltaSourceKinds` | `QuestCompletion = "quest_completion"` (prototype constant) | | `ReputationDeltaRow` | Immutable audit row: `Id`, `PlayerId`, `FactionId`, `DeltaAmount`, `ResultingStanding`, `SourceKind`, `SourceId`, `RecordedAt` | Standing snapshot is keyed **`(playerId, factionId)` → int`**; no separate row type beyond normalized ids + value. ### 2. `IFactionStandingStore` Inject **`IFactionDefinitionRegistry`** in implementations (not on interface methods). - **`bool CanWritePlayer(string playerId)`** — dev bucket or Postgres player row exists. - **`FactionStandingReadOutcome TryGetStanding(string playerId, string factionId)`** — missing row ⇒ **0**; unknown faction ⇒ deny with `unknown_faction`; returned value clamped to faction min/max from registry. - **`FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount)`** — validates faction + writable player; reads current (default 0), adds delta, **clamps** to min/max, persists; first write returns success with before/after. Denies unknown faction / non-writable player / empty ids. **Does not append audit** (NEO-136 orchestrates both stores). XML remarks: consumed by **`ReputationOperations`** (NEO-136) and gate eval (NEO-137); not HTTP directly. ### 3. `IReputationDeltaStore` - **`bool TryAppend(ReputationDeltaRow row)`** — append-only; returns `false` on duplicate `Id` or invalid row (empty player/faction/id). - **`IReadOnlyList GetDeltasForPlayer(string playerId, int? limit = null)`** — test/audit read; ordered by `RecordedAt` then `Id` ascending. No update/delete APIs in prototype. ### 4. In-memory implementations Mirror NEO-116 / NEO-38 patterns: - **`InMemoryFactionStandingStore`** — `ConcurrentDictionary` keyed by composite key; seeds dev player from `GamePositionOptions`; per-key locks. - **`InMemoryReputationDeltaStore`** — append-only list/dictionary by row id; secondary index by player for queries; thread-safe. ### 5. Postgres implementations Mirror NEO-116 quest store: - **`PostgresPlayerFactionStandingBootstrap`** + **`V009__player_faction_standing.sql`** - Table `player_faction_standing (player_id FK → player_position, faction_id, standing INT, updated_at)` - PK `(player_id, faction_id)` - **`PostgresReputationDeltaBootstrap`** + **`V010__reputation_delta_audit.sql`** - Table `reputation_delta_audit (id UUID/TEXT PK, player_id FK, faction_id, delta_amount, resulting_standing, source_kind, source_id, recorded_at)` - Index on `(player_id, recorded_at)` for audit queries - **`PostgresFactionStandingStore`**, **`PostgresReputationDeltaStore`** — `EnsureSchema` on first use; standing store checks `player_position` exists before write. ### 6. DI and `Program.cs` - **`FactionStandingServiceCollectionExtensions.AddFactionStandingStores(configuration)`** — Postgres pair when connection string set; else in-memory pair. - Register **after** `AddFactionDefinitionCatalog` (stores depend on registry at runtime, not catalog singleton). - Place near other player persistence (`AddPlayerQuestStateStore`, `AddRewardDeliveryStore`). - **`InMemoryWebApplicationFactory`**: ensure stores resolve (override only if host tests need isolation — follow existing factory pattern). ### 7. Tests **In-memory (`InMemoryFactionStandingStoreTests`):** - Missing row reads **0** for known faction. - Read clamp when stored value exceeds max (direct internal seed or apply overflow scenario). - First `TryApplyStandingDelta` +15 from 0 → 15. - Apply delta that would exceed max clamps to **100**; below min clamps to **-100**. - Unknown faction id → `unknown_faction` deny, standing unchanged. - Non-writable player → deny. **In-memory (`InMemoryReputationDeltaStoreTests`):** - `TryAppend` success; `GetDeltasForPlayer` returns row. - Second append with new id succeeds; duplicate id returns `false`. - Empty player/faction/id → append deny. **Postgres (`FactionStandingPersistenceIntegrationTests`, `ReputationDeltaPersistenceIntegrationTests`):** - Write on factory A, read on factory B (`RequirePostgresFact` + harness). **Host DI smoke:** resolve both stores from `WebApplicationFactory` (optional one-liner in existing host test file). Manual QA: **none** (server infrastructure). ## Files to add | Path | Purpose | |------|---------| | `server/NeonSprawl.Server/Game/Factions/FactionStandingIds.cs` | Player/faction id normalization | | `server/NeonSprawl.Server/Game/Factions/FactionStandingReasonCodes.cs` | Stable deny codes | | `server/NeonSprawl.Server/Game/Factions/FactionStandingReadOutcome.cs` | Read result record | | `server/NeonSprawl.Server/Game/Factions/FactionStandingMutationOutcome.cs` | Mutation result record | | `server/NeonSprawl.Server/Game/Factions/ReputationDeltaSourceKinds.cs` | Source kind constants | | `server/NeonSprawl.Server/Game/Factions/ReputationDeltaRow.cs` | Audit row type | | `server/NeonSprawl.Server/Game/Factions/IFactionStandingStore.cs` | Standing store contract | | `server/NeonSprawl.Server/Game/Factions/IReputationDeltaStore.cs` | Audit store contract | | `server/NeonSprawl.Server/Game/Factions/InMemoryFactionStandingStore.cs` | Dev in-memory standing | | `server/NeonSprawl.Server/Game/Factions/InMemoryReputationDeltaStore.cs` | Dev in-memory audit | | `server/NeonSprawl.Server/Game/Factions/PostgresPlayerFactionStandingBootstrap.cs` | Migration bootstrap | | `server/NeonSprawl.Server/Game/Factions/PostgresFactionStandingStore.cs` | Postgres standing | | `server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaBootstrap.cs` | Audit migration bootstrap | | `server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaStore.cs` | Postgres audit | | `server/NeonSprawl.Server/Game/Factions/FactionStandingServiceCollectionExtensions.cs` | DI registration | | `server/db/migrations/V009__player_faction_standing.sql` | Standing table | | `server/db/migrations/V010__reputation_delta_audit.sql` | Audit table | | `server/NeonSprawl.Server.Tests/Game/Factions/InMemoryFactionStandingStoreTests.cs` | Standing AAA tests | | `server/NeonSprawl.Server.Tests/Game/Factions/InMemoryReputationDeltaStoreTests.cs` | Audit AAA tests | | `server/NeonSprawl.Server.Tests/Game/Factions/FactionStandingPersistenceIntegrationTests.cs` | Postgres standing | | `server/NeonSprawl.Server.Tests/Game/Factions/ReputationDeltaPersistenceIntegrationTests.cs` | Postgres audit | ## Files to modify | Path | Change | |------|--------| | `server/NeonSprawl.Server/Program.cs` | Register `AddFactionStandingStores` after faction catalog | | `server/README.md` | Faction standing + reputation delta store sections | | `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Register/override faction stores if host tests require it | ## Tests | Layer | What | |-------|------| | `InMemoryFactionStandingStoreTests` | Default 0, read clamp, apply delta, clamp min/max, unknown faction, player gate | | `InMemoryReputationDeltaStoreTests` | Append, query, duplicate-id deny, validation | | `FactionStandingPersistenceIntegrationTests` | Postgres write/read round-trip (`RequirePostgresFact`) | | `ReputationDeltaPersistenceIntegrationTests` | Postgres append/query round-trip (`RequirePostgresFact`) | | CI | Existing `dotnet test` + no content pipeline changes | ## Implementation order 1. Types, reason codes, ids, store interfaces. 2. In-memory standing + audit stores. 3. Postgres migrations + bootstrap + Postgres stores. 4. DI extension + `Program.cs`. 5. Unit tests → Postgres integration tests → `dotnet test`. 6. `server/README.md`. ## Open questions / risks | Item | Agent recommendation | Status | |------|---------------------|--------| | Single vs dual migration files | **Two files (V009 standing, V010 audit)** — matches one-table-per-migration precedent (V007 gig, V008 quest) | `adopted` | | Audit row id format | **UUID string** generated by caller (NEO-136); store rejects empty/duplicate | `adopted` | | Standing store exposes `TryApplyStandingDelta` vs absolute set | **Delta primitive only** — matches backlog test wording and NEO-136 apply flow | `adopted` | | Registry on read for unknown faction | **Deny unknown on read and write** — AC “store boundary”; gate eval (NEO-137) always uses catalog factions | `adopted` | ## Client counterpart None for E7M3-03. Client faction HUD: **NEO-142** / capstone **NEO-143**. ## Blocks **NEO-136** (`ReputationOperations`) and **NEO-137** (gate eval) depend on these stores landing first.