# 03 — Coaches & the Coaching Carousel

Documentation suite for **CoachCarouselRecruitTool**. Synthesized 2026-07-14. All table/field facts verified
against real saves unless tagged UNVERIFIED. Source tags as in doc 01 (`[api] [fc] [pipe] [jersey] [carousel]
[web] [schema] [dump] [diff]`).

---

## Table of contents

1. [Coach table identity & schema lineage](#1-coach-table-identity--schema-lineage)
2. [Coach fields — the ones that matter](#2-coach-fields--the-ones-that-matter)
3. [A real Coach record](#3-a-real-coach-record)
4. [Role encoding](#4-role-encoding)
5. [Team ↔ coach linkage](#5-team--coach-linkage)
6. [Contracts, job security, prestige, alma mater](#6-contracts-job-security-prestige-alma-mater)
7. [Carousel machinery: JobOpening, offers, StaffHiringEval](#7-carousel-machinery-jobopening-offers-staffhiringeval)
8. [Carousel tunables (StaffHiringTuning)](#8-carousel-tunables-staffhiringtuning)
9. [Carousel lifecycle: when data exists and what survives](#9-carousel-lifecycle-when-data-exists-and-what-survives)
10. [CoachTransactionHistoryEntry — the persistent carousel log ★](#10-coachtransactionhistoryentry--the-persistent-carousel-log-)
11. [Post-carousel movement signature (single-save detection)](#11-post-carousel-movement-signature-single-save-detection)
12. [Retirement & comeback machinery](#12-retirement--comeback-machinery)
13. [Mutation recipes from the KivJoy carousel tool](#13-mutation-recipes-from-the-kivjoy-carousel-tool)
14. [Dead fields — do not build on these](#14-dead-fields--do-not-build-on-these)
15. [Sources](#sources)

---

## 1. Coach table identity & schema lineage

- Table: `Coach`, **uniqueId 1860529246**, tableId 4173 (this build), capacity 632, **497 non-empty in every
  examined save** (the pool is conserved; carousel moves change rows in place). table2-backed strings.
  Beware the decoy `Coach` table (uid 2796679390, cap 1). `[dump §1,§5][diff §5]`
- **Coach row index is a stable identity within a dynasty** (user coach "Ryan VanVuren" is row 433 in all
  five saves; Kiffin 222, DeBoer 108). Use `(LeagueID, coachRow)` as the durable coach key. `[diff §2.2]`
- Schema inheritance (attributes are pre-flattened in the bundle — no base-chain walking needed):
  `Person` → `StaffPerson` (22 members) → `CoachingStaffPerson` (79) → `Coach` (137). Siblings of Coach
  (`Owner`, `Scout`, `Trainer`, `GeneralManager`, `PlayerPersonnel`) exist as schema classes; UNVERIFIED
  whether all are instantiated in CFB27 saves. `[schema §5]`

## 2. Coach fields — the ones that matter

Bit widths from the schema bundle + live offset tables; live observations from DYNASTY-QQ/QQ2/QQ2b.

### 2.1 Identity & employment

| Field | Type / bits | Live observations | Source |
|---|---|---|---|
| `FirstName` / `LastName` | string 17 / 21 (table2) | "Scott" / "Abell" | `[schema §5][dump §5]` |
| `Name` | string 18 | "S. Abell" — display short form | `[dump §5]` |
| `Age` | int 7b | 58 | `[dump §5]` |
| `Position` | enum CoachPosition 8b | HC=144/156, OC=161/164, DC=176/191, +1 shared ST placeholder | `[dump §5]` |
| `PrevPosition` | enum CoachPosition 8b, def Invalid_ | set on movers but NOISY (see §11) | `[diff §2.2]` |
| `TeamIndex` | int 8b, def 255 | joins `Team.TeamIndex`; 255 = unassigned | `[schema §5][dump §5]` |
| `PrevTeamIndex` | int 8b, def 255 | **the reliable "moved" marker** — 255 = never moved | `[diff §2.2]` |
| `SeasonsWithTeam` | int 7b | 0 (or 1 — see §11) right after a move | `[diff §2.2]` |
| `YearsCoaching` | int 7b | 8 | `[dump §5]` |
| `IsUserControlled` | bool | exactly 1 true per save — identifies the human | `[dump §5][pipe §2]` |
| `IsCreated` / `IsLegend` / `IsNIL` | bool | | `[schema §5]` |
| `AlmaMater` | int 8b, schema range 1100–1300 | observed raw 88, 42, 67, 103 — see §6.3 | `[schema §5][dump §5]` |
| `HomeState` / `HomeTown` | enum StateName / ref City | "Virginia" | `[dump §5]` |

### 2.2 Contract & progression

| Field | Type / bits | Notes | Source |
|---|---|---|---|
| `ContractStatus` | enum StaffPersonContractStatus 4b | full enum §6.1 | `[schema §10][dump §5]` |
| `ContractLength` / `ContractYearsRemaining` | int 3b (0–7) / 5b (0–31) | | `[schema §5]` |
| `ContractSalary` | int 14b 0–16383 | observed 0 — UNVERIFIED whether used in CFB27 | `[schema §5][dump §5]` |
| `NumContractOffers` | int 4b 0–12 | count of pending carousel offers naming this coach; 0 outside window | `[carousel §1d]` |
| `Level` | int 7b 0–100 | obs 11–39 | `[dump §5]` |
| `ExperiencePoints` | int 20b 0–1,000,000 | | `[schema §5]` |
| `CoachPoints` | int 12b 0–4095 | talent-tree currency | `[schema §5]` |
| `LegacyScore` | int 16b | | `[schema §5]` |
| `EarnedContractPoints_ThisYear/_LastYear/_TwoYearsAgo` | int −300–300 | contract-points economy (§8) | `[schema §5]` |
| `CurrentContractExpectation` / `ContractExpectationProgress` | enum ContractExpectations | e.g. `Win5Games` / `Count_` | `[dump §5]` |
| `Probation` | bool | | `[schema §5]` |

### 2.3 Job security (fire risk)

| Field | Type / bits | Live | Source |
|---|---|---|---|
| `CurrentJobSecurityPercentage` | int 7b 0–100 | obs 25–99 | `[dump §5]` |
| `CurrentJobSecurityPercentageRank` | int 9b 0–500 | | `[schema §5]` |
| `CurrentJobSecurityStatus` | enum JobSecurityStatus 3b {Safe=0, SafeForNow=1, Low=2, HotSeat=3, Invalid=4} | QQ: Safe 344 / HotSeat 63 / SafeForNow 46 / Low 40 / Invalid 4; QQ2: HotSeat balloons to 194 | `[schema §10][dump scan.json]` |
| `SeasonStartJobSecurityStatus` | same enum | baseline for the season | `[carousel §1d]` |

### 2.4 Prestige

| Field | Type / bits | Live | Source |
|---|---|---|---|
| `CoachPrestige` | enum LetterGrade 4b {Aplus=0 … F=12, COUNT/Incomplete=13} | full A+..F spread observed + 4 bogus `COUNT` rows | `[schema §10][dump scan.json]` |
| `CoachPrestigeScore` | int 14b 0–10000 | obs 0–660 (early dynasty) | `[dump §5]` |

### 2.5 Recruiting-relevant coach fields

| Field | Type | Notes | Source |
|---|---|---|---|
| `PrimaryPipeline` | enum Pipeline 6b | the coach's personal recruiting region — travels with him between jobs (e.g. "Tidewater") | `[pipe §2][dump §5]` |
| `DominantArchetype` | enum CoachTalentArcheType | e.g. `EliteRecruiter`, `SchemeGuru` | `[dump §5][web §6]` |
| `SpecialtyType` | enum CoachSpecialtyType | `Offense` observed | `[dump §5]` |
| `ActiveTalentTree` | ref | talent-tree effects surface per-team in `CoachTalentEffects` | `[dump §5][web §6]` |
| `OffensiveScheme`/`DefensiveScheme`/`OffensivePlaybook`/`DefensivePlaybook` | refs | ref tableIds ≥ ~16000 do NOT resolve via getTableById (blob-space refs) — treat as opaque | `[dump §15]` |

## 3. A real Coach record

Coach row 0 of DYNASTY-QQ (abridged; full record in `research/dumps/QQ/Coach.1860529246.sample.json`):

```json
{ "FirstName": "Scott", "LastName": "Abell", "Name": "S. Abell",
  "Position": "HeadCoach", "PrevPosition": "Invalid_",
  "TeamIndex": 77, "PrevTeamIndex": 255, "SeasonsWithTeam": 1, "YearsCoaching": 8, "Age": 58,
  "ContractStatus": "First_Active", "ContractLength": 4, "ContractYearsRemaining": 4, "ContractSalary": 0,
  "CurrentJobSecurityStatus": "Safe", "CurrentJobSecurityPercentage": 85, "CurrentJobSecurityPercentageRank": 232,
  "CoachPrestige": "Dplus", "CoachPrestigeScore": 290, "Level": 21,
  "DominantArchetype": "EliteRecruiter", "SpecialtyType": "Offense", "PrimaryPipeline": "Tidewater",
  "HomeState": "Virginia", "AlmaMater": 88, "NumContractOffers": 0,
  "COACH_FIREREPORTED": true, "COACH_RESIGNREPORTED": true,
  "COACH_LASTTEAMFIRED": 0, "COACH_LASTTEAMRESIGNED": 0,
  "CharacterVisuals": "ref->CharacterVisuals#8", "SeasonStats": "ref->SeasonCoachStats#44",
  "CareerStats": "ref->CareerCoachStats#110", "ActiveTalentTree": "ref->ActiveTalentTree#285" }
```

Note `ContractStatus: "First_Active"` — that is the **alias name for Signed(0)** (see §6.1) and
`IsNIL: true` on a coach row (real-likeness flag; semantics on coaches UNVERIFIED). `[dump/QQ Coach sample]`

## 4. Role encoding

`Coach.Position`, `JobOpening.Position` and `StaffPersonContractOffer.ContractPosition` all use the 8-bit
**CoachPosition** enum (values, from the schema and live dumps `[schema §10][dump §5]`):

```
First_/HeadCoach = 0        OffensiveCoordinator = 1     DefensiveCoordinator = 2
NumCollegeCoaches/SpecialTeams = 3
Owner = 4   Scout = 5   Trainer = 6   GeneralManager = 7   PlayerPersonnel = 8   (NFL-side leftovers)
Max_ = 9    Invalid_ = 255
```

- madden-franchise returns the **first alias** for shared raw values: raw 3 reads back `'NumCollegeCoaches'`
  (not `'SpecialTeams'`); raw 0 can read `'First_'`. Compare on raw values or alias sets. `[carousel §1a][dump §5]`
- Distribution (QQ): HeadCoach 144, OC 161, DC 191, NumCollegeCoaches 1 — a **single shared placeholder
  Special-Teams coach** (Coach row 375) that every sampled Team's `SpecialTeamsCoach` points at. `[dump §5]`
- Unrelated but confusable: the **PositionE** enum (player positions) also contains coach-role pseudo-members
  `HC_CFM=35, OC_CFM=36, DC_CFM=37, Owner_CFM=38` used in depth-chart/CFM contexts — these are NOT what
  `Coach.Position` uses. `[jersey §5][schema §10]`

## 5. Team ↔ coach linkage

Redundant two-sided linkage — any hire/fire writer must keep BOTH sides consistent `[carousel §7]`:

1. **Team side (authoritative for "who coaches here")**: `Team.HeadCoach`, `Team.OffensiveCoordinator`,
   `Team.DefensiveCoordinator`, `Team.SpecialTeamsCoach` — 32-bit refs to Coach rows, verified live.
   `[carousel §1e][dump §7]`
2. **Coach side**: `Coach.TeamIndex` (int, joins `Team.TeamIndex`) + `Coach.Position`. `[carousel §2]`
3. Joins: `Coach.TeamIndex → Team.TeamIndex` is an **int join, not a row index** (Team row 35 = FIU has
   TeamIndex 115). And TeamIndex is NOT unique: the 5 FCS pseudo-teams all share TeamIndex 255 — key teams
   by row, expect index→name maps to collide on 255. `[dump §7][diff §5][web §6, 2SRC]`
4. The engine fully propagates both sides by offseason stage 2 after a carousel: 106 of 143 Team rows had
   HC/OC/DC (and/or prestige) rewritten, and **zero coaches remain in Pending\* statuses**. `[diff §2.1]`
5. `Team.UserCharacter` (ref, type UserEntity) non-null ⇒ user-controlled school (`.includes('1')` idiom).
   Alternative: the Coach row with `Position==='HeadCoach' && IsUserControlled===true` → TeamIndex. `[jersey §2][pipe §2]`
6. `Team.StaffPersonBlacklist: StaffPerson[]` exists (schools that won't hire someone — semantics UNVERIFIED).
   `[schema §6]`

## 6. Contracts, job security, prestige, alma mater

### 6.1 StaffPersonContractStatus enum (4 bits) — full domain with aliases

Raw-value table (formatted name returned by madden-franchise is the FIRST alias listed): `[dump §5][schema §10]`

| raw | alias names | meaning |
|---|---|---|
| 0 | `First_Active`, `Signed` | active/signed |
| 1 | `Expiring` | final contract year |
| 2 | `First_Pending`, `PendingFire` | in-carousel: about to be fired |
| 3 | `PendingNFL` | in-carousel: leaving for NFL |
| 4 | `PendingRenewal` | in-carousel: renewal pending |
| 5 | `Last_Active`, `PendingRetire` | in-carousel: about to retire |
| 6 | `Last_Pending`, `PendingHire` | in-carousel: hired somewhere |
| 7 | `FreeAgent` | unemployed pool |
| 8 | `Retired` | retired |
| 9 | `Deleted` | |
| 10 | `None` | |

Live: QQ = First_Active 429 / FreeAgent 68; QQ2 (post-carousel, portal stage) = First_Active 429 /
FreeAgent 61 / Retired 7 — **no Pending\* survives to the portal stage**. The Pending values appear in
`CoachTransactionHistoryEntry.ContractStatus` stamps (§10). `[dump scan.json][diff §2.2]`

### 6.2 Job security & firing dials

The firing pipeline runs off `CurrentJobSecurityStatus`: `StaffHiringTuning.CoachFiringStatusLevel =
JobSecurityStatus:HotSeat` (coaches at HotSeat are fire-eligible), `HC_FiredRangeMin/Max` 5/15,
`Coord_FiredRangeMin/Max` 5/15, thresholds `HC/OC/DC_SafeThreshold` 90 / `_LowThreshold` 80 /
`_HotseatThreshold` 70. `LeagueSetting.CoachFiring` (`ON`/`CPU_ONLY`/`OFF`) can disable firings league-wide.
`[schema §2][dump §12]`

### 6.3 Alma mater — open discrepancy

`AlmaMater` is an int whose **schema range is 1100–1300 (8 bits)** while live reads return 88, 42, 67, 103.
An 8-bit field cannot store 1100–1300, so the stored raw is presumably `value − 1100` (madden-franchise adds
no offset for positive minValue). UNVERIFIED: whether `raw + 1100` indexes some asset/team id space; it is
NOT established that AlmaMater equals a TeamIndex. Needed for any "alumni loyalty" features; also relevant:
`Team.DesiresAlmuni: bool` and `CoachTalentEffects.Pipeline_LevelBoost_AlmaMater`. `[schema §5][dump §5][web §6]`

### 6.4 Prestige

- `Coach.CoachPrestige` (LetterGrade) + `CoachPrestigeScore` (0–10000) = coach-side prestige.
- `Team.TeamPrestige` int 0–10 (stars = value/2), `PrestigeRank` (1 = best), `TeamPrestigeBias` 0–50,
  `PrestigeDisplay` is a **string** (UI text; empty in saves — never parse for logic). TeamPrestige moves
  season to season (Alabama 10→8, LSU 7→9…) — read live values. `[schema §13-14][dump §7][diff §2.1]`
- The engine's own "better job" math: `StaffHiringTuning.PrestigeCompareScoreSpline` (+Buffer 50),
  `PoachThresholdHC = 2`, `PoachThresholdCoord = 1`, `BetterJobInterestModifier` 0 / `WorseJobInterestModifier`
  50 — a ready analog for our follow-coach "similar-or-better prestige" rule. `[schema §2]`

## 7. Carousel machinery: JobOpening, offers, StaffHiringEval

### 7.1 `JobOpening` (uid 263453863, cap 408) — 11 fields, all verified

| Field | Type / bits | Meaning |
|---|---|---|
| `Team` | ref Team 32 | school with the opening (DESTROYED in freed rows — first field = free-list pointer) |
| `Position` | enum CoachPosition 8b | HC/OC/DC (live histogram of stale rows: HC 90 / OC 73 / DC 89) |
| `PrevCoach` | ref Coach 32 | who vacated — reads null in every stale row (cleared or never persisted) |
| `SelectedCoach` | ref Coach 32 | who was hired — likewise null post-window |
| `Filled` | bool 1 | true on all used stale rows |
| `IsEmergentJobOpening` | bool 1 | false on all sampled rows; UNVERIFIED trigger (name suggests mid-carousel openings when a coach leaves for another job) |
| `Reason` | enum CoachLeaveReason (stored 32b live; 3b of value space) | **None=0, Fired=1, Retired=2, Pro=3, NewJob=4, ContractEnding=5** — all six confirmed in live stale data |
| `ContractOfferList` | ref StaffPersonContractOffer[] 32 | the ordered candidate list |
| `InterestedUserTeamsList` | ref Team[] 32 | UNVERIFIED semantics |
| `FinalContractProgramPoints` / `HighestOfferedProgramPoints` | int 11b 0–2000 | UNVERIFIED: final/highest bid |

Sources: `[carousel §1c][schema §2][dump §10][diff §2.4]`. Stale-row Reason histogram (QQ2, 252 freed rows
mixing two cycles): ContractEnding 119, Fired 99, Retired 13, Pro 11, NewJob 10. `[diff §2.4]`

### 7.2 `StaffPersonContractOffer` (uid 674348040, cap 804) — 14 fields

One row = "team X considers coach Y for position Z". `[carousel §1a][schema §2][dump §10]`

| Field | Type / bits (live) | Notes |
|---|---|---|
| `Team` | ref Team | hiring school |
| `StaffPerson` | ref **StaffPerson** (base class) | in practice points at Coach rows, but schema-legally may point at Scout/Trainer — validate the ref's target table |
| `StaffPersonTeam` | ref Team | candidate's current school |
| `ContractPosition` | enum CoachPosition 8b | group key |
| `Status` | enum ContractOfferStatus 3b | **Accepted=0, Declined=1, Pending=2, Withdrawn=3, NoOffer=4, Invalid_=7**; stale histogram Pending 389 / Accepted 415 |
| `TeamInterestInStaffPerson` | int 9b 0–280 | primary ranking key (UI shows /100; schema max 280 — engine tolerance for >100 UNVERIFIED) |
| `BaseStaffPersonInterestInOffer` | int 9b 0–280 | coach's base interest |
| `AdjustedStaffPersonInterestInOffer` | int 7b 0–100 | coach interest after adjustments |
| `OfferIndex` | int, physically 17b, value range 0–6 | rank within its (team, position) group |
| `Length` | int, physically 5b, range 0–15 | offered contract years |
| `OfferedContractProgramPoints` / `ExpectedContractProgramPoints` | int 11b 0–2000 | salary in "program points" (UNVERIFIED semantics) |
| `ExperiencePoints` | int 16b 0–50000 | |
| `ContractExpectationsByYear` | ref enum[] | per-year contract expectation enums (UNVERIFIED) |

### 7.3 `StaffPersonContractOffer[]` (uid 4119397260, cap 408) — candidate ordering

Array table, **6 slots per row** (`StaffPersonContractOffer0..5`) = the ordered candidate list for one
opening, referenced from `JobOpening.ContractOfferList`. The 6-slot design matches
`StaffHiringTuning.MaxJobCandidates = 6`. Whether the engine reads `OfferIndex` or array order when resolving
hires is UNVERIFIED — keep both consistent (the KivJoy tool does). `[carousel §1b][schema §2]`

### 7.4 `StaffHiringEval` (uid 1794614061, singleton) — the carousel engine

- Data: **`JobOpenings: JobOpening[]`** (the sanctioned way to reach openings — follow this instead of
  scanning the raw table), `OutstandingStaffPersonOffersList: StaffPersonContractOffer[]`, refs to
  StaffHiringTuning/SeasonInfo/Franchise/CoachRetirementEval/CoachManager. `[schema §2]`
- CPU offer-loop dials on the Eval itself: `ChanceCoachWaits`, `ChanceToIncreaseOffer`,
  `ChanceToWithdrawOffer`, `PercentageToIncreaseOffer` (schema defaults 33/25/25/5; the table-explorer save
  shows 25/90/5/15 — live values differ from schema defaults, read them from the save). `[schema §2][web §6]`
- Lifecycle handler names (function fields — bodies native): `HandleChampionshipWeekStart`,
  `HandleNationalChampionshipStart`, `HandleOffseasonStart`, `HandleRegularBowlWeekStart`,
  `HandleStaffHiringWeekAdvance`, `HandleStaffHiringEvaluateOffersStart`, `CreateOfferForAllTeams`,
  `EvaluateFireStaffForAllTeams`, `EvaluateHireStaffForAllTeams`, `ReleaseStaffWithNoOutstandingOffers`,
  `SelectOfferToAccept`, `GetCoachInterest`, `IssueEnterCoachCarouselRequest`,
  `ValidateHeadCoach/OffensiveCoordinator/DefensiveCoordinator` — i.e. the carousel runs from bowl-season
  week starts into offseason start. `[schema §2][web §6]`

## 8. Carousel tunables (StaffHiringTuning)

120 members, league-editable in the save; highlights (name : schema default): `[schema §2]`

- `MaxJobCandidates` = **6** (the array-slot count); `MaxOffersPerCoach` = **4** (so `Coach.NumContractOffers`
  4-bit max 12 is safe in practice; library clamping UNVERIFIED `[schema §13]`).
- Candidate/interest: `CandidateCutoffScore` 140, `InterestedThresholdForOffer` 70, `JobInterestCutOff` 70.
- Firing thresholds: see §6.2.
- Leaving: `LeaveForNFLMax` 5, `LeaveForNFLPrestigeOddsThreshold` 80, `RetireMax` 5, `CoachLeavingOddsSpline`.
- Poaching: `PoachThresholdHC` 2, `PoachThresholdCoord` 1.
- Interest shaping: `BetterJobInterestModifier` 0, `WorseJobInterestModifier` 50,
  `ContractLengthInterestMultiplier` −10, `PrestigeCompareScoreSpline(+Buffer 50)`,
  `TeamPrestigeToInterestPointsSpline`, `JobSecurityToInterestPointsSpline`, `UserExpressedInterestScoreBonus`,
  `UserPrestigeBonus`.
- Contract-points economy: `ContractPoints_WinGame` 20, `_ConfChamp` 40, `_NatChamp` 100, `_NewCoach` 40,
  `_NewContractBonus` 40; `MaxExpectedContractPoints` 230; year multipliers ×5/×2/×1;
  `CoordinatorRoleModifier` 0.7; `CoordPrestigePenalty` 30.
- Extensions: `ShortTermExtensionLength` 1 @ SafeForNow, `LongTermExtensionLength` 2 @ Safe,
  `CPUAutoExtendContractYearsRemainingThreshold` 2.
- Pipeline weight in hiring: `HalfPipelinePoints` 25, `MaxPipelinePoints` 50.

## 9. Carousel lifecycle: when data exists and what survives

Empirical timeline (dynasty year N): `[dump §6][diff §2.4]`

```
PreSeason (wk 0)
  └─ NFLSeason wks 0..20  (CurrentStage enum value 'NFLSeason' covers regular + post season)
       ├─ wks ~15..20: CAROUSEL WINDOW — JobOpening + StaffPersonContractOffer rows live,
       │   CoachTransactionHistoryEntry rows written (bulk at wk 18)
       └─ (engine resolves hires; Team/Coach sides rewritten)
  └─ OffSeason stages 0..8
       ├─ by stage 2 (transfer portal): JobOpening/offer rows ALL FREED; no Pending coach statuses remain
       └─ stage 9 == OffseasonNumStages parked value into next PreSeason
```

- All five saves show **0 live JobOpening rows** and 1 junk offer row; but free-list heads
  (`nextRecordToUse`) prove usage: QQ 0 (never ran), QQ2b 251, QQ2 235 → **~235–251 openings per cycle**,
  all freed before offseason stage 2. `[diff §2.4]`
- Freed JobOpening rows keep `Reason`/`Position`/`Filled` (stale but readable), lose `Team` (first field =
  free-list pointer) and read null `PrevCoach`/`SelectedCoach` → post-window, JobOpening yields **aggregate
  stats only**, never who/where. `[diff §2.4]`
- To capture per-opening detail (who left, why, who was hired, which team) you must read the save **inside**
  the window — none of the user's saves was in-window, so live JobOpening rows remain UNVERIFIED-by-us
  (creation/teardown tick inferred from reaction names: created around post-season week starts, torn down by
  `StaffHiringPeriodEndEvent`/`CoachCarouselEndEvent`). `[schema §2]`
- `SeasonInfo.IsCarouselPeriodActive` was false in all five saves (none in-window); stage-1 code analysis
  says it flips true during the window (in-window value UNVERIFIED). `[diff §1.3]`

## 10. CoachTransactionHistoryEntry — the persistent carousel log ★

Table uid **2701814500**, tableId 4271, cap 300. **The best post-hoc coaching-change source: it persists
through the entire following offseason + season** (QQ2b, a preseason save, still holds all 102 year-1 rows;
QQ2 holds only year-3 rows — the table is cleared/rewritten at each new carousel). `[dump §6]`

### 10.1 Schema (12 fields, from the live dump)

| Field | Type / bits |
|---|---|
| `Coach` | ref Coach 32 |
| `OldTeam` / `NewTeam` | ref Team 32 (null = none) |
| `OldCoachPosition` / `NewCoachPosition` | enum CoachPosition 8b |
| `TransactionId` | int |
| `SeasonStage` | enum SeasonStage {PreSeason=0, NFLSeason=1, OffSeason=2} |
| `SeasonYear` | int (dynasty year, 0-based) |
| `SeasonWeek` | int (observed 15–20, bulk at 18) |
| `ContractSalary` | int (always 0 observed) |
| `ContractLength` | int (new contract length; 0–6 observed) |
| `ContractStatus` | enum StaffPersonContractStatus — the *transaction type* stamp |

Row 0 is a null sentinel. `[dump §6]` (field defs: `research/dumps/QQ2b/CoachTransactionHistoryEntry.2701814500.sample.json`)

### 10.2 Decoding the transaction patterns (verified on 238 real rows)

| OldTeam | NewTeam | ContractStatus (raw) | Meaning | QQ2b yr-1 | QQ2 yr-3 |
|---|---|---|---|---|---|
| set | null | `First_Pending` (=PendingFire, 2) | **Fired** | 32 | 55 (28 HC / 13 OC / 14 DC) |
| set | set | `Last_Pending` (=PendingHire, 6) | **Moved schools** (incl. promotions DC→HC ×9, OC→HC ×7; demotions HC→OC ×3, HC→DC ×2; lateral HC→HC ×17) | 42 | 46 |
| null | set | `Last_Pending` (=PendingHire, 6) | **Hired from unemployed pool** (incl. cross-role OC→DC ×6, DC→OC ×6) | 28 | 26 |
| set | null | `PendingNFL` (3) | **Left for the NFL** | 0 | 2 |
| set | null | `Last_Active` (=PendingRetire, 5) | **Retired** | 0 | 5 |

`OldCoachPosition`/`NewCoachPosition` give exact role changes (promotion tracking for free). `[dump §6]`

Real sample rows (QQ2b dump): coach row 468, OldTeam null → NewTeam Team#35, OC→OC, `Last_Pending`, yr 1
wk 18 (pool hire); coach row 475, OldTeam Team#104 → null, DC, `First_Pending`, yr 1 wk 15 (fired); coach
row 115, Team#27 → Team#112, HC→HC, `Last_Pending`, ContractLength 6, yr 1 wk 18 (poached).
`[research/dumps/QQ2b/CoachTransactionHistoryEntry.2701814500.sample.json]`

### 10.3 What it lacks

No fired-vs-resigned-vs-contract-expired distinction beyond the patterns above — `JobOpening.Reason` has the
richer CoachLeaveReason enum but only in-window. Fired and ContractEnding departures both look like
"oldTeam / — / PendingFire"? **No** — Fired maps to PendingFire; how ContractEnding departures appear in this
table is UNVERIFIED (candidates: FreeAgent transition without an entry, or the same PendingFire pattern).
`[dump §6][diff §2.4]`

## 11. Post-carousel movement signature (single-save detection)

Works at the transfer-portal stage — verified on QQ2 (46 + 34 movers, 19 free agents, 7 retirees). `[diff §2.2]`

| Situation | ContractStatus | TeamIndex | PrevTeamIndex | SeasonsWithTeam |
|---|---|---|---|---|
| Hired elsewhere this carousel | `First_Active` (Signed) | new team | **old team** | 0 (some movers show 1 — UNVERIFIED why; best guess in-season vs offseason hire wave) |
| Departed, unhired (fired or contract ended — cause indistinguishable) | `FreeAgent` | 255 | old team | 0 |
| Retired this offseason | `Retired` | 255 | team retired from | NOT reset (2–5) |
| Never moved | `First_Active` | team | **255** | ≥ 1 |

- `PrevTeamIndex != 255` is the reliable "has ever moved" marker; it persists at least through the following
  preseason and is overwritten by the next move (reset-to-255 timing UNVERIFIED). In a never-carouseled
  year-0 save, **zero** coaches have PrevTeamIndex ≠ 255. `[diff §2.2]`
- `PrevPosition` is noisy (stamped on non-movers by what look like contract renewals; some genuine movers
  show `Invalid_`) — do not use as primary marker. FreeAgents' `Position` is unreliable as "last role held".
  `[diff §2.2]`
- Cross-check the Team side: a mover's old team's current coach at that role is his replacement. `[diff §6b]`
- Sanity signal that a carousel ever ran: `JobOpening(263453863).header.nextRecordToUse > 0`. `[diff §6b]`

## 12. Retirement & comeback machinery

- `CoachRetirementEval` (singleton): `CoachesReturningThisYear/AfterOneYear/AfterTwoYears/AfterThreeYears:
  Coach[]` (un-retirement lists), `EvaluateCoachForRetirement`, `ForceRetirement`, HOF farewell strings.
  `[web §6, CODE via table-map.json]`
- `CoachRetirementEvalInfo` tunables: `RetirementAgeThreshold` 65, `AlwaysRetireAgeThreshold` 100,
  `RetirementModifier` 1.25, `FreeAgentMod` 5, **`RetirementReturnEnable` = false**, splines
  (`LegacyScoreRetireSpline`, `YearsCoachingModSpline`, …). `[web §6]`
- Live: 7 `Retired` coaches at QQ2 offseason stage 2; whether Retired rows persist into the next season or
  get recycled (FreeAgent/Deleted) is UNVERIFIED. `[diff §2.2]`
- `Franchise.RetiredCoaches / FreeAgentCoaches / RecruitingClassCoaches / DraftClassCoaches: Coach[]` —
  pool lists on the Franchise root. `[schema §3]`

## 13. Mutation recipes from the KivJoy carousel tool

The KivJoy tool (reference/CFB27-Coaching-Carousel) edits only **candidate lists and interests** — the game
engine executes actual hires/fires on week advance. It writes exactly three tables:
`StaffPersonContractOffer` (values), `StaffPersonContractOffer[]` (ordering), `Coach.NumContractOffers`.
It never writes `Team.HeadCoach`, `Coach.Position/TeamIndex`, or `JobOpening.*`. `[carousel §3]`

### 13.1 Edit interest

```js
const field = record.getFieldByKey(fieldName);       // Base/Adjusted/TeamInterest
const min = Number(field?.offset.minValue) ?? fallbackMin;
const max = Number(field?.offset.maxValue) ?? fallbackMax;
record[fieldName] = clampInteger(rawValue, min, max); // schema-driven clamp (TeamInterest max is 280!)
```
If `TeamInterestInStaffPerson` changed, re-run the ordering sync (13.3). `[carousel §3.1]`

### 13.2 Replace a candidate on an offer

```js
record.StaffPerson    = coachTable.getBinaryReferenceToRecord(coachRow); // the new candidate
record.StaffPersonTeam = coachTeamRef || EMPTY_REFERENCE;                // his current school (or null)
// then recount pending offers per coach across the whole table:
coach.NumContractOffers = <count of pending offers whose StaffPerson === that coach's ref>; // for EVERY coach
```
Validation first: reject duplicates within the same (team, position) group; warn if the coach appears in
other pending offers. Interest values are left as inherited. `[carousel §3.3]`

### 13.3 Ordering sync — the canonical array-table write pattern

1. Group pending offers by `(Team ref, ContractPosition)`; sort by TeamInterest desc → BaseInterest desc →
   AdjustedInterest desc → OfferIndex asc → row index asc; write `OfferIndex = 0..n-1`.
2. Rewrite the group's `StaffPersonContractOffer[]` row:

```js
for (let i = 0; i < slotFields.length; i += 1) {
  arrayRecord[slotFields[i].key] = i < writeCount ? offerRefs[i] : EMPTY_REFERENCE;
}
arrayRecord.arraySize = writeCount;
if (table.arraySizes && table.arraySizes.length > arrayRecord.index) {
  table.arraySizes[arrayRecord.index] = writeCount;   // keep the table-level mirror in sync
}
arrayRecord.isChanged = true;  table.isChanged = true;
```

Takeaways: array writes must set slot values AND `record.arraySize` AND `table.arraySizes[row]`, and mark
`isChanged` on record + table; pad unused slots with the 32-zero string; keep `OfferIndex` and slot order in
agreement. `[carousel §3.4]`

Note: KivJoy locates the array row by reverse-matching refs (majority vote); the simpler sanctioned route is
following `JobOpening.ContractOfferList`. `[carousel §1b]`

## 14. Dead fields — do not build on these

Verified across all five saves: `[diff §2.3]`

- `COACH_FIREREPORTED` / `COACH_RESIGNREPORTED`: **true for all 497 coaches in every save**, even the
  never-carouseled year-0 save. Useless as signals.
- `COACH_LASTTEAMFIRED` / `COACH_LASTTEAMRESIGNED`: **0 for every coach in every save**. Dead in CFB27.
- `Story` table: purged in offseason — not a carousel history source. `[dump §13]`

## Sources

- `research/notes-carousel-tool.md` (KivJoy tool internals; verified table/field dumps)
- `research/notes-schema.md` + `research/schema-relevant-tables.json` (C27_468_2 schema mining: enums, tunables)
- `research/notes-save-dump.md` + `research/dumps/QQ*/Coach.*.sample.json`,
  `research/dumps/QQ2b/CoachTransactionHistoryEntry.2701814500.sample.json`, `research/dumps/*/scan.json`,
  `research/dumps/QQ2/misc-checks.json`
- `research/notes-save-diff.md` + `research/dumps/save-diff.json` (QQ2b→QQ2 carousel diff; movement signatures)
- `research/notes-web.md` (table-map.json extracted schemas [CODE]; brooksg357 docs [1SRC])
- `research/notes-pipeline-tool.md`, `research/notes-jersey-tool.md` (IsUserControlled, UserCharacter idioms)
