Depends on #1364.
Closes #1365.
## Summary
Destructive-cleanup follow-up to #1364. Retires the legacy January-1 (aerie_milestone_v1) Forecast V2 field family and the transitional dual-shape compatibility #1364 deliberately kept, narrowing to the single supported January-31 shape. This is representation cleanup only — the session_3 internal key, every January-31 field, and every number (Jan Forecast, Pipeline Additions, card subtotals, Finance) are unchanged from #1364.
> Merge is gated on #1364 being deployed AND verified in production. #1364 is merged to main, but app/worker CD only deploys on push to production, so production still runs the OLD worker and the active Convex Forecast V2 publication is still the LEGACY shape (legacy docs at rest). The Convex schema narrowing here (by design) will NOT deploy while legacy docs remain — that is the intended fail-closed guardrail, not a bug. Do not merge until #1364 is promoted to production and its first new-model publication has self-pruned the legacy run.
## Changes by layer
- Analytics worker (sync/src/redshift/admissions-forecast.ts): no legacy Jan-1 field selection/mapping/dual-write existed after #1364; comment updated to document the DBT-retention exception. Strict fail-closed validation of every required January-31 field is retained.
- Shared contract (packages/contracts/src/admissions-forecast-v2.ts): already the January-31 shape after #1364; header + FORECAST_V2_MODEL_VERSION docs updated to state enforcement now lives in the publish mutation and the legacy group is retired.
- HTTP publish validation (chat/convex/admissions/analytics/forecastV2Validators.ts + forecastV2.ts): removed the forecastV2Session3LegacyValidator and its type; the publish mutation now requires modelVersion === aerie_milestone_v2 on every row, so a legacy-only OR mixed legacy/current payload is rejected (the strict session3 object validator already rejects the legacy field shape at arg validation). Pointer never advances on rejection (fail closed).
- Convex table schema (chat/convex/admissions/schema.ts + validators): forecastV2StoredRowValidator is now the strict published-row shape (legacy session3 union removed); admissionsForecastV2Rows requires the current shape. This narrowing deploys cleanly only once no legacy document remains at rest.
- Dashboard reader (chat/convex/admissions/dashboards/forecastV2.ts): removed the legacy model-version gate and the new/legacy session3 discriminator (isNewModelSession3). The completeness gate (non-empty snapshot whose fetched row count matches the publication metadata) is retained.
- DBT (dbt/models/marts/admissions/_mart_admissions__models.yml): see exception below.
- Tests (chat/convex/admissions/forecastV2.test.ts): added rejection coverage; see below.
## DBT-column-retention exception
The ticket requires external warehouse-owner confirmation before physically dropping the legacy Redshift columns, which cannot be obtained autonomously (a repository search cannot prove external safety). Per the ticket's explicit fallback, the legacy Jan-1 columns are NOT dropped. Instead:
- All code-side legacy (worker/contract/HTTP/Convex/reader) is removed.
- The legacy Jan-1 columns (session_3_future_enrollments_before_january_1, session_3_future_enrollments, session_3_withdrawals_before_january_1, session_3_withdrawals_on_or_after_january_1, session_3_transfers_before_january_1, session_3_transfers_on_or_after_january_1, session_3_roster_base, session_3_target_date, session_3_forecast_enrollment, session_3_headline_enrollment) are retained in the mart SQL and documented DEPRECATED (#1365) in the model yml, each naming its current-shape replacement and noting no Aerie consumer.
- The mart/intermediate SQL and the legacy data tests are unchanged so the retained columns keep reconciling. All source facts the new January calc needs are kept. session_3_forecast_status is shared (still consumed) and is correctly NOT deprecated.
A follow-up ticket can drop the columns once the warehouse owner confirms no external DBT/Redshift consumer depends on them.
## Repository-wide search — no remaining runtime consumer of legacy fields
$ grep -rnE "futureEnrollmentsBeforeJan1|withdrawalsBeforeJan1|withdrawalsOnOrAfterJan1|transfersBeforeJan1|transfersOnOrAfterJan1|forecastV2Session3LegacyValidator|ForecastV2Session3LegacyStored|isNewModelSession3|aerie_milestone_v1" \--include="*.ts" --include="*.tsx" . | grep -v node_modules | grep -v '\.test\.ts'
# (no output — zero runtime consumers)
The only surviving references to the legacy field names are in forecastV2.test.ts's rejection factory (legacySession3), which exists solely to assert the legacy shape is now REJECTED. The remaining session_3_january_roster_base matches are the NEW January-31 column, not legacy.
## Preconditions evidence
Code-side preconditions (satisfied by this PR):
| Precondition | Status | Evidence |
| --- | --- | --- |
| No remaining runtime consumer of the legacy fields | MET | grep above returns zero non-test hits |
| No deployed worker code can publish the legacy payload | MET (code-side) | worker selects/maps only January-31 columns; contract describes only the January-31 shape |
| Shared contract requires only the current January-31 group | MET | packages/contracts/src/admissions-forecast-v2.ts (ForecastV2Session3 has no legacy fields) |
| Publish endpoint rejects legacy-only and mixed payloads | MET | strict session3 validator + modelVersion === aerie_milestone_v2 check in forecastV2.ts; tests assert 500/reject with no pointer advance |
| Convex storage requires the current shape (no legacy union/optional aliases) | MET (code-side) | forecastV2StoredRowValidator = strict published-row validator; schema narrowed |
| Dashboard reader has no legacy model-version branch/fallback | MET | gate + isNewModelSession3 removed from dashboards/forecastV2.ts |
| Stable session_3 internal key unchanged | MET | key retained everywhere |
| Jan Forecast / Pipeline Additions / subtotals / Finance / labels unchanged | MET | representation-only change; contract helpers + reconciliation/report tests unchanged and green |
| Existing atomicity / self-pruning tests still pass | MET | forecastV2.test.ts 41/41 pass |
| Tests prove a valid current payload publishes and legacy is rejected | MET | new + existing tests |
Deployment / data-state preconditions (NOT-YET-VERIFIED — #1364 is not yet promoted to production; merge is gated on this):
| Precondition | Status |
| --- | --- |
| Active Forecast V2 publication model version is aerie_milestone_v2 | NOT-YET-VERIFIED — production still runs the OLD worker; active publication is still the legacy shape |
| Active publication contains the expected number of schools + all required January operands | NOT-YET-VERIFIED |
| The previous legacy publication was deleted by the successful publication transaction (self-prune) | NOT-YET-VERIFIED — no new-model run has occurred in production |
| No in-flight or deployed old analytics worker can publish the legacy payload | NOT-YET-VERIFIED — old worker still deployed in production |
| No legacy document remains at rest (required for the Convex schema narrowing to deploy) | NOT-YET-VERIFIED — the schema narrowing WILL fail to deploy until this holds, by design |
| Forecast V2 and Finance Forecast reconcile for representative schools post-deploy | NOT-YET-VERIFIED |
| Warehouse owner confirms no external DBT/Redshift consumer depends on legacy columns | NOT OBTAINABLE autonomously → DBT-column-retention fallback applied (see above) |
No production data was deleted or mutated to force validation.
## Rollback
Rollback target is the #1364 functional January release (not the pre-January implementation), since this cleanup removes legacy acceptance. If the schema narrowing fails to deploy because a legacy document still exists, stop and investigate the publication state — do not delete data manually to force schema validation.
## Verification (local)
- pnpm typecheck — green across all workspaces
- pnpm biome check (changed files) — clean
- pnpm lint:boundaries, pnpm lint:test-architecture — green
- chat forecastV2.test.ts — 41/41 pass; sync forecast reader + refresh tests — 52/52 pass
- dbt yml validated as well-formed YAML (all 10 legacy columns marked DEPRECATED); dbt parse not run locally (dbt not installed in this environment) — the dbt change is documentation-only column descriptions with no structural/test change
🤖 Generated with [Claude Code](https://claude.com/claude-code)