# Declarative Gate + Promotion Policy (Sub-project B1) — Design

**Date:** 2026-07-09
**Status:** Approved (brainstorming) — ready for implementation plan
**Sub-project:** B1 of the policy plane (layer 4) of the "unified data-driven pipeline" north-star.
**Builds on:** sub-project A (deterministic forward state — the DB anchor + record SSOT, branch
`feat/deterministic-forward-state`). Requires A merged/available: B1 reads `forward_summary` +
`forward_nav` (A's record) and extends `forward_ingest`.

## North-star context (layer 4, plane 1 of 3)

The policy plane turns A's record into decisions, driven by ONE declarative, versioned, git-committed
policy — not scattered constants. The three planes:

```
A's record (per-track forward series + summary)
  → B1  GATE + PROMOTION policy  → per-track STATUS {WAIT | PASS | deploy-tier}   ← THIS sub-project
  → B3  HEALTH / DECAY policy     → per-edge {healthy | decaying | killed} (ex-ante, rare)
  → B2  ALLOCATION policy         → per-strategy target weights (bounded by a human capital ceiling)
  → (C) execution engine
```

Guiding principle (mirror of A): a decision = `f(committed policy, A's record)`. Policy is declarative +
versioned + committed in **git** (a change is a reviewed commit + a version bump), NOT runtime-editable in
the DB (that would reintroduce the hand-editable real-money state A eliminated). Evaluation is automatic.
The human sets policy + (later, in B2) a capital ceiling. B1 touches NO capital — "deploy-tier" is a
tracking tier; real capital is B2 + manual venue-key arming.

**The problem B1 solves.** Gate/promotion policy is scattered: default thresholds live as module constants
(`reconciliation_gate.MIN_FORWARD_DAYS`, `RECON_TOLERANCE_FRAC`, `RECON_MIN_BAND`, `RECON_MIN_CORR`) and
inline `.get(..., default)` literals in `gate.py`, per-track overrides live in registry `gate_spec` dicts,
and promotion is a `promote_on_pass` flag + `evaluate_promotions`. Changing a threshold means editing a
constant or a function default in code, with no version/audit trail on the capital-affecting verdict.

## Goal

Consolidate the scattered gate + promotion defaults into ONE declarative, versioned, in-code `GatePolicy`;
resolve the effective per-track policy at one point; stamp every gate verdict with the `policy_version` +
content-derived `policy_hash` that produced it (auditability); and formalize promotion as
`gate==PASS AND human flag AND automatic correlation backstop`. Behavior is byte-identical to today under the
current thresholds (a pure consolidation + audit + one new promotion guard).

## Components

### 1. The policy object (`gate_policy.py`, in-code typed, versioned)

A new module `src/fxhnt/application/gate_policy.py`:

- `GatePolicy` — a frozen dataclass holding the DEFAULTS currently scattered as module constants / inline
  literals:
  - reconciliation: `min_forward_days: int`, `recon_tolerance_frac: float`, `recon_min_band: float`,
    `recon_min_corr: float`, `max_gap_days: int`
  - absolute: `min_days: int`, `min_total_return: float`, `min_sharpe: float`
  - promotion: `promotion_max_corr: float` (the auto-corr backstop threshold) + `promotion_min_overlap_days:
    int` (minimum overlapping days for a valid correlation — below this the corr is indeterminate and
    promotion is withheld). Both are covered by `policy_hash`.
- `POLICY_VERSION: int` — bumped on ANY change to the policy defaults (auditable; dev-controlled).
- `policy_hash(policy: GatePolicy, version: int) -> str` — sha256 of the canonical policy fields + version
  (content-derived; a forgotten version bump still changes the hash → the self-check test fails).
- `POLICY: GatePolicy` — the single current policy instance with the values that are byte-identical to
  today's constants (so the consolidation changes no verdict).

The module constants in `reconciliation_gate.py` and the inline defaults in `gate.py` are REMOVED — those
files read from the resolved policy (single source of truth for the defaults). Per-track overrides stay in
the registry `gate_spec` dict (unchanged shape).

### 2. Effective-policy resolution (one point)

`resolve_policy(policy: GatePolicy, gate_spec: dict) -> ResolvedPolicy` layers the per-track `gate_spec`
overrides on top of the `GatePolicy` defaults, producing a complete `ResolvedPolicy` (every threshold
explicit). `evaluate_reconciliation_gate` and `evaluate_gate` take the `ResolvedPolicy` (not raw `gate_spec`
+ module-constant fallbacks). This is the single place defaults and overrides combine; there are no hidden
fallbacks elsewhere.

### 3. Auditability — stamp the verdict

`forward_summary` (ORM `ForwardSummaryRow`) gains two columns: `policy_version: int` and
`policy_hash: str` (both nullable for backfill/migration tolerance). When `forward_ingest.ingest` writes a
verdict via `upsert_summary`, it stamps the `POLICY_VERSION` + `policy_hash(POLICY, POLICY_VERSION)` that
produced it. "PASSed under policy v3" is queryable; a policy version bump means future verdicts carry the
new version while history keeps the old — you can see exactly which policy each capital-affecting PASS fell
under. `upsert_summary`'s signature gains the two fields (defaulted so no other caller breaks).

### 4. Promotion policy (human flag + automatic correlation backstop)

`evaluate_promotions` (in `forward_ingest`) promotes research→deploy iff ALL hold:

1. `gate_status == "PASS"` (unchanged), AND
2. the registry `promote_on_pass` flag is set (human intent — the diversification candidate), AND
3. **NEW — the automatic correlation backstop:** the track's daily-return correlation to the current
   deploy book is `< POLICY.promotion_max_corr`.

The **deploy book** is the equal-weight aggregate of the currently-deploy-tier tracks' daily returns
(from A's record: `nav_history`/`record_series` per deploy track, aligned by date, averaged per day). The
candidate's daily returns come from A's record too. Correlation is computed over the overlapping window.

- **Empty deploy book** (no deploy-tier track yet — the first edge): the backstop passes (nothing to
  correlate against — the first edge trivially diversifies). PROMOTE.
- **A deploy book exists but no/short overlap** with the candidate: err safe — WITHHOLD promotion (log it);
  promotion is capital-adjacent, so an indeterminate diversification is not auto-promoted.
- A flagged candidate at PASS whose corr `>= threshold` is WITHHELD with a clear log line
  (`"<sid> gate=PASS but corr 0.55 >= 0.40 → promotion withheld"`), catching a human mis-flag with data.

Promotion stays ONE-WAY + persistent (unchanged): once promoted, it does not revert if corr later drifts up
(the fund-membership decision is made deliberately once, not flickering daily).

### 5. Where the human stays in the loop

The human: sets the POLICY (thresholds + `POLICY_VERSION`, in git — reviewed) and flags promotion candidates
(`promote_on_pass`). The policy AUTOMATES the gate verdict + promotion (given flag + corr backstop). NO
capital is committed by B1 — deploy-tier is a tracking/display tier; real capital is B2 (allocation + a hard
human capital ceiling) + manual venue-key arming.

## Error handling

- **Forgotten version bump:** the content-derived `policy_hash` changes whenever the policy fields change,
  even if `POLICY_VERSION` is not bumped. A self-check test pins `policy_hash(POLICY, POLICY_VERSION)` to an
  expected value; changing the policy without bumping the version fails the test, forcing the bump (mirrors
  A's `definition_version` discipline).
- **Corr backstop indeterminate:** empty deploy book → pass (first edge); non-empty book but insufficient
  overlap → withhold (err safe, capital-adjacent).
- **Gate verdict logic unchanged:** the consolidation must produce byte-identical verdicts under the current
  thresholds — a regression anchor test asserts this.
- **WAIT-safe preserved:** insufficient data → WAIT, never a fabricated PASS (unchanged from A).

## Testing

- **Policy resolution:** `GatePolicy` defaults ⊕ a track's `gate_spec` overrides → the correct
  `ResolvedPolicy` (override wins; unset falls back to the default).
- **Byte-identical verdicts (regression anchor):** the same forward records produce the same gate verdicts
  as before the consolidation, under the current `POLICY` values.
- **Verdict stamping:** an ingested verdict carries `POLICY_VERSION` + `policy_hash(POLICY, POLICY_VERSION)`.
- **Policy-hash self-check:** `policy_hash(POLICY, POLICY_VERSION)` equals a pinned expected value (changing
  a policy field without bumping the version fails this).
- **Promotion:** flag + PASS + corr `<` threshold → promoted; corr `>=` threshold → withheld; empty deploy
  book → promoted (first edge); non-empty book + no overlap → withheld; no flag → not promoted; already
  promoted → stays promoted (one-way).
- **Deploy-book aggregate:** the equal-weight daily aggregate over ≥2 deploy tracks aligns by date and
  averages correctly.

## Out of scope (YAGNI)

No allocation (B2), no health/decay (B3), no runtime-editable/DB policy, no capital. No UI beyond surfacing
the policy version (a later cockpit touch, optional). Registry `gate_spec` per-track overrides stay as-is.

## Affected code (initial map — the plan refines this)

- New: `src/fxhnt/application/gate_policy.py` — `GatePolicy`, `POLICY`, `POLICY_VERSION`, `policy_hash`,
  `resolve_policy`, `ResolvedPolicy`.
- Modify: `src/fxhnt/application/reconciliation_gate.py` — read from the resolved policy; remove the module
  constants (or re-export from `gate_policy` for back-compat if other code imports them — the plan checks).
- Modify: `src/fxhnt/application/gate.py` — take the resolved policy instead of inline `.get` defaults.
- Modify: `src/fxhnt/application/forward_ingest.py` — resolve the policy per track, stamp the verdict, and
  add the correlation backstop to `evaluate_promotions` (build the equal-weight deploy book from A's records).
- Modify: `src/fxhnt/adapters/persistence/cockpit_models.py` — `ForwardSummaryRow` gains `policy_version` +
  `policy_hash`; `src/fxhnt/adapters/persistence/forward_nav.py` — `upsert_summary` stamps them.
- Modify: `src/fxhnt/application/forward_models.py` — `ForwardSummary` DTO if it must carry the stamp
  (the plan decides whether the stamp is a DTO field or a separate upsert arg).
```
