diff --git a/aborist/cli.py b/aborist/cli.py index 6d02ffc..fc56d80 100644 --- a/aborist/cli.py +++ b/aborist/cli.py @@ -424,6 +424,11 @@ def _cmd_query(args: argparse.Namespace) -> int: # Strict mode: hard-block on lexical contradictions instead # of label-only. call_policy["metacognition_block_on_contradiction"] = True + if getattr(args, "soft_preflight", False): + # Ticket #000011 — opt-in to model-assisted soft preflight + # sidecar. Adds one short LLM round-trip; NEVER gates + # admissibility (D1 preserved). + call_policy["soft_preflight_enabled"] = True result = query( question=args.question, @@ -686,6 +691,19 @@ def _render_warrant_tail(result: dict) -> str: parts.append("out of corpus") if "reference_frame_ambiguous" in statuses: parts.append("frame ambiguous") + # Ticket #000011 — soft preflight sidecar hint. Renders distinctly + # from the hard tails above so an operator can tell at a glance + # that the signal is advisory. Skips SOFT_DISABLED / SOFT_PARSE_FAIL + # / SOFT_WELL_FORMED (no actionable signal). + soft = result.get("soft_preflight_hint") or {} + soft_label = soft.get("classifier_label") or "" + if soft_label and soft_label not in ( + "SOFT_DISABLED", "SOFT_PARSE_FAIL", "SOFT_WELL_FORMED", + ): + # Strip SOFT_ prefix + lowercase for tail readability + # (e.g. SOFT_FALSE_PREMISE_SUSPECTED → "false premise suspected"). + readable = soft_label.removeprefix("SOFT_").lower().replace("_", " ") + parts.append(f"soft: {readable}") if not parts: return "" return " · " + " · ".join(parts) @@ -1860,15 +1878,12 @@ def _cmd_providence_show_preflight( file=sys.stderr, ) return 1 - # The stage hash itself is informative; the payload that produced - # the hash is NOT in the persisted blob (only the leaf hash is — - # by Merkle convention, the inputs are derivable from the result - # dict at write time but not from the persisted blob alone). What - # we DO have on the result dict for the original write was the - # five-clause payload; for inspection we re-render the run-DAG - # node + cross-reference to the preflight_hash 12-char prefix - # callers may have seen in bench rows or `_render_query_human`. - out = { + # Pull the full preflight payload (Ticket #000009 §7.2 — payload + # now persisted alongside nodes via build_run_dag's + # preflight_payload kwarg). Fall back to hash-only render for + # legacy rows whose blob predates the payload-storage commit. + payload = parsed.get("preflight_payload") + out: dict = { "cache_key": row["cache_key"][:12], "question": row["question_text"], "preflight_stage_hash": preflight_node.get("hash"), @@ -1876,6 +1891,23 @@ def _cmd_providence_show_preflight( "run_dag_root": parsed.get("root"), "run_dag_stages": [n.get("stage") for n in nodes], } + if payload is not None: + # Verify the persisted payload hashes to the persisted leaf. + # Mismatch would indicate post-write tampering or a serialization + # drift; surface it explicitly so an auditor can detect. + from aborist.qa.dag import _canonical_json, _sha256_hex + recomputed = _sha256_hex(_canonical_json(payload)) + out["preflight_payload"] = payload + out["payload_hash_check"] = ( + "ok" if recomputed == preflight_node.get("hash") + else f"MISMATCH (recomputed {recomputed[:12]} != stored {preflight_node.get('hash', '')[:12]})" + ) + else: + out["preflight_payload"] = None + out["payload_hash_check"] = ( + "unavailable: legacy row predates preflight_payload " + "persistence (Ticket #000009 §7.2)" + ) print(json.dumps(out, indent=2, ensure_ascii=False)) return 0 @@ -3650,6 +3682,19 @@ def build_parser() -> argparse.ArgumentParser: "spouse is X married to' return PREFLIGHT_BLOCKED." ), ) + query_cmd.add_argument( + "--soft-preflight", + dest="soft_preflight", action="store_true", + help=( + "Ticket #000011 — opt-in to the model-assisted soft " + "preflight sidecar. Adds one short LLM round-trip " + "(~200ms median) before the main answer call; the model " + "classifies the question shape and returns a SOFT_* " + "advisory hint that surfaces as `· soft: