# NEO-144 — E7M4-01: ContractTemplate catalog + schemas + CI **Linear:** [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) **Branch:** `NEO-144-e7m4-01-contracttemplate-catalog-schemas-ci` **Backlog:** [E7M4-pre-production-backlog.md](E7M4-pre-production-backlog.md) — **E7M4-01** **Module:** [E7_M4_ContractMissionGenerator.md](../decomposition/modules/E7_M4_ContractMissionGenerator.md) **Pattern:** [NEO-133](NEO-133-implementation-plan.md) (faction/quest schemas + catalog + `validate_content.py`); [NEO-124](NEO-124-implementation-plan.md) (`completionRewardBundle` reuse) **Precursors:** [E5.M3](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) **`Ready`** — **`prototype_combat_pocket`**; [E7.M2](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) **`Ready`** — **`quest-reward-bundle`** / grant rows; [E7.M3](../decomposition/modules/E7_M3_FactionReputationLedger.md) **`Ready`** — faction catalog + rep grants **Blocks:** [NEO-145](https://linear.app/neon-sprawl/issue/NEO-145) (server contract template catalog load) **Client counterpart:** none (infrastructure-only); playable loop is **NEO-154** (E7M4-11) ## Goal Lock contract content shape and CI validation before server load (NEO-145+). ## Kickoff clarifications Decisions are frozen in [E7M4-pre-production-backlog.md — Kickoff decisions](E7M4-pre-production-backlog.md#kickoff-decisions) and [E7_M4 — Prototype Slice 4 freeze](../decomposition/modules/E7_M4_ContractMissionGenerator.md#prototype-slice-4-freeze-e7m4-01). No blocking `AskQuestion` items. | Topic | Question | Agent recommendation | Answer | |-------|----------|----------------------|--------| | — | — | — | **No blocking decisions** — freeze table, band caps, single-template roster, and `$ref` reuse are fully specified in backlog + module doc. | **Additional defaults (no kickoff question — settled by backlog / landed code):** - **JSON naming:** camelCase (`encounterTemplateId`, `zoneDifficultyBand`, `minFactionStanding`, `completionRewardBundle`, `itemGrants`, `skillXpGrants`) — matches quest/faction catalogs. - **`minFactionStanding` shape:** `$ref` [`faction-gate-rule.schema.json`](../../content/schemas/faction-gate-rule.schema.json) (`factionId` + `minStanding`) — prototype row **`prototype_faction_grid_operators`** / **0** (open issuance). - **`objectiveKind`:** required enum **`encounter_clear`** only in Slice 4 v1; pairs with **`encounterTemplateId`** (not quest-style objective graph). - **Catalog envelope:** **`schemaVersion`: 1**, top-level **`contractTemplates`** array — mirrors quest/faction file layout. - **Band caps in CI (E7M4-01):** implement **`PROTOTYPE_E7M4_BAND1_*`** constants + `_prototype_e7m4_band_cap_gate` in `validate_content.py` now (band **1**: item qty ≤ **10**, skill XP ≤ **25**, rep ≤ **10** per grant row); E7M4-07 reuses same policy at server issue time. - **Over-cap AC proof:** lint runs on live catalog (at-cap template passes); tampering any grant above cap fails `python scripts/validate_content.py` — no separate invalid fixture file (NEO-124 / NEO-133 precedent). - **Server loader / stores / HTTP / Godot:** **Out of scope** (NEO-145+). - **`dotnet test`:** **No server changes expected** — contract catalog is not loaded at startup until NEO-145. ## Scope and out-of-scope **In scope (from Linear + [E7M4-01](E7M4-pre-production-backlog.md#e7m4-01--contracttemplate-catalog--schemas--ci)):** - `content/schemas/contract-template.schema.json`, `content/schemas/contract-seed.schema.json` (issue/audit request shape for NEO-147+; no catalog JSON file in this story). - Reuse **`quest-reward-bundle`** / **`reputation-grant-row`** / **`reward-grant-row`** / **`quest-skill-xp-grant`** `$ref`s for **`completionRewardBundle`** on templates. - `content/contracts/prototype_contract_templates.json` — one frozen template row per module freeze table. - `scripts/validate_content.py`: contract schema validation; encounter / item / skill / faction cross-refs; single-template allowlist; template + bundle freeze gate; band-cap lint. - Designer note + CI line in [E7_M4](../decomposition/modules/E7_M4_ContractMissionGenerator.md); **`content/README.md`** E7 Slice 4 contracts paragraph; [CT.M1](../decomposition/modules/CT_M1_ContentValidationPipeline.md) contract-catalog PR gate bullet. **Out of scope (from Linear + backlog):** - Server loader, `IContractTemplateRegistry`, instance store, generator, HTTP, Godot (NEO-145–NEO-154). - Live **`GET …/zone`** read (E4.M1); content-only **`zoneDifficultyBand`** on template + optional seed field. - Full E6.M4 parity map; server **`ContractEconomyValidation`** at issue time (NEO-147 / E7M4-07). ## Acceptance criteria checklist - [x] PR gate validates contract template JSON. - [x] Exactly one prototype template id with encounter + bundle cross-refs. - [x] Bundle exceeding band caps fails CI. ## Implementation reconciliation (shipped) - **Schemas:** [`contract-template.schema.json`](../../content/schemas/contract-template.schema.json), [`contract-seed.schema.json`](../../content/schemas/contract-seed.schema.json) — `$ref`s to `quest-reward-bundle`, `faction-gate-rule`, grant rows. - **Catalog:** [`prototype_contract_templates.json`](../../content/contracts/prototype_contract_templates.json) — one frozen template per E7M4-01 freeze table. - **CI:** `PROTOTYPE_E7M4_*` gates in [`validate_content.py`](../../scripts/validate_content.py) — roster, freeze (incl. empty **`reputationGrants`** via E7M3 bundle normalizer), cross-refs (all catalog rows), band caps keyed by **`PROTOTYPE_E7M4_BAND_CAPS`**; **`contract-seed`** schema smoke fixture; contract catalog wired into PR gate `main()`. - **Review follow-up (2026-06-20):** freeze gate uses `_normalize_e7m3_completion_bundle`; cross-ref/band-cap gates iterate all loaded template rows; **`PROTOTYPE_E7M4_BAND_CAPS`** comment points to NEO-147 C# port. - **Docs:** `content/README.md` E7 Slice 4 paragraph; E7_M4 CI line; CT.M1 contract-catalog bullet; E7M4-01 backlog checkboxes. - **Server:** no changes (823 tests green — no contract loader until NEO-145). - **Band-cap negative proof:** `_prototype_e7m4_band_cap_gate` rejects `scrap_metal_bulk` ×11 locally (not committed). ## Technical approach ### 1. Prototype contract template freeze (E7M4-01) | `ContractTemplate.id` | `zoneDifficultyBand` | `objectiveKind` | `encounterTemplateId` | `minFactionStanding` | `completionRewardBundle` | |-------------------------|----------------------|-----------------|-------------------------|----------------------|---------------------------| | **`prototype_contract_clear_combat_pocket`** | **1** | **`encounter_clear`** | **`prototype_combat_pocket`** | **`prototype_faction_grid_operators`** **0** | **`scrap_metal_bulk` ×5**; **`salvage`** **15** (`mission_reward`) | **Band 1 caps (CI + future E7M4-07):** max **10** per item grant quantity; max **25** skill XP per grant; max **10** rep per grant row. ### 2. `contract-template.schema.json` Draft 2020-12 object; `additionalProperties: false`. Required: - **`id`** — pattern `^[a-z][a-z0-9_]*$` - **`displayName`** — non-empty string - **`zoneDifficultyBand`** — integer ≥ 1 - **`objectiveKind`** — enum **`encounter_clear`** (Slice 4 v1 only) - **`encounterTemplateId`** — pattern `^[a-z][a-z0-9_]*$` - **`minFactionStanding`** — `$ref` faction-gate-rule (prototype uses Grid Operators **0**) - **`completionRewardBundle`** — `$ref` quest-reward-bundle ### 3. `contract-seed.schema.json` Draft 2020-12 object for future **`POST …/contracts/issue`** body / audit (NEO-147+). Required: **`playerId`**, **`templateId`**, **`seedBucket`** (non-empty string — date bucket or client counter). Optional: **`zoneDifficultyBand`** (integer ≥ 1; prototype default **1** when omitted). No catalog file in E7M4-01 — schema-only so CI can `$ref` it from docs/tests later. ### 4. Catalog — `content/contracts/prototype_contract_templates.json` ```json { "schemaVersion": 1, "contractTemplates": [ { "id": "prototype_contract_clear_combat_pocket", "displayName": "Clear Combat Pocket (Repeat)", "zoneDifficultyBand": 1, "objectiveKind": "encounter_clear", "encounterTemplateId": "prototype_combat_pocket", "minFactionStanding": { "factionId": "prototype_faction_grid_operators", "minStanding": 0 }, "completionRewardBundle": { "itemGrants": [{ "itemId": "scrap_metal_bulk", "quantity": 5 }], "skillXpGrants": [{ "skillId": "salvage", "amount": 15 }] } } ] } ``` ### 5. `validate_content.py` gates Add **`CONTRACTS_DIR`**, schema paths, **`PROTOTYPE_E7M4_TEMPLATE_IDS`**, **`PROTOTYPE_E7M4_TEMPLATE_FREEZE`**, **`PROTOTYPE_E7M4_BAND_CAPS`** (band-keyed dict; supersedes early draft **`PROTOTYPE_E7M4_BAND1_*`** naming). 1. **`_contract_template_validator()`** — registry with contract-template + quest-reward-bundle + nested grant `$ref`s (same pattern as `_quest_def_validator`). 2. **`_validate_contract_catalogs`** — load `content/contracts/*_contract_templates.json`; schema validate each row; reject duplicate **`id`**; require **`schemaVersion` 1**. 3. **`_prototype_e7m4_template_roster_gate`** — exactly one id: **`prototype_contract_clear_combat_pocket`**. 4. **`_prototype_e7m4_template_freeze_gate`** — row matches freeze table (band, objective kind, encounter id, min standing, bundle contents). 5. **`_prototype_e7m4_cross_ref_gate`** — **`encounterTemplateId`** ∈ loaded encounter catalog; bundle **`itemId`** / **`skillId`** ∈ frozen item/skill catalogs; **`minFactionStanding.factionId`** ∈ faction catalog; skill XP targets list **`mission_reward`** in **`allowedXpSourceKinds`** (E7M2 rule). 6. **`_prototype_e7m4_band_cap_gate`** — for each template, compare **`zoneDifficultyBand`** to cap table; fail when any item qty, skill XP amount, or rep amount exceeds band limits (prototype band **1** only). Wire into **`main()`** after encounter + faction validation (cross-refs need encounter and faction maps) and before or after quest validation (order agnostic; prefer after faction, alongside quest bundle rules). Update module docstring at top of `validate_content.py` to list contract catalogs (NEO-144). ### 6. Documentation - **`content/README.md`:** add **`contracts/`** table row + **Prototype E7 Slice 4 — contracts (NEO-144)** paragraph (one template id, encounter cross-ref, repeat payout bundle, band-cap CI). - **`E7_M4_ContractMissionGenerator.md`:** add CI landed line under freeze section when complete (catalog + schemas + validate_content gates). - **`CT_M1_ContentValidationPipeline.md`:** bullet for contract template validation (schema, roster, freeze, cross-refs, band caps). ### 7. Implementation order 1. Schemas (`contract-template`, `contract-seed`). 2. `prototype_contract_templates.json`. 3. `validate_content.py` validator + gates + constants. 4. Docs (`content/README.md`, E7_M4, CT.M1). 5. Run `python3 scripts/validate_content.py` and `dotnet test` (expect green — no server catalog load yet). ## Files to add | Path | Purpose | |------|---------| | `docs/plans/NEO-144-implementation-plan.md` | This plan. | | `content/schemas/contract-template.schema.json` | `ContractTemplate` row shape with `$ref`s to bundle + faction gate rule. | | `content/schemas/contract-seed.schema.json` | Issue/audit request shape for NEO-147+ (schema-only in this story). | | `content/contracts/prototype_contract_templates.json` | One frozen template row per E7M4-01 freeze table. | ## Files to modify | Path | Rationale | |------|-----------| | `scripts/validate_content.py` | Contract catalog load, schema registry, roster/freeze/cross-ref/band-cap gates; module docstring. | | `content/README.md` | Document `content/contracts/`, new schemas, Slice 4 prototype template + band-cap CI. | | `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | CI/catalog landed note under freeze section. | | `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | PR gate paragraph for contract templates. | ## Tests | Layer | What | |-------|------| | CI | `python3 scripts/validate_content.py` — primary AC surface (schema, roster, freeze, cross-refs, band caps on live catalog). | | Manual negative | Edit template bundle to **`scrap_metal_bulk` ×11** (or **`salvage` 26**) locally → validator exits non-zero (documents over-cap AC without committed bad data). | | Server | **`dotnet test`** — regression only; no new `[Fact]` expected (no server contract loader until NEO-145). | Manual Godot QA: **none** (content-only; capstone is NEO-154). ## Open questions / risks | Question / risk | Agent recommendation | Status | |-----------------|----------------------|--------| | **Band caps in E7M4-01 vs E7M4-07** | CI lint in E7M4-01; server **`TryIssue`** re-validates in E7M4-07 with shared cap constants ported to C#. **NEO-145 kickoff:** mirror **`PROTOTYPE_E7M4_BAND_CAPS`** keys in C# `ContractEconomyValidation`. | **adopted** | | **`contract-seed` without catalog JSON** | Schema-only in E7M4-01 per backlog; validated when HTTP DTOs land in NEO-147. | **adopted** | | **`minFactionStanding` optional on future templates** | Required on prototype row; schema requires field for v1 (single open template). Relax to optional when second template lands. | **adopted** | | **Rep grants on contract bundles** | Prototype bundle is item + skill XP only; band-cap gate still checks **`reputationGrants`** if present (forward-compatible). | **adopted** |