17 KiB
NEO-146 — E7M4-03: Contract instance + outcome stores
Linear: NEO-146
Branch: NEO-146-e7m4-03-contract-instance-outcome-stores
Backlog: E7M4-pre-production-backlog.md — E7M4-03
Module: E7_M4_ContractMissionGenerator.md
Pattern: NEO-116-implementation-plan.md (in-memory + Postgres when configured); NEO-135-implementation-plan.md (dual-store split); NEO-126-implementation-plan.md (grant snapshot on audit row)
Precursor: NEO-145 Done — IContractTemplateRegistry, fail-fast contract catalog load (on main)
Blocks: NEO-147 (E7M4-04 ContractGeneratorOperations.TryIssue)
Client counterpart: none (infrastructure-only); playable loop is NEO-154 (E7M4-11)
Goal
Durable per-player ContractInstance rows (active/completed) and append-only ContractOutcome audit records. Stores expose mutation primitives for issue/complete flows; generator, payout apply, and HTTP land in later stories.
Kickoff clarifications
No blocking decisions needed. Linear AC, E7M4-03 backlog scope, E7M4 kickoff decisions table, and NEO-116/NEO-135/NEO-126 precedents settle:
| Topic | Decision | Evidence |
|---|---|---|
| Postgres persistence | Include when configured — in-memory fallback otherwise | Backlog “NEO-116-style split”; NEO-116/NEO-135 adopted same |
| Store split | IContractInstanceStore + IContractOutcomeStore |
Backlog in-scope list; snapshot vs append-only audit |
| Store API shape | Mutation methods on instance store — TryCreateActive, TryMarkComplete; outcome store TryAppend + query |
NEO-116 quest store precedent; backlog unit tests “issue, complete, idempotent complete replay” |
| One active per player | Enforce in TryCreateActive — deny when player already has active row |
Linear AC + kickoff “One active contract per player” |
expired status |
Enum value only — no transition API in E7M4-03 | Backlog instance row lists status; no expiry story in Slice 4 backlog |
| Missing instance | Implicit absence — TryGet returns false; no not_started row |
NEO-116 missing-row pattern |
| Player write gate | CanWritePlayer on instance store (dev bucket / Postgres player_position) |
NEO-116 quest store precedent |
| Template validation | Store persists templateId string only — registry checks deferred to E7M4-04 generator |
NEO-116 does not validate quest ids at store boundary |
| Outcome grant snapshot | Reuse RewardItemGrantApplied / RewardSkillXpGrantApplied / RewardReputationGrantApplied from Game/Rewards/ |
Contract completion bundle same grant kinds (E7M4-05); avoids duplicate DTOs |
| Idempotency key on outcome | {playerId}:contract_complete:{contractInstanceId} stored on outcome row |
E7M4 kickoff decisions table |
| Generator / payout / HTTP | Out of scope | Backlog E7M4-03; NEO-147+ |
| DI registration | AddContractInstanceStores extension separate from catalog |
NEO-116 AddPlayerQuestStateStore precedent |
Scope and out-of-scope
In scope (from Linear + backlog):
ContractInstanceStatesnapshot +ContractInstanceStatus(Active,Completed,Expired).IContractInstanceStore,IContractOutcomeStore— in-memory + Postgres whenConnectionStrings:NeonSprawlis set.- Instance fields:
contractInstanceId,templateId,playerId,status,seedBucket,issuedAt, optionalcompletedAt. - Outcome fields: instance id, delivery idempotency key, grant snapshot summary,
recordedAt, row id. ContractInstanceIds— player/instance id normalization + composite keys.V011__contract_instance.sql,V012__contract_outcome.sql+ bootstrap helpers when Postgres configured.- Unit tests (AAA): create active (issue primitive), deny second active, complete, idempotent complete replay, outcome append-only + duplicate deny.
- Postgres persistence integration tests (
RequirePostgresFact) for both stores. server/README.mdcontract instance + outcome store sections.- DI wired from
Program.cs; in-memory override inInMemoryWebApplicationFactory.
Out of scope (from Linear + backlog):
ContractGeneratorOperations.TryIssue(NEO-147 / E7M4-04).RewardRouterOperations.TryDeliverContractCompletion(NEO-148 / E7M4-05).- Encounter completion wiring (NEO-149 / E7M4-06).
- Economy validation at issue (NEO-150 / E7M4-07).
- HTTP issue/GET (NEO-151 / E7M4-08), telemetry hooks (NEO-152), Godot (NEO-153+).
Client counterpart: none (infrastructure-only).
Acceptance criteria checklist
- At most one
activeinstance per player (prototype policy). - Completed instances immutable; outcome append-only.
dotnet testcovers store gates.
Implementation reconciliation (shipped)
- Types:
ContractInstanceStatus,ContractInstanceState,ContractInstanceIds,ContractInstanceReasonCodes,ContractOutcomeRow,ContractOutcomeIds. - Stores:
IContractInstanceStore/IContractOutcomeStore; in-memory + Postgres (V011,V012) when connection string set; partial unique index + app-level one-active enforcement. - DI:
AddContractInstanceStoresinProgram.cs; in-memory override inInMemoryWebApplicationFactory. - Tests:
InMemoryContractInstanceStoreTests,InMemoryContractOutcomeStoreTests, Postgres persistence integration tests; host DI smoke (872tests green). - In-memory outcome guard:
InMemoryContractOutcomeStore.TryAppenddenies whenIContractInstanceStore.TryGetis false (Postgres FK + player/instance SQL guard parity). - Docs:
server/README.mdcontract instance + outcome store sections. - Bruno:
bruno/neon-sprawl-server/contract-stores/health smoke (startup-only; no contract HTTP in E7M4-03).
Technical approach
1. Types (Game/Contracts/)
| Type | Role |
|---|---|
ContractInstanceStatus |
Active, Completed, Expired (no expiry transition in E7M4-03) |
ContractInstanceState |
Immutable snapshot: ContractInstanceId, TemplateId, PlayerId, Status, SeedBucket, IssuedAt, CompletedAt? |
ContractInstanceIds |
NormalizePlayerId, NormalizeContractInstanceId, MakeInstanceKey (per-row lock key) |
ContractInstanceReasonCodes |
player_not_writable, active_contract_exists, instance_not_found, instance_not_active, invalid_ids |
ContractOutcomeRow |
Id, ContractInstanceId, PlayerId, IdempotencyKey, grant snapshots (items/skill XP/rep), RecordedAt |
ContractOutcomeIds |
MakeIdempotencyKey(playerId, contractInstanceId) → {p}:contract_complete:{i} |
Grant snapshot fields on ContractOutcomeRow use existing reward types:
IReadOnlyList<RewardItemGrantApplied> GrantedItemsIReadOnlyList<RewardSkillXpGrantApplied> GrantedSkillXpIReadOnlyList<RewardReputationGrantApplied> GrantedReputation
2. IContractInstanceStore
Injected by ContractGeneratorOperations (NEO-147) and ContractCompletionOperations (NEO-149); not HTTP directly.
bool CanWritePlayer(string playerId)— dev bucket or Postgresplayer_positionrow exists.bool TryGet(string playerId, string contractInstanceId, out ContractInstanceState snapshot)— missing ⇒false.bool TryGetActiveForPlayer(string playerId, out ContractInstanceState snapshot)— at most one active row per player; missing active ⇒false.bool TryCreateActive(string playerId, string contractInstanceId, string templateId, string seedBucket, DateTimeOffset issuedAt, out ContractInstanceState snapshot)— first active for player returnstrue; denies when player not writable, invalid ids, duplicate instance id, oractive_contract_existswhen another active row present.bool TryMarkComplete(string playerId, string contractInstanceId, DateTimeOffset completedAt, out ContractInstanceState snapshot)— requires active row; first completetrue; replayfalsepreserving firstCompletedAt; completed rows cannot regress.bool TryClearInstance(string playerId, string contractInstanceId)— dev fixture only; idempotent when absent (Bruno/test reset parity with quest storeTryClearProgress).
3. IContractOutcomeStore
bool TryAppend(ContractOutcomeRow row)— append-only;falseon duplicateIdor duplicateIdempotencyKeyor invalid row.bool TryGetByIdempotencyKey(string idempotencyKey, out ContractOutcomeRow row)— audit/idempotency read for tests and E7M4-05 router replay.IReadOnlyList<ContractOutcomeRow> GetOutcomesForInstance(string contractInstanceId, int? limit = null)— ordered byRecordedAtthenIdascending.bool TryClearForInstance(string contractInstanceId)— dev fixture only.
No update/delete APIs in prototype.
4. In-memory implementations
Mirror NEO-116 / NEO-135:
InMemoryContractInstanceStore—ConcurrentDictionaryby instance key; secondary indexplayerId → active instance id; dev player seeded fromGamePositionOptions; per-key locks for mutations.InMemoryContractOutcomeStore— append-only by row id + idempotency key index; thread-safe.
5. Postgres implementations
Mirror NEO-116 quest store:
PostgresContractInstanceBootstrap+V011__contract_instance.sql- Table
contract_instance (contract_instance_id TEXT PK, player_id FK → player_position, template_id, status, seed_bucket, issued_at, completed_at NULL) - Partial unique index on
(player_id) WHERE status = 'active'— DB-enforced one-active policy
- Table
PostgresContractOutcomeBootstrap+V012__contract_outcome.sql- Table
contract_outcome (id TEXT PK, contract_instance_id FK, player_id FK, idempotency_key UNIQUE, granted_items JSONB, granted_skill_xp JSONB, granted_reputation JSONB, recorded_at) - Index on
(contract_instance_id, recorded_at)
- Table
PostgresContractInstanceStore,PostgresContractOutcomeStore—EnsureSchemaon first use; instance store checks writable player before insert/update.
JSON columns store grant snapshots as compact arrays (same shape as in-memory lists); no behavior change vs in-memory.
6. DI and Program.cs
ContractInstanceServiceCollectionExtensions.AddContractInstanceStores— Postgres implementations when connection string set; otherwise in-memory pair.- Register after
AddContractTemplateCatalog(catalog unrelated; stores are runtime state). InMemoryWebApplicationFactory: remove/replace Postgres registrations for both store interfaces; register in-memory implementations inConfigureTestServicesloop (same pattern as quest/faction stores).
7. Tests
Instance store (InMemoryContractInstanceStoreTests):
- Create active (happy path) — all fields persisted.
- Deny second active for same player (
active_contract_exists). - Complete active instance; snapshot status
Completed+CompletedAt. - Idempotent complete replay — second
TryMarkCompletereturnsfalse,CompletedAtunchanged. - Completed instance cannot accept new active without dev clear (optional: completed player can issue again after complete — new instance id allowed when no active row).
- Deny create when player not writable / empty ids.
- Host DI:
Host_ShouldResolveContractInstanceStoresFromDi.
Outcome store (InMemoryContractOutcomeStoreTests):
- Append first outcome; query by instance + idempotency key.
- Duplicate row id or idempotency key denies append.
- Append-only — no mutation APIs.
Postgres (ContractInstancePersistenceIntegrationTests, ContractOutcomePersistenceIntegrationTests):
RequirePostgresFact: write on factory A, read on factory B for instance create/complete and outcome append.
8. Docs
server/README.md— contract instance store + contract outcome store sections (config, one-active policy, idempotency key shape, load order note: stores are runtime, not content catalog).- Optional one-line E7_M4 module store note when implementation lands.
9. Implementation order
- Types, ids, reason codes.
- In-memory stores + interfaces.
- Postgres migrations + stores.
- DI extension,
Program.cs, factory overrides. - Unit tests → Postgres integration tests → host DI smoke.
server/README.md.- Run
dotnet test.
Files to add
| Path | Purpose |
|---|---|
docs/plans/NEO-146-implementation-plan.md |
This plan. |
server/NeonSprawl.Server/Game/Contracts/ContractInstanceStatus.cs |
Instance lifecycle enum |
server/NeonSprawl.Server/Game/Contracts/ContractInstanceState.cs |
Immutable instance snapshot |
server/NeonSprawl.Server/Game/Contracts/ContractInstanceIds.cs |
Id normalization + composite keys |
server/NeonSprawl.Server/Game/Contracts/ContractInstanceReasonCodes.cs |
Stable deny reason strings |
server/NeonSprawl.Server/Game/Contracts/IContractInstanceStore.cs |
Instance store contract |
server/NeonSprawl.Server/Game/Contracts/InMemoryContractInstanceStore.cs |
Thread-safe in-memory instance store |
server/NeonSprawl.Server/Game/Contracts/ContractOutcomeRow.cs |
Append-only outcome audit row |
server/NeonSprawl.Server/Game/Contracts/ContractOutcomeIds.cs |
Idempotency key helper |
server/NeonSprawl.Server/Game/Contracts/IContractOutcomeStore.cs |
Outcome store contract |
server/NeonSprawl.Server/Game/Contracts/InMemoryContractOutcomeStore.cs |
Thread-safe in-memory outcome store |
server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceBootstrap.cs |
Schema ensure for instance table |
server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs |
Postgres instance store |
server/NeonSprawl.Server/Game/Contracts/PostgresContractOutcomeBootstrap.cs |
Schema ensure for outcome table |
server/NeonSprawl.Server/Game/Contracts/PostgresContractOutcomeStore.cs |
Postgres outcome store |
server/NeonSprawl.Server/Game/Contracts/ContractInstanceServiceCollectionExtensions.cs |
DI registration |
server/db/migrations/V011__contract_instance.sql |
Postgres instance migration |
server/db/migrations/V012__contract_outcome.sql |
Postgres outcome migration |
server/NeonSprawl.Server.Tests/Game/Contracts/InMemoryContractInstanceStoreTests.cs |
Instance store AAA tests |
server/NeonSprawl.Server.Tests/Game/Contracts/InMemoryContractOutcomeStoreTests.cs |
Outcome store AAA tests |
server/NeonSprawl.Server.Tests/Game/Contracts/ContractInstancePersistenceIntegrationTests.cs |
Postgres instance persistence |
server/NeonSprawl.Server.Tests/Game/Contracts/ContractOutcomePersistenceIntegrationTests.cs |
Postgres outcome persistence |
Files to modify
| Path | Rationale |
|---|---|
server/NeonSprawl.Server/Program.cs |
Register AddContractInstanceStores after contract catalog |
server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs |
Force in-memory contract stores in tests (override Postgres registrations) |
server/README.md |
Document contract instance + outcome stores |
Tests
| Layer | What |
|---|---|
InMemoryContractInstanceStoreTests |
Create active, one-active deny, complete, idempotent complete, immutability, writable gate, empty-id deny |
InMemoryContractOutcomeStoreTests |
Append, duplicate deny, query by instance/idempotency key, append-only |
ContractInstancePersistenceIntegrationTests |
Postgres round-trip create + complete (RequirePostgresFact) |
ContractOutcomePersistenceIntegrationTests |
Postgres round-trip append + read (RequirePostgresFact) |
| Host boot | Resolve IContractInstanceStore + IContractOutcomeStore from DI via factory |
| CI | Existing dotnet test; no Python/content changes expected |
Manual Godot QA: none (server infrastructure; capstone is NEO-154).
Open questions / risks
| Question / risk | Agent recommendation | Status |
|---|---|---|
| Postgres partial unique index vs app-only one-active check | Both — enforce in store logic (fast deny reason) + partial unique index for race safety | adopted |
| Grant snapshot on outcome before E7M4-05 router | Store accepts caller-supplied snapshot on append; E7M4-06 orchestrator fills from router result | adopted |
Expired status unused in Slice 4 |
Keep enum for forward compatibility; no API until a future expiry story | adopted |
Dev TryClear* on Postgres |
Implement on in-memory only first; add Postgres clear if Bruno fixture story needs it (mirror quest TryClearProgress on both when needed) |
adopted — both backends implement clear helpers |
Client counterpart
None for E7M4-03. Client contract HUD: NEO-153 / capstone NEO-154.