# NEO-151 — E7M4-08: Contract issue POST + per-player contract GET **Linear:** [NEO-151](https://linear.app/neon-sprawl/issue/NEO-151) **Branch:** `NEO-151-e7m4-08-contract-issue-post-per-player-contract-get` **Backlog:** [E7M4-pre-production-backlog.md](E7M4-pre-production-backlog.md) — **E7M4-08** **Module:** [E7_M4_ContractMissionGenerator.md](../decomposition/modules/E7_M4_ContractMissionGenerator.md) **Pattern:** [NEO-120-implementation-plan.md](NEO-120-implementation-plan.md) (`QuestAcceptApi` issue envelope); [NEO-129-implementation-plan.md](NEO-129-implementation-plan.md) (`completionRewardSummary` on GET rows); [NEO-147-implementation-plan.md](NEO-147-implementation-plan.md) (`ContractGeneratorOperations.TryIssue`); [NEO-149-implementation-plan.md](NEO-149-implementation-plan.md) (encounter clear → completion + delivery) **Precursors:** [NEO-149](https://linear.app/neon-sprawl/issue/NEO-149) **`Done`** — `ContractCompletionOperations` + encounter wiring; [NEO-150](https://linear.app/neon-sprawl/issue/NEO-150) **`Done`** — issue-time economy validation (**on `main`**) **Blocks:** [NEO-152](https://linear.app/neon-sprawl/issue/NEO-152) (telemetry hook sites), [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) (Godot contract HUD) **Client counterpart:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) — contract panel after this story lands ## Goal Client-readable contract issuance and state without local math — **`POST …/contracts/issue`** returns an **`active`** instance projection; **`GET …/contracts`** lists the player's active contract (if any) plus recent completed rows with encounter objective id and delivery summary. ## Kickoff clarifications | Topic | Question | Agent recommendation | Answer | |-------|----------|---------------------|--------| | GET completed history depth | How many completed rows alongside active? | **Active (0–1) + up to 10 recent completed**, ordered by **`issuedAt` desc** | **Adopted** — user chose cap 10 | | POST body shape | Full `contract-seed` vs path-only player id? | **Full seed fields + `schemaVersion` 1**; **`playerId` in body must match path `{id}`** (400 on mismatch); **`templateId` required** per schema + NEO-147 | **Adopted** — schema + NEO-147 precedent | | Issue response envelope | Raw snapshot vs structured response? | **`{ issued, reasonCode, contract }`** mirroring quest accept **`{ accepted, reasonCode, quest }`** | **Adopted** — NEO-120 pattern | | Grant summary DTOs | New contract types vs reuse quest summary? | **Reuse `QuestCompletionRewardSummaryJson`** and grant line types — same `IRewardDeliveryStore` snapshot shape | **Adopted** — NEO-129/NEO-140 | | Store read for GET | Extend `IContractInstanceStore` vs ad-hoc SQL in API? | **Add `ListForPlayer(playerId, maxCompletedRows)`** on interface + both store impls | **Adopted** — store boundary precedent (NEO-146) | | Issue deny HTTP status | 400 vs 200 with `issued: false`? | **200 OK** with **`issued: false`** + **`reasonCode`** (and optional **`contract`** snapshot on `active_contract_exists`) | **Adopted** — quest accept precedent | | Bruno scope | Minimal vs full issue → clear → GET loop? | **`bruno/neon-sprawl-server/contracts/`** — issue, GET default, GET after encounter clear (reuse ability-cast / encounter clear spine) | **Adopted** — backlog in-scope | | Telemetry / Godot | In scope? | **Out of scope** — NEO-152 comment hooks; NEO-153 client HUD | **Adopted** — backlog | ## Scope and out-of-scope **In scope (from Linear + backlog):** - **`POST /game/players/{id}/contracts/issue`** — `ContractIssueApi` + request/response DTOs; delegates to **`ContractGeneratorOperations.TryIssue`** with full DI registries (encounter, item, skill, faction per NEO-150 handoff). - **`GET /game/players/{id}/contracts`** — `ContractListApi` + list DTOs; active row + up to **10** recent completed instances with template summary, **`encounterTemplateId`**, **`completionRewardSummary`** when delivered. - **`IContractInstanceStore.ListForPlayer`** — read path for completed history (in-memory + Postgres). - Integration tests (`ContractIssueApiTests` / `ContractListApiTests` or combined `ContractApiTests`). - Bruno **`bruno/neon-sprawl-server/contracts/`** — issue → encounter clear → GET loop; update contract-stores reset bru cross-link. - `server/README.md` contract HTTP sections; E7.M4 module anchor. **Out of scope (from Linear + backlog):** - Godot parse/HUD (**NEO-153** / E7M4-10). - Telemetry comment hooks (**NEO-152** / E7M4-09). - Optional **`templateId`** auto-select over HTTP (ops layer supports null; HTTP requires explicit id per schema). - Live E4.M1 zone read for **`zoneDifficultyBand`** (POST override only until E4.M1). **Client counterpart:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) — server HTTP must land in this story first. ## Acceptance criteria checklist - [ ] POST issue returns **`active`** instance JSON. - [ ] GET lists active contract with encounter objective id. - [ ] GET completed row includes delivery summary after encounter clear. ## Technical approach ### 1. Store read — `IContractInstanceStore.ListForPlayer` ```csharp IReadOnlyList ListForPlayer(string playerId, int maxCompletedRows); ``` **Semantics:** - Normalize **`playerId`**; return empty list when player not writable / unknown to store. - If an **active** row exists, include it first (at most one). - Append up to **`maxCompletedRows`** **completed** instances for that player, **`issuedAt` descending** (tie-break **`contractInstanceId`** ordinal). - Prototype constant **`ContractListApi.MaxCompletedRows = 10`** (kickoff decision). **Postgres:** `SELECT … WHERE player_id = @pid AND status = 'completed' ORDER BY completed_at DESC NULLS LAST, contract_instance_id LIMIT @n` plus existing active query. **In-memory:** scan **`byInstanceId`** for player-owned completed rows, sort, take N, prepend active. Unit tests in **`InMemoryContractInstanceStoreTests`**: empty player, active only, completed only, active + multiple completed ordering, cap respected. ### 2. DTOs (`Game/Contracts/ContractApiDtos.cs`) | Type | Role | |------|------| | **`ContractIssueRequest`** | `schemaVersion` **1**, `playerId`, `templateId`, `seedBucket`, optional `zoneDifficultyBand` — mirrors [`contract-seed.schema.json`](../../content/schemas/contract-seed.schema.json) + version gate | | **`ContractIssueResponse`** | `schemaVersion`, `issued`, optional `reasonCode`, optional **`ContractInstanceRowJson`** | | **`ContractListResponse`** | `schemaVersion`, `playerId`, **`contracts`** array | | **`ContractInstanceRowJson`** | `contractInstanceId`, `templateId`, `templateDisplayName`, `status` (`active` \| `completed`), `encounterTemplateId`, `seedBucket`, `issuedAt`, optional `completedAt`, optional **`completionRewardSummary`** (`QuestCompletionRewardSummaryJson`) | **Row mapping (`ContractListApi.MapInstanceRow`):** 1. Load template via **`IContractTemplateRegistry.TryGetDefinition`** for **`templateDisplayName`** + **`encounterTemplateId`** fallback. 2. For **`completed`** status, **`IRewardDeliveryStore.TryGet(playerId, RewardDeliverySourceKinds.ContractCompletion, contractInstanceId, …)`** → map grants via same helpers as **`QuestProgressApi.MapCompletionRewardSummary`** (extract shared mapper or duplicate minimally — prefer internal reuse on **`QuestProgressApi`** mappers if accessible, else parallel private helpers in contract API). ### 3. `ContractIssueApi` **Route:** `POST /game/players/{id}/contracts/issue` **Handler flow:** 1. **400** when body null, **`schemaVersion`** mismatch, empty **`templateId`** / **`seedBucket`**, or body **`playerId`** ≠ trimmed path **`{id}`** (case-sensitive ordinal after trim — match inventory/quest normalization). 2. **404** when path player unknown (**`IPositionStateStore`** gate — same as quest progress). 3. Call **`ContractGeneratorOperations.TryIssue`** with path player id, body fields, and injected registries/stores (**mirror `ContractGeneratorOperationsIntegrationTests`** DI wiring). 4. **200** **`ContractIssueResponse`** via **`MapIssueResponse`** — on success map **`ContractInstanceRowJson`** from result snapshot + template registry; on deny include **`reasonCode`** and optional snapshot row (e.g. **`active_contract_exists`**). Register in **`Program.cs`** alongside quest routes: **`app.MapContractIssueApi()`**. ### 4. `ContractListApi` **Route:** `GET /game/players/{id}/contracts` **Handler flow:** 1. **404** unknown player (position gate). 2. **`instanceStore.ListForPlayer(trimmedId, ContractListApi.MaxCompletedRows)`**. 3. Map each snapshot to **`ContractInstanceRowJson`**. 4. **200** **`ContractListResponse`** — **`contracts`** may be empty array. ### 5. Integration tests (`ContractApiTests`) | Test | Covers | |------|--------| | POST issue 404 unknown player | Position gate | | POST issue 400 schema / playerId mismatch | Request validation | | POST issue 200 happy path | **`issued: true`**, **`active`**, **`encounterTemplateId: prototype_combat_pocket`**, deterministic **`contractInstanceId`** | | POST issue 200 deny second active | **`issued: false`**, **`active_contract_exists`**, optional active row echo | | GET 404 unknown player | Position gate | | GET empty list | Known player, no instances | | GET active after issue | Active row with encounter id, no summary | | GET completed after encounter clear | Issue → **`ContractCompletionOperations.TryCompleteOnEncounterClear`** (or ability-cast integration path) → GET asserts **`completed`**, **`completionRewardSummary`** with prototype bundle lines | | GET idempotent replay | Second GET same summary (mirror NEO-129) | Optional **`RequirePostgresFact`**: issue + list round-trip on Postgres store (mirror NEO-146 persistence tests). ### 6. Bruno (`bruno/neon-sprawl-server/contracts/`) | File | Purpose | |------|---------| | `Issue prototype contract.bru` | POST issue with frozen template + seed bucket | | `Get contracts default.bru` | GET empty or active-only baseline | | `Get contracts after encounter clear.bru` | Full spine: issue → clear **`prototype_combat_pocket`** → GET asserts **`completionRewardSummary`** | Update **`contract-stores/Reset contract instance via quest fixture.bru`** docs to reference real instance id from issue bru. ### 7. Docs - **`server/README.md`** — contract issue GET/POST routes, JSON samples, reason codes (link generator/completion sections). - **`E7_M4_ContractMissionGenerator.md`** — E7M4-08 HTTP landed note. - **`documentation_and_implementation_alignment.md`** — E7.M4 row after implementation. ## Files to add | Path | Purpose | |------|---------| | `docs/plans/NEO-151-implementation-plan.md` | This plan | | `server/NeonSprawl.Server/Game/Contracts/ContractApiDtos.cs` | Issue/list request/response + row JSON types | | `server/NeonSprawl.Server/Game/Contracts/ContractIssueApi.cs` | `POST …/contracts/issue` handler + mapper | | `server/NeonSprawl.Server/Game/Contracts/ContractListApi.cs` | `GET …/contracts` handler + row mapper | | `server/NeonSprawl.Server.Tests/Game/Contracts/ContractApiTests.cs` | AAA HTTP integration tests | | `bruno/neon-sprawl-server/contracts/Issue prototype contract.bru` | Bruno issue spine | | `bruno/neon-sprawl-server/contracts/Get contracts default.bru` | Bruno GET baseline | | `bruno/neon-sprawl-server/contracts/Get contracts after encounter clear.bru` | Bruno issue → clear → GET capstone | ## Files to modify | Path | Rationale | |------|-----------| | `server/NeonSprawl.Server/Game/Contracts/IContractInstanceStore.cs` | Add **`ListForPlayer`** for completed history reads | | `server/NeonSprawl.Server/Game/Contracts/InMemoryContractInstanceStore.cs` | Implement **`ListForPlayer`** | | `server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs` | Implement **`ListForPlayer`** SQL | | `server/NeonSprawl.Server/Program.cs` | Register **`MapContractIssueApi`** + **`MapContractListApi`** | | `server/NeonSprawl.Server.Tests/Game/Contracts/InMemoryContractInstanceStoreTests.cs` | Unit tests for **`ListForPlayer`** ordering + cap | | `server/README.md` | Document contract issue/list HTTP routes + samples | | `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | NEO-151 HTTP anchor | | `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M4 alignment note (post-implementation) | | `bruno/neon-sprawl-server/contract-stores/Reset contract instance via quest fixture.bru` | Cross-link contracts bru folder | ## Tests | File | What it covers | |------|----------------| | `InMemoryContractInstanceStoreTests` | **`ListForPlayer`**: empty, active-only, completed cap, ordering | | `ContractApiTests` | POST issue happy/deny paths; GET empty/active/completed + **`completionRewardSummary`**; 404/400 gates | | `ContractGeneratorOperationsIntegrationTests` (existing) | Unchanged — orchestrator DI baseline | | CI | `dotnet test` green; Bruno manual against dev server | Manual Godot QA: **none** (server HTTP; capstone **NEO-154**). Bruno: **`contracts/`** folder per plan. ## Open questions / risks | Question / risk | Agent recommendation | Status | |-----------------|----------------------|--------| | **`TryIssue` registry wiring in HTTP handler** | Pass same four registries as **`ContractGeneratorOperationsIntegrationTests`** — verified at kickoff (NEO-150 handoff) | `adopted` | | Duplicate grant-summary mapper vs quest API | Extract **`internal`** shared mapper on **`QuestProgressApi`** if clean; else parallel private helpers — keep grant order parity | `pending` | | GET payload size with 10 completed rows | Accept for prototype; client (**NEO-153**) reads active row first | `adopted` | | **`contract-seed.schema.json` lacks `schemaVersion`** | HTTP DTO adds **`schemaVersion`** alongside seed fields (quest/inventory precedent); schema file unchanged unless CI needs `$ref` later | `adopted` | | Postgres **`ListForPlayer`** index | Existing **`player_id`** + status filter sufficient for prototype volume; no new migration | `adopted` | ## Client counterpart [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) — **`contract_client.gd`**, issue binding, HUD labels; blocked until this story merges. Surfaces deny codes including **`economy_cap_exceeded`** and **`invalid_reward_bundle`** from NEO-150.