# Ticket #000009 — Preflight run-DAG node binding

**Status:** closed · re-landed 2026-05-04 with §8 corrections
(reject-path DAG, nested CTI clauses, node_version, reminder
template id, policy_refs). Original landing 2026-05-03 in
`c36e85c`; §8 corrections per architectural feedback at
`~/Downloads/RESPONSE-ticket-000009-quantifier-preflight-dag-binding.txt`.
**Opened:** 2026-05-03
**Scope (expanded 2026-05-03):** Bind BOTH ticket #000008's broad-
quantifier preflight contract AND ticket #000010's meta-cognition
QuestionState into the per-run Merkle-DAG. Both share the same
audit-replay gap (per #000010 §12.6) and inserting two separate
nodes between `question` and `retrieval` is operationally awkward;
single combined `preflight` node carries both payloads.
**Audience:** fox + future blackops shifts.
**Hard constraint:** No `schema_version`, `canonicalization_version`,
or `chunking_version` bumps. Same constraint #000008 §1 carried.
The new DAG node is part of the per-run `run_dag_blob` payload, not
a column on `providence_cache`. Adding the node bumps a derived
field (`run_dag_root` SHA), which is allowed — that's already
recomputed every run.

---

## 1. Problem statement

#000008 landed Phases 0–4 in commit `4f2b5a6`. The preflight
classifier output (`quantifier_intensity`, `scope_bound_hint`,
`claim_cap_applied`) and the policy decision (cap applied or not,
reminder injected or not) appear on the `query()` / `runner.ask()`
result dict and on bench rows.

**They do NOT appear in `run_dag_root`.**

That means an audit replay of a `providence_cache` row CANNOT
distinguish:

```
question Q + classifier=ALL + cap=8-applied + reminder=injected
question Q + classifier=ALL + cap=12-applied + reminder=injected
question Q + classifier=ALL + cap=8-applied + reminder=skipped
question Q + classifier=null (guard off entirely)
```

All four would produce the same `run_dag_root` because the run
DAG is unchanged by quantifier-guard activity. Same model output,
same verifier verdict, same hash — but causally different
behavior.

The §9.5 Merkle-AGI-DAG framing (#000008) explicitly calls for the
preflight decision to be hash-bound:

> The node hash should include: stage, question_hash,
> model_profile_hash, governance_policy_hash, classifier_version,
> intensity, matched_token, explicit_count, claim_cap, allow_broad,
> reject_broad, reminder_injected.

That binding is what closes the audit-replay gap.

## 2. Scope

What this ticket covers:

- Extend the per-run DAG with a `quantifier_preflight` node between
  `question` and `retrieval_plan` stages.
- Three-clause CTI contract from #000008 §10.4 — `quantifier`,
  `answer_contract`, `evidence_contract` — bound as sub-fields of
  the new node OR as three separate nodes (design TBD).
- Hash binding: classifier output + policy decision contribute to
  `run_dag_root` so audit replay can distinguish guard-on vs
  guard-off, cap-applied vs not, reminder-injected vs skipped.

What this ticket does NOT cover:

- Verifier semantics. The preflight node carries policy state into
  the audit chain; the verifier still operates on the same inputs.
- Schema bumps. Per #000008 §1 hard constraint, no
  `schema_version`/`canonicalization_version`/`chunking_version`
  changes.
- Cross-model bench (Qwen / GPT-4) — that's a separate manual task
  per #000008 §11.11.

## 3. Design sketch

### 3.1 New stage between question and retrieval_plan

Current run-DAG order (claim_lattice modes):

```
question → retrieval_plan → retrieval_result → evidence_map →
prompt → raw_model_output → parsed_claim_lattice → pointer_verify
→ format_check → anchor_warrant → final_label
```

Proposed:

```
question → quantifier_preflight → retrieval_plan → ...
```

`quantifier_preflight` is hash-bound and feeds into `retrieval_plan`
the same way `question` does today (parent → child via
`HashCombine`).

### 3.2 Node payload

Single-node form (proposal):

```json
{
  "stage": "quantifier_preflight",
  "classifier_version": "quantifier-v0.1",
  "intensity": "ALL",
  "matched_token": "all",
  "explicit_count": null,
  "scope_bound_hint": "unbounded",
  "is_broad": true,
  "operational_shape": "universal_enumeration",
  "guard_enabled": true,
  "mode_gated": true,
  "apply_caps_active": false,
  "claim_cap_resolved": 8,
  "claim_cap_applied": null,
  "reminder_enabled": true,
  "reminder_injected": true,
  "reject_broad_active": false
}
```

Three-clause form (per #000008 §10.4):

```json
{
  "stage": "quantifier_preflight",
  "classifier_version": "quantifier-v0.1",
  "quantifier_clause":   { "intensity": "ALL", ... },
  "answer_contract":     { "max_claims": 8, ... },
  "evidence_contract":   { "max_evidence_ids_exposed": 8, ... }
}
```

Three-clause form is more extensible (each clause can grow
independently) but adds one indirection layer. Single-node form
is denser and simpler. **Recommendation: single node initially;
split if a future clause needs independent hashing.**

### 3.3 Where the node lives in code

- New helper in `arborist/qa/dag.py` (alongside existing per-stage
  helpers): `_quantifier_preflight_node(quantifier, policy,
  effective_max_claims, reminder_injected)`.
- Wire into `query()` at the same point quantifier classification
  runs (after policy resolution, before `t_start`).
- Wire into `runner.ask()` symmetrically.
- The node hash contributes to `run_dag_root` automatically via
  the existing run-DAG framing.

### 3.4 Audit replay implications

After this lands, an audit reading a `providence_cache` row can:

```
hash(quantifier_preflight node) = h_pre
→ pin: classifier output, cap decision, reminder decision

If h_pre changes between two cache_keys for the same question,
that row reflects a different preflight policy.

Allows for: regression bisection ("which day did the cap default
flip break our STRICT-rate?"), policy A/B reconstruction
("show me all rows where cap was applied vs not"), cross-model
diff ("hermes vs qwen on the same question with the same
preflight contract").
```

## 4. Implementation sketch

```
1. New function in arborist/qa/dag.py:
     def quantifier_preflight_node(quantifier, policy, ...)
2. Wire it into query() between policy resolution and t_start.
3. Wire it into runner.ask() symmetrically.
4. Run-DAG ordering: insert as child of `question` node, parent
   of `retrieval_plan` node.
5. Update tests/test_dag.py with a fixture asserting the node
   appears with the expected payload + position.
6. Update docs/cti-architecture.md to reflect the new stage.
7. Bench cycle to confirm no run_dag_root collisions across
   guard-on vs guard-off cells.
```

Estimated cost: 3-4 hours including tests + bench verification.

## 5. Why now

#000008 §11.11 explicitly listed this as Phase 5 follow-up.
Closing #000008 with the preflight mechanism shipped but the
audit-replay gap still open creates a "shipped but
non-auditable" state — the seven-point program D3 directive
("build CTI internally") is half-honored: the contract exists
in code, but isn't bound into the audit chain.

This ticket closes the loop.

## 6. Open questions

- Single node vs three-clause split (§3.2)? Default: single, split
  later if needed.
- Should the node hash include the model_profile_id verbatim, or
  just rely on the existing `model_profile_hash`? Default: rely on
  existing — it's already in the cache_key derivation.
- Bench cell needed before merging? Default: yes — confirm
  run_dag_root bumps when a guard knob flips, AND that
  governance_policy_hash continues to invalidate cache rows
  appropriately.

## 7. Status

**Closed · landed 2026-05-03 (zero-shot).** Mechanism shipped end-
to-end:

- `arborist/qa/dag.py:preflight_node_hash()` — combines
  QuestionState + quantifier classifier output + behavioral
  policy_state into one canonical SHA-256 hex.
- `arborist/qa/dag.py:build_run_dag()` — new optional
  `preflight_hash` parameter inserts a `preflight` stage at
  position 1 (between `question` and `retrieval`). Quote-mode
  shape becomes 8 stages; pointer-mode CTI shape becomes 10
  stages. Backward-compatible: when `preflight_hash` is None,
  shapes stay 7/9 exactly so legacy `run_dag_root` values
  re-validate.
- `arborist/qa/query.py` + `arborist/qa/runner.py` — both build
  the preflight payload from `question_state`, the quantifier
  dict, and a 10-field `policy_state` capturing the *behavioral*
  decisions taken on this run (guard_enabled, apply_caps_active,
  claim_cap_resolved, claim_cap_actually_applied, reminder_enabled,
  reminder_eligible, reject_broad_active, metacognition_enabled,
  block_on_contradiction, mode-gating bits).

9 new tests in `tests/test_dag.py` pin: hash determinism,
question_state/policy_state independence, all-None defensive
shape, 7→8 / 9→10 stage transitions, root change on policy flip,
verify_run_dag round-trip with preflight stage.

987 tests passing (9 new); 36 skipped.

### 7.1 What this enables (audit replay)

Two cache rows that have:

- The same question
- The same model output
- The same verifier verdict

But different behind-the-scenes preflight policy state (e.g. cap
applied vs not, reminder injected vs not, reject-broad path
taken vs not) now produce **different `run_dag_root`** values.

Audit replay can:

```
hash(preflight node) = h_pre
→ pin: classifier output, cap decision, reminder decision,
       reject decision, metacog gates

If h_pre changes between two cache_keys for the same question,
that row reflects a different preflight policy.

Allows: regression bisection ("which day did the cap default
flip break our STRICT-rate?"), policy A/B reconstruction
("show me all rows where cap was applied vs not"), cross-model
diff ("hermes vs qwen on the same question with the same
preflight contract").
```

### 7.2 What's NOT in this ticket

- **CLI flag for inspecting preflight node**: a future
  `arborist providence --show-preflight <cache_key>` would render
  the preflight payload from `run_dag_blob`. Out of scope here.
- **Bench harness preflight-hash field**: bench rows could
  surface `preflight_hash` (12-char prefix like `cache_key`)
  for cross-row comparison. Out of scope; can add later if
  bench analysis needs it.
- **`SOFT_PREFLIGHT_HINT` (model-assisted preflight)**:
  reserved per #000010 §18 / source doc. Hard rule preserved.

## 8. Architectural review + corrections (2026-05-04)

Source: `~/Downloads/RESPONSE-ticket-000009-quantifier-preflight-dag-binding.txt`
(2026-05-04, Asia/Kuala_Lumpur). Verdict: "Go. Land #000009."
Recommendations against the as-shipped (`c36e85c`) implementation:

### 8.1 Gaps surfaced

| # | Gap                                                    | Severity |
|---|--------------------------------------------------------|----------|
| A | **Reject-broad path emits NO run_dag_blob.** Preflight rejection currently early-returns from `query()` before `build_run_dag()` runs. Audit replay can't see that a rejection happened. | critical |
| B | Payload is flat (`question_state` / `quantifier` / `policy_state`). Feedback §3 recommends nested CTI clauses (`classifier`, `answer_contract`, `prompt_contract`, `evidence_contract`, `policy_refs`) for diff legibility. | structural |
| C | No `node_version` field. Legacy runs without the stage can't be unambiguously labeled `unavailable_legacy_run`. | clarity |
| D | Payload bundles raw policy booleans instead of referencing `governance_policy_hash` / `model_profile_hash`. Bloats the hash and double-commits already-hashed state. | clarity |
| E | No `reminder_template_id` field for the prompt contract. | minor |
| F | Stage named `preflight` (because metacog also lives there) vs feedback's `quantifier_preflight`. Defensible either way; we'll document the choice. | naming |
| G | `docs/cti-architecture.md` not updated. | docs |

### 8.2 Landing plan

1. **A — reject-path DAG.** Build a minimal `question → preflight →
   final_label` DAG for the reject-broad early-return path. Reject
   rows currently have no `run_dag_root` / `run_dag_blob`; this
   gives them one with the preflight node and a final-label that
   carries `BROAD_QUANTIFIER_REJECTED`. **Audit replay parity with
   normal runs.**
2. **B — restructure payload.** Move from flat 3-key to nested
   5-clause:
   ```
   classifier      — quantifier classifier output
   answer_contract — guard / cap / reject / allow_broad state
   prompt_contract — reminder enabled / injected / template_id
   evidence_contract — exposure budget, line-discipline
   policy_refs     — governance_policy_hash, model_profile_hash,
                     answer_mode (reference, not raw policy)
   ```
   Plus a top-level `node_version: "preflight-node-v1"` for legacy
   disambiguation (C).
3. **D — reference hashes only.** Replace bundled policy booleans
   with `policy_refs.governance_policy_hash` +
   `policy_refs.model_profile_hash`. Keep the *behavioral*
   decisions (claim_cap_applied, reminder_injected, etc.) in
   `answer_contract` / `prompt_contract` since those are
   per-run-state-of-the-world, not policy.
4. **E — reminder_template_id.** When reminder fires, record which
   template (`broad-quantifier-bounded-v1` /
   `broad-quantifier-unbounded-v1`).
5. **F — keep `preflight` name** because the node carries BOTH
   #000008 quantifier classifier AND #000010 metacognition
   QuestionState. `quantifier_preflight` would be misleading. The
   `node_version` field disambiguates schema.
6. **G — update docs/cti-architecture.md.**

### 8.3 What stays as-is

The feedback's recommendations match what's already shipped on:

- Single DAG stage (not three separate nodes) — ✓
- Stage placement between `question` and `retrieval` — ✓
- Backward-compat (None preflight_hash → 7/9-stage shape) — ✓
- `governance_policy_hash` invalidates cache rows on policy
  flip — ✓ (already in #000008 / #000010)
- No verifier / retrieval / schema changes — ✓
- Symmetric wiring in `query()` and `runner.ask()` — ✓

### 8.4 Out of scope (per feedback §12)

> Do not use #000009 to sneak in any of this:
>
> - new verifier rules
> - new quantifier classifier categories
> - retrieval changes
> - new schema/cache columns
> - new audit_mode token
> - NLI / semantic entailment
> - prompt-template behavior changes
> - default policy flips

Confirmed. Corrections stay strictly in the audit-binding lane.

### 8.5 Corrections landed (commit pending after this update)

**A — reject-path DAG** (the critical gap):

`arborist/qa/dag.py:build_reject_run_dag()` ships. Builds a 3-stage
`question → preflight → final_label` DAG for the reject-broad
early-return path. `query()` now wires it in and returns
`run_dag_root` + `run_dag_blob` on the rejection result dict.
Live-verified on `make query Q="winners of all major sports?"
REJECT_BROAD=1 BURN=1`:

```
"status": "broad_quantifier_rejected",
"run_dag_root": "86a03380…",
"run_dag_blob": {"nodes": [
  {"stage": "question", "hash": "…"},
  {"stage": "preflight", "hash": "…"},
  {"stage": "final_label", "hash": "…"}
]}
```

3-stage shape always means reject path; audit replay can read the
stage list and tell instantly without parsing the payload.

**B — nested CTI clauses + C — node_version + D — reference
hashes + E — reminder_template_id**:

`preflight_node_hash()` payload restructured from flat 3-key
(`question_state` / `quantifier` / `policy_state`) to nested
5-clause:

```
classifier         — quantifier classifier output
answer_contract    — guard / cap / reject / metacog state (per-run)
prompt_contract    — reminder enabled / injected / template_id
evidence_contract  — exposure budget, line discipline
policy_refs        — governance_policy_hash, model_profile_hash,
                     answer_mode (reference, not raw policy)
```

Plus top-level `stage`, `node_version: "preflight-node-v1"`, and
`question_state` (metacog, kept its own clause for now since
QuestionState carries its own internal `preflight_policy_hash`).

`reminder_template_id` is `"broad-quantifier-bounded-v1"` or
`"broad-quantifier-unbounded-v1"` depending on
`scope_bound_hint`, populated only when reminder actually fires.

`policy_refs.governance_policy_hash` is the
`verifier_policy_hash(policy)` already used for cache identity —
reusing the existing hash rather than re-canonicalizing all the
policy fields. `model_profile_hash` follows the same pattern.

**F — stage name kept as `preflight`** (not `quantifier_preflight`)
since the node carries both #000008 quantifier classifier AND
#000010 metacognition QuestionState. `node_version` field
disambiguates schema for audit tools.

**G — `docs/cti-architecture.md` update**: deferred to a small
follow-up commit. The architecture description in the ticket §8
serves as the canonical reference until then.

Hash compatibility note: rows written between commit `c36e85c`
(initial #000009 landing) and this commit have hash payloads
matching the OLD flat 3-key shape. The persisted
`run_dag_blob` captures the actual payload that was hashed, so
those rows still verify via `verify_run_dag()`. New rows after
this commit use the nested 5-clause shape. Operators reading
the `run_dag_blob` directly see the structure either way.

### 8.6 New tests

`tests/test_dag.py`:
- `test_preflight_node_hash_changes_with_answer_contract` —
  apply_caps flip in answer_contract → distinct node hash.
- `test_preflight_node_hash_changes_with_prompt_contract` —
  reminder injection flip → distinct hash.
- `test_preflight_node_hash_changes_with_policy_refs` —
  governance_policy_hash flip → distinct hash.
- `test_preflight_node_hash_includes_node_version` — pins
  `PREFLIGHT_NODE_VERSION = "preflight-node-v1"`.
- `test_reject_run_dag_three_stage_shape` — reject DAG always
  3 stages (question → preflight → final_label).
- `test_reject_run_dag_root_changes_with_preflight_hash` —
  audit-replay payoff for reject path.
- `test_reject_run_dag_round_trips_through_verify` — 3-stage
  shape verifies the same way as 7/9/8/10-stage shapes.

993 tests passing (6 net new); 36 skipped.

### 8.7 Status — re-closed

Closed · landed via this commit on top of `c36e85c`. Audit-binding
gap from feedback §6.2 (reject path) closed; nested-clause payload
landed per feedback §3; node_version + reminder_template_id +
policy_refs landed per feedback §4 + §9.
