# v1.9.6 stability update — `email_security_policy_enforcing` disposition closed

**Date:** 2026-05-13
**Release:** v1.9.6
**Topology under test:** v1.9.6 (v1.9.3+ with `dkim_present` removed
as an evidence binding for `email_security_policy_enforcing`).
**Corpus:** same v1.9.4 combined 141 domains used in v1.9.5; the
v1.9.6 inference re-runs use the saved evidence (slugs + signals)
from those NDJSON files, so the engine change is isolated from any
upstream DNS drift.
**Predecessor:** [v1.9.5 stability report](v1.9.5-stability.md).

## Headline

`email_security_policy_enforcing` moves from `not yet` (v1.9.5) to
`stable` (v1.9.6). The v1.9.5 disposition called for a CPT-change-
discipline-style redefinition; v1.9.6 ships the redefinition. The
fix is **removing** `dkim_present` as an evidence binding rather
than retuning its likelihood — see the `bayesian_network.yaml`
concept comment on the node and `CONTRIBUTING.md` "CPT-change
discipline → Worked example 2" for the reasoning.

| Verdict | v1.9.5 count | v1.9.6 count | Change |
|---|---|---|---|
| **stable** | 7 | **8** | +1 (`policy_enforcing` promoted) |
| **not yet** | 2 | 1 | -1 |

The single remaining `not yet` is `okta_idp` (firings 7 of required
10) — disposition unchanged: corpus-limited, not engine-limited;
keep node, expand corpus.

## `email_security_policy_enforcing` — before / after

| Metric | v1.9.5 | v1.9.6 | Direction |
|---|---|---|---|
| **(b1) det-positive-HIGH ⇒ posterior > 0.5** | 119/129 (92.2%) | **119/119 (100%)** | ✓ criterion now passes |
| **(b2) det-silent ⇒ sparse** | 12/12 (100%) | 22/22 (100%) | unchanged at 100%; eligible set +10 (the dkim-only cases joined) |
| **Firings** (criterion c) | 122 | 112 | -10 (dkim-only cases moved to det-silent) |
| **Brier score** | 0.0346 | 0.0331 | slight improvement |
| **Log-score** | 0.1522 | 0.1747 | slight rise (smaller eligible set → higher per-observation variance; not a calibration regression) |
| **ECE** | 0.128 | 0.154 | slight rise (same artifact; well under the 0.20 threshold) |
| **Verdict** | not yet | **stable** | promoted |

The 10 cases that flipped were all
`evidence_used = (signal:dkim_present,)` with nothing else. In
v1.9.5 those cases produced posterior ≈ 0.486 with the layer not
flagging sparse (a single binding cleared the n_eff threshold); they
counted against (b1). In v1.9.6 those cases have
`evidence_used = ()` because `dkim_present` is no longer bound, so
the layer correctly marks them det-silent and sparse=true, joining
(b2)'s correctly-hedged set.

The minor ECE / log-score rise reflects the *smaller* eligible set:
removing 10 well-classified non-sparse observations leaves a set
whose per-bin variance is higher, even though the layer's
predictions on the remaining set are unchanged. This is a numerator
artifact, not a calibration regression. ECE stays comfortably below
the 0.20 advisory threshold; Brier improved.

## Other nodes — no changes

The seven nodes that were `stable` in v1.9.5 remain `stable` with
unchanged numbers (the inference re-run uses the same saved
evidence, and only the `email_security_policy_enforcing` node's
CPT changed). `okta_idp` remains `not yet` with the same 7
firings and the same corpus-limited disposition.

## What this validates

* **The concept fix is the right fix.** Removing a binding that
  didn't speak to the node's claim moved 10 observations from
  "non-sparse with low posterior" (b1-failing miss) to
  "sparse, correctly hedged" (b2-eligible silence). The layer is
  more honest about the dkim-only posture, not just more agreeable
  with the criterion.
* **The corpus-fitting alternative would have been worse.**
  Lowering `dkim_present`'s likelihood `[0.85, 0.30]` to `[0.85, 0.20]`
  (the considered-and-rejected option) would have lifted the same
  10 cases above 0.5, but those cases would then be CONFIDENTLY
  flagged as policy-enforcing despite having only DKIM evidence —
  a posture that empirically isn't enforcing. The criterion would
  have passed; the node would have been worse at its job.
* **The CPT-change discipline is testable on the worked example.**
  CONTRIBUTING.md's "CPT-change discipline → Worked example 2"
  cites this redefinition. The discipline IS the example — the
  v1.9.6 release ships both the rule and its first canonical
  application.

## Reproducibility

```bash
# Re-run inference against the v1.9.4 saved evidence with the
# v1.9.6 network. (Engine-isolated: no live DNS.)
python -c "
import json
from recon_tool.bayesian import infer, load_network
# ... see validation/v1.9.6-stability-update.md for full snippet ...
"

# Recompute stability metrics.
python validation/compute_node_stability.py \
    --hardened validation/corpus-private/v1.9.4-hardened/results.v196.ndjson \
    --soft     validation/runs-private/v1.9.4-soft-rerun/results.v196.ndjson

# Criterion (a) test (still passes with 4 bindings instead of 5).
pytest tests/test_node_stability_criteria.py -v
```

The `.v196.ndjson` files are intermediate artifacts; they live
alongside the original NDJSON in the gitignored validation
directories.

## What this run does NOT validate

1. **Behavior on a fresh DNS corpus.** This run re-uses saved
   evidence to isolate the engine change. The next routine
   calibration pass should run live DNS against the same domain
   list to confirm the network behaves the same with fresh
   evidence — no expected differences, but worth checking.
2. **Per-vertical generalization.** Same caveats as the v1.9.5
   report: enterprise + hardened-enterprise corpus only.
3. **The v1.9.6 discipline at scale.** The discipline ships as
   documentation + PR template prompt. It will be tested by
   future CPT change attempts; no synthetic stress test exists.
