# NEO-150 — E7M4-07: Economy validation lint at issue time **Linear:** [NEO-150](https://linear.app/neon-sprawl/issue/NEO-150) **Branch:** `NEO-150-e7m4-07-economy-validation-lint-at-issue-time` **Backlog:** [E7M4-pre-production-backlog.md](E7M4-pre-production-backlog.md) — **E7M4-07** **Module:** [E7_M4_ContractMissionGenerator.md](../decomposition/modules/E7_M4_ContractMissionGenerator.md) **Pattern:** [NEO-145-implementation-plan.md](NEO-145-implementation-plan.md) (CI-parity gates + `PrototypeE7M4ContractCatalogRules`); [NEO-147-implementation-plan.md](NEO-147-implementation-plan.md) (`ContractGeneratorOperations.TryIssue` orchestrator) **Precursor:** [NEO-147](https://linear.app/neon-sprawl/issue/NEO-147) **`Done`** — `ContractGeneratorOperations.TryIssue` (**on `main`**) **Blocks:** [NEO-151](https://linear.app/neon-sprawl/issue/NEO-151) (E7M4-08 contract issue HTTP) **Client counterpart:** none (server policy); readable deny copy lands in **NEO-153** (E7M4-10) ## Goal Slice 4 AC — issued contracts validate against **prototype economy caps** and **reward bundle cross-refs** at issue time (defense in depth after CI + startup catalog load). ## Kickoff clarifications | Topic | Question | Agent recommendation | Answer | |-------|----------|---------------------|--------| | Deny reason codes | Single vs split codes for cap vs cross-ref failures? | **Split:** `economy_cap_exceeded` for band-cap violations; `invalid_reward_bundle` for unknown item/skill/faction refs or disallowed skill XP source — keeps E7M4-10 HUD copy precise and matches backlog unit-test split (over-cap vs unknown id). | **Adopted** — user chose separate codes | | Validation reuse | New logic vs delegate to `PrototypeE7M4ContractCatalogRules`? | **Delegate** — `ContractEconomyValidation.TryValidateTemplate` calls existing `TryGetBandCapGateError` + `TryGetCrossRefError` on a single-row map; same constants as CI/loader (`BandCapsByDifficultyBand`). | **Adopted** — repo precedent (NEO-145) | | Cross-ref scope at issue | Bundle-only vs full loader parity? | **Full loader parity** for the selected template (encounter, faction gate, bundle item/skill/faction refs, `mission_reward` allowlist) via live registries — matches backlog “registry lookups” and “slipped through” AC. | **Adopted** — backlog in-scope list | | TryIssue signature | Add registry parameters vs hide inside validation service? | **Add registry parameters** to `TryIssue` (encounter, item, skill, faction registries + skill defs for allowlist) — mirror `ContractCompletionOperations` / loader cross-ref deps; static orchestrator pattern unchanged. | **Adopted** — NEO-147 orchestrator precedent | | Startup vs issue-time | Re-validate when catalog already passed load? | **Yes** — issue-time lint is fail-closed defense; production path always passes; unit tests inject tampered in-memory templates to prove deny. | **Adopted** — Linear AC “cannot issue if slipped through” | | HTTP / Godot / telemetry | In scope? | **Out of scope** — NEO-151 HTTP, NEO-153 client HUD, NEO-152 comment hooks. | **Adopted** — backlog | ## Scope and out-of-scope **In scope (from Linear + backlog):** - **`ContractEconomyValidation.TryValidateTemplate`** — band caps + cross-ref for one `ContractTemplateRow` using `PrototypeE7M4ContractCatalogRules` helpers and injected registries. - Call from **`ContractGeneratorOperations.TryIssue`** after template selection, **before** `TryCreateActive`; fail-closed deny. - **`ContractGeneratorReasonCodes`:** `economy_cap_exceeded`, `invalid_reward_bundle`. - Unit tests: at-cap pass (prototype freeze), over-cap deny, unknown bundle id deny. - Confirm startup/CI path unchanged: tampered catalog still fails `ContractTemplateCatalogLoader` / `validate_content.py` (existing NEO-144/NEO-145 tests). - `server/README.md` contract generator section — economy validation reason codes + issue-time gate. - **E7_M4** module doc — economy validation note + parity stub toward E6.M4 / E3.M5 (full map deferred). **Out of scope (from Linear + backlog):** - Full **`RewardParityMap`** (E6.M4); live ops dashboards (E9.M2 / E3.M5). - **`POST …/contracts/issue`** HTTP (NEO-151). - Godot client deny copy (NEO-153). - Telemetry **`reward_anomaly`** comment hooks (NEO-152). ## Acceptance criteria checklist - [x] Template at freeze caps issues successfully. - [x] Tampered catalog row over caps fails at startup (CI) and cannot issue if slipped through. - [x] `dotnet test` covers economy validation gates. ## Implementation reconciliation (shipped) - **`ContractEconomyValidation.TryValidateTemplate`** — delegates to `PrototypeE7M4ContractCatalogRules.TryGetBandCapGateError` + `TryGetCrossRefError` for one template row against live registries. - **`ContractGeneratorOperations.TryIssue`** — extended signature (encounter/item/skill/faction registries); economy validation after template selection, before persist. - **`ContractGeneratorReasonCodes`:** `economy_cap_exceeded`, `invalid_reward_bundle`. - **Tests:** `ContractEconomyValidationTests` (4 unit), `ContractGeneratorOperationsTests` (+2 deny paths), shared `ContractPrototypeTestFixtures`; all `TryIssue` call sites updated; **912** tests green. - **Docs:** `server/README.md`, `E7_M4_ContractMissionGenerator.md`, alignment register, module dependency register. ## Technical approach ### 1. `ContractEconomyValidation` (`Game/Contracts/`) Static helper (mirror `MoveCommandValidation` shape): ```csharp public static bool TryValidateTemplate( ContractTemplateRow template, IEncounterDefinitionRegistry encounterRegistry, IItemDefinitionRegistry itemRegistry, ISkillDefinitionRegistry skillRegistry, IFactionDefinitionRegistry factionRegistry, out string? reasonCode) ``` **Flow:** 1. Build single-row `Dictionary` for the selected template id. 2. **`TryGetBandCapGateError`** — if non-null → `reasonCode = economy_cap_exceeded`, return false. 3. **`TryGetCrossRefError`** — pass `knownEncounterIds`, `knownFactionIds`, `skillDefsById` from registries (same inputs as `ContractTemplateCatalogLoader`). 4. If cross-ref error → `reasonCode = invalid_reward_bundle`, return false. 5. Otherwise return true. Internal detail strings from gate helpers stay server-side; orchestrator exposes only stable reason codes. ### 2. `ContractGeneratorOperations.TryIssue` After successful `TrySelectTemplate`, before `MakeDeterministicInstanceId` / `TryCreateActive`: ```csharp if (!ContractEconomyValidation.TryValidateTemplate( template!, encounterRegistry, itemRegistry, skillRegistry, factionRegistry, out var economyReason)) { return Deny(economyReason!); } ``` Extend signature with: - `IEncounterDefinitionRegistry encounterRegistry` - `IItemDefinitionRegistry itemRegistry` - `ISkillDefinitionRegistry skillRegistry` - `IFactionDefinitionRegistry factionRegistry` Update all call sites (generator tests, completion tests, encounter tests, quest fixture test, integration tests). ### 3. Reason codes | Code | When | |------|------| | **`economy_cap_exceeded`** | Item qty, skill XP, or rep grant exceeds band policy | | **`invalid_reward_bundle`** | Unknown encounter/item/skill/faction ref or skill missing `mission_reward` allowlist | ### 4. Docs - **`server/README.md`** — extend contract generator reason-code table; note issue-time economy lint (NEO-150). - **`E7_M4_ContractMissionGenerator.md`** — economy validation at issue + stub: “Prototype band caps enforce Slice 4 policy; full E6.M4 `RewardParityMap` / E3.M5 balance dashboards deferred.” - **`documentation_and_implementation_alignment.md`** — E7.M4 row note after implementation. ### 5. Parity stub (E6.M4 / E3.M5) One short subsection under E7.M4 module doc — no new E6.M4 implementation; documents that issue-time lint is **prototype band caps only**, not equivalent-power parity. ## Files to add | Path | Purpose | |------|---------| | `docs/plans/NEO-150-implementation-plan.md` | This plan | | `server/NeonSprawl.Server/Game/Contracts/ContractEconomyValidation.cs` | `TryValidateTemplate` — band caps + cross-ref for one template | | `server/NeonSprawl.Server.Tests/Game/Contracts/ContractEconomyValidationTests.cs` | AAA unit tests for validation helper (at-cap, over-cap, unknown ref) | ## Files to modify | Path | Rationale | |------|-----------| | `server/NeonSprawl.Server/Game/Contracts/ContractGeneratorReasonCodes.cs` | Add `EconomyCapExceeded`, `InvalidRewardBundle` constants | | `server/NeonSprawl.Server/Game/Contracts/ContractGeneratorOperations.cs` | Invoke `TryValidateTemplate` before persist; extend `TryIssue` signature | | `server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs` | Wire registries into helper; add over-cap + unknown-ref deny tests; update `TryIssue` calls | | `server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsIntegrationTests.cs` | Pass registries from DI | | `server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorPersistenceIntegrationTests.cs` | Pass registries from DI | | `server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsTests.cs` | Update `TryIssue` call sites | | `server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsIntegrationTests.cs` | Update `TryIssue` call sites | | `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs` | Update `TryIssue` call sites | | `server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs` | Update `TryIssue` call sites | | `server/README.md` | Document economy validation at issue + reason codes | | `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | NEO-150 economy validation + E6.M4/E3.M5 parity stub | ## Tests | File | What it covers | |------|----------------| | `ContractEconomyValidationTests` | At-cap pass (prototype freeze row); over-cap deny → `economy_cap_exceeded`; unknown item id → `invalid_reward_bundle` | | `ContractGeneratorOperationsTests` | End-to-end: tampered template in in-memory registry denies issue with correct reason codes; prototype happy path still passes | | `ContractGeneratorOperationsIntegrationTests` | DI path with real catalog still issues successfully | | `ContractTemplateCatalogLoaderTests` (existing) | Startup fail on over-cap catalog — AC “fails at startup (CI)”; no change expected | | CI | `python3 scripts/validate_content.py` unchanged; `dotnet test` green | Manual Godot QA: **none** (server policy; capstone NEO-154). ## Open questions / risks | Question / risk | Agent recommendation | Status | |-----------------|----------------------|--------| | Duplicate validation vs startup load | Accept small cost — issue-time lint is explicit Slice 4 AC defense-in-depth | `adopted` | | `TryIssue` signature churn | Update all call sites in this story; NEO-151 HTTP wrapper inherits extended signature | `adopted` | | Client deny copy for `invalid_reward_bundle` | Defer readable copy to NEO-153; server ships stable code now | `deferred` | | Telemetry `reward_anomaly` at economy deny | Defer comment-only hooks to NEO-152 | `deferred` | | **NEO-151 HTTP `TryIssue` wiring** | Production DI path must pass the same four registries already used in `ContractGeneratorOperationsIntegrationTests` (`IEncounterDefinitionRegistry`, `IItemDefinitionRegistry`, `ISkillDefinitionRegistry`, `IFactionDefinitionRegistry`) — verify at NEO-151 kickoff | `pending` | ## Client counterpart None (server policy). **NEO-153** will surface `economy_cap_exceeded` and `invalid_reward_bundle` after **NEO-151** HTTP lands.