"""Governance bridge — decides and executes interventions.

GovernanceBridge is the abstract interface.
BuiltinBridge is the lightweight built-in implementation.

Key design:
- enforce() branches on pathway_id. escalation_level is NOT the execution switch.
  It is retained for telemetry and audit only.
- Both BuiltinBridge and the Governor bridge (CanonicalScannerGateBridge) always produce a pathway_id.
  pathway_id=None is error-containment, not a dispatch path.
- No LLM re-prompting. No revision loop. No ad hoc prose. No recovery of
  blocked content by implication, tone, or paraphrase.
- Audit log is append-only JSONL with a stable, boring schema.

PATHWAY RENDERING CONTRACT
--------------------------
Pathway renderers are deterministic. They render from:
  - decision.flags (to select the right hard-stop continuation)
  - decision.resource (escalation routing from policy)
  - decision.interaction_open (whether to invite further input)
  - static policy-authorized text only

No second LLM call. No improvised prose. No "helpful" framing that narrows
toward a blocked determination.

PATHWAY MAP
-----------
Pathway                      Flag class                    Continuation shape
---------------------------- ----------------------------- -----------------------------------------------
P_STOP_ESCALATE_EMERGENCY    EMERGENCY_TRIAGE_GUIDANCE     Immediate escalation; conversation closed
P_STOP_SUPPORTIVE_DEESCALATE SELF_HARM_INSTRUCTION         Supportive refusal; conversation open
P_STOP_TERMINAL              MEDICAL_DOSAGE_*              Refusal + pharmacist / clinician
                             PERSONALIZED_MEDICAL_ADVICE   Refusal + qualified clinician redirect
                             ILLEGAL_INSTRUCTION           Clean refusal only — no workaround adjacency
                             TARGETED_DEFAMATION           Clean refusal only
                             PERSONALIZED_LEGAL_ADVICE     Refusal + legal aid / resource offer
                             PERSONALIZED_FINANCIAL_ADVICE Refusal + licensed financial adviser redirect
                             (all others)                  Clean refusal
P_STOP_FORENSIC              SENSITIVE_PII_EXPOSURE        Stop + forensic notification chain
"""

from __future__ import annotations

import datetime
import hashlib
import json
import threading
import uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING

from aurora_lens.context import (
    auth_label_var,
    auth_policy_var,
    get_lock_metadata,
    get_request_hash,
    session_id_var,
    trace_id_var,
)
from aurora_lens.govern.audit_io import CHAIN_GENESIS, append_audit_entry, append_checkpoint_entry
from aurora_lens.log_slice import consume_log_slice
from aurora_lens.govern.decision import GovernanceDecision, InterventionAction
from aurora_lens.govern.policy import InterventionPolicy, DEFAULT_STRICT, DEFAULT_MODERATE
from aurora_lens.verify.flags import Flag, FlagType

# Forensic envelope schema version — must match governor.forensic_schema.FORENSIC_SCHEMA_VERSION.
# Defined here to avoid a governor/ import at bridge load time (verify_audit subprocess path).
FORENSIC_SCHEMA_VERSION = "1.0"

if TYPE_CHECKING:
    from aurora_lens.adapters.base import LLMAdapter
    from aurora_lens.pef.state import PEFState


# Audit log schema version (D1: canonical envelope)
_AUDIT_SCHEMA_VERSION = 2

# Thread-local sentinel used by _domain_fallback_continuation to signal
# error-containment (Tier 3/4) back to enforce() without signature changes.
# enforce() resets this before each render call and reads it after.
_fallback_sentinel = threading.local()

# Regex to strip a leading article ("a", "an", "the") from a resource string.
# Used by _with_article() to avoid doubling the article when the resource
# already carries one (e.g. "a licensed lawyer" → "A licensed lawyer", not
# "A a licensed lawyer").
import re as _re
_LEADING_ARTICLE_RE = _re.compile(r"^(?:a|an|the)\s+", _re.IGNORECASE)


def _with_article(resource: str) -> str:
    """Prepend 'A' to *resource*, stripping any leading article first.

    Examples:
        "a licensed lawyer"       → "A licensed lawyer"
        "the supervising lawyer"  → "The supervising lawyer"
        "qualified legal aid"     → "A qualified legal aid"
    """
    stripped = _LEADING_ARTICLE_RE.sub("", resource).strip()
    return f"A {stripped}"


# Forensic event status: action -> status string
_ACTION_TO_FORENSIC_STATUS: dict[InterventionAction, str] = {
    InterventionAction.CONTAIN: "ASK",
    InterventionAction.FORCE_REVISE: "REFUSE",
    InterventionAction.HARD_STOP: "STOP",
}


def build_forensic_event(
    decision: GovernanceDecision,
    *,
    pre_llm: bool,
    pef_snapshot: dict | None,
    trace_id: str = "",
    timestamp: str = "",
    audit_id: str | None = None,
) -> dict[str, object]:
    """Build canonical forensic_event envelope for non-ADMIT outcomes.

    Every ASK / REFUSE / STOP path produces a forensic event that records:
    - What was blocked and why (failed_constraints, status, domain)
    - What pathway was selected (pathway_id, output_mode, resolution_mode)
    - Whether the determination was closed (commitment_closed)
    - Whether the conversation continues (interaction_open)
    - What forensic obligations attach (forensic_obligations)
    - Content hashes proving what was shown vs suppressed, without storing either
    - PEF state hash for replay verification

    The event is self-hashed (event_hash) so it can be verified in isolation.
    """
    status = _ACTION_TO_FORENSIC_STATUS.get(decision.action, "REFUSE")
    attempted_action = "call_upstream" if pre_llm else "respond"
    failed_constraints = [f.flag_type.name for f in decision.flags] if decision.flags else []
    if pef_snapshot is not None:
        state_hash = hashlib.sha256(
            json.dumps(pef_snapshot, separators=(",", ":"), sort_keys=True).encode()
        ).hexdigest()
    else:
        state_hash = None
    route = (
        ESCALATION_ROUTES.get(decision.flags[0].flag_type.name, (None, None, None, None))
        if decision.flags
        else (None, None, None, None)
    )
    domain, subdomain = route[0], route[1]
    event: dict[str, object] = {
        "schema_version": FORENSIC_SCHEMA_VERSION,
        "trace_id": trace_id or (audit_id or ""),
        "timestamp": timestamp,
        "status": status,
        "attempted_action": attempted_action,
        "domain": domain,
        "subdomain": subdomain,
        "failed_constraints": failed_constraints,
        "state_hash": state_hash,
    }

    # Pathway metadata: what continuation was selected and its contractual properties.
    event["pathway_id"] = decision.pathway_id
    event["output_mode"] = decision.output_mode
    event["commitment_closed"] = decision.commitment_closed
    event["interaction_open"] = decision.interaction_open
    event["forensic_obligations"] = decision.forensic_obligations or []
    event["resolution_mode"] = decision.resolution_mode
    # Escalation target: the effective resource surfaced to the user (from
    # policy.escalation_target, optionally overridden by ESCALATION_ROUTES fallback).
    # Required for self-sufficient replay without re-running the resolver.
    event["escalation_target"] = decision.resource

    # Content hashes: prove what was suppressed and what was shown without
    # storing either text in the forensic event itself.
    if decision.original_response is not None:
        event["blocked_response_hash"] = "sha256:" + hashlib.sha256(
            decision.original_response.encode("utf-8")
        ).hexdigest()
    if decision.governed_response is not None:
        event["governed_response_hash"] = "sha256:" + hashlib.sha256(
            decision.governed_response.encode("utf-8")
        ).hexdigest()

    # Self-hash: SHA-256 of canonical JSON of event fields (excluding event_hash itself).
    # Allows the forensic_event to be verified in isolation without the outer audit entry.
    event["event_hash"] = "sha256:" + hashlib.sha256(
        json.dumps(event, separators=(",", ":"), sort_keys=True, ensure_ascii=False).encode("utf-8")
    ).hexdigest()
    return event

# Phase 7/12: Escalation routing — (domain, subdomain, requires_role, resource) per flag type
ESCALATION_ROUTES: dict[str, tuple[str | None, str | None, str | None, str | None]] = {
    "SELF_HARM_INSTRUCTION": ("human_safety", "self_harm", "clinical_escalation", "crisis support resource"),
    "ILLEGAL_INSTRUCTION": ("security", "illegal", "security_ops", None),
    "TARGETED_DEFAMATION": ("legal", "defamation", "legal_compliance", None),
    "SENSITIVE_PII_EXPOSURE": ("privacy", "pii", "privacy_officer", None),
}


# ── Renderer context types ────────────────────────────────────────────────────
#
# The governing principle:
#   Clarification may expose admissible alternatives.
#   Refusal may not expose blocked substance.
#
# Each pathway class gets its own context type carrying exactly what that class
# of renderer is permitted to see. No renderer receives a GovernanceDecision
# or any field derived from blocked content (evidence, claim, original_response).
#
# AmbiguityRendererContext  → P_ASK_DISAMBIGUATE, P_ASK_MISSING_FACT
#   May carry: normalized PEF candidate labels, missing field names.
#   May NOT carry: evidence text, claim text, any blocked content.
#
# RefusalRendererContext    → P_REFUSE_EXPLAIN_REDIRECT, P_REFUSE_ESCALATE_PRO,
#                             P_HANDOFF_SUMMARY
#   May carry: flag type (for routing), policy-sourced resource, interaction_open.
#   May NOT carry: anything from the blocked determination.
#
# HardStopRendererContext   → P_STOP_TERMINAL, P_STOP_FORENSIC
#   May carry: flag type (for continuation routing), policy-sourced resource.
#   May NOT carry: anything from the blocked determination.

@dataclass(frozen=True)
class AmbiguityRendererContext:
    """Renderer context for clarification pathways.

    candidates: normalized PEF entity/referent labels — e.g. ("Anna's sister", "Emma's sister").
                These come from Flag.candidates, which is populated by the checker from the
                world model. Never from raw evidence text.
    missing_fields: names of missing facts the interaction needs — e.g. ("dosage unit",).
    interaction_open: True for all clarification pathways.
    """
    candidates: tuple[str, ...]
    missing_fields: tuple[str, ...]
    interaction_open: bool = True


@dataclass(frozen=True)
class RefusalRendererContext:
    """Renderer context for refusal pathways.

    flag_type: the primary flag's type enum — used for routing to the right
               refusal text. No content from the flag is permitted.
    resource:  policy-sourced redirect target (e.g. "a licensed financial adviser").
               Never derived from user input or LLM output.
    interaction_open: whether the interaction continues after this refusal.
    """
    flag_type: FlagType | None
    resource: str | None
    interaction_open: bool


@dataclass(frozen=True)
class HardStopRendererContext:
    """Renderer context for hard-stop pathways.

    flag_type:        the primary flag's type enum — used for domain-appropriate
                      continuation routing. No content from the flag is permitted.
    resource:         policy-sourced redirect target. Never derived from blocked content.
    interaction_open: when True, the renderer may append a bounded follow-up question
                      inside the lawful procedural corridor. Never for ILLEGAL_INSTRUCTION
                      or TARGETED_DEFAMATION regardless of this flag.
    domain:           coarse domain string ("medical", "legal", "finance", "harmful",
                      "ambiguity", or None). Used only for the domain-fallback renderer
                      when flag_type does not match a specific handler. Never carries
                      blocked content — derived from flag taxonomy only.
    """
    flag_type: FlagType | None
    resource: str | None
    interaction_open: bool = False
    domain: str | None = None


# Minimal flag-type → domain string mapping for fallback routing.
# This is intentionally a local bridge-layer map, not the canonical governor
# domain resolution (which handles ContextVars, rule-ids, and explicit routing).
# It exists purely so the catch-all renderer can produce a lawful domain-appropriate
# fallback when a new FlagType is added and no specific handler exists yet.
_HARD_STOP_FLAG_DOMAIN: dict[FlagType, str] = {
    FlagType.MEDICAL_DOSAGE_RECOMMENDATION:   "medical",
    FlagType.PEDIATRIC_DOSAGE_RECOMMENDATION: "medical",
    FlagType.NUMERIC_MEDICAL_INSTRUCTION:     "medical",
    FlagType.EMERGENCY_TRIAGE_GUIDANCE:       "medical",
    FlagType.SELF_HARM_INSTRUCTION:           "medical",
    FlagType.PERSONALIZED_MEDICAL_ADVICE:     "medical",
    FlagType.PERSONALIZED_LEGAL_ADVICE:       "legal",
    FlagType.ILLEGAL_INSTRUCTION:             "harmful",
    FlagType.TARGETED_DEFAMATION:             "harmful",
    FlagType.PERSONALIZED_FINANCIAL_ADVICE:   "finance",
    FlagType.SENSITIVE_PII_EXPOSURE:          "legal",
    FlagType.UNRESOLVED_STATE_TRANSITION:     "procedural",    # data-protection is a legal/compliance domain
}


# ── Context builders ──────────────────────────────────────────────────────────
#
# These are the only functions that may read from GovernanceDecision to produce
# renderer input. They extract only permitted fields and discard everything else.
# enforce() calls these before dispatching to any renderer.

def _build_ambiguity_context(decision: GovernanceDecision) -> AmbiguityRendererContext:
    candidates: list[str] = []
    missing_fields: list[str] = []
    for flag in decision.flags:
        if flag.candidates:
            for c in flag.candidates:
                if c not in candidates:
                    candidates.append(c)
        elif flag.entity_name and flag.entity_name not in missing_fields:
            missing_fields.append(flag.entity_name)
    return AmbiguityRendererContext(
        candidates=tuple(candidates),
        missing_fields=tuple(missing_fields),
        interaction_open=decision.interaction_open,
    )


def _build_refusal_context(decision: GovernanceDecision) -> RefusalRendererContext:
    return RefusalRendererContext(
        flag_type=decision.flags[0].flag_type if decision.flags else None,
        resource=decision.resource,
        interaction_open=decision.interaction_open,
    )


def _build_hard_stop_context(decision: GovernanceDecision) -> HardStopRendererContext:
    flag_type = decision.flags[0].flag_type if decision.flags else None
    return HardStopRendererContext(
        flag_type=flag_type,
        resource=decision.resource,
        interaction_open=decision.interaction_open,
        domain=_HARD_STOP_FLAG_DOMAIN.get(flag_type) if flag_type is not None else None,
    )


# ── Pathway renderers ─────────────────────────────────────────────────────────
#
# Each renderer takes its typed context and returns a user-visible string.
# Renderers must not:
#   - call any LLM
#   - generate ad hoc prose
#   - recover, imply, suggest, or narratively complete a blocked determination
#   - produce workaround-adjacent language for ILLEGAL_INSTRUCTION
#   - access anything outside their typed context
#
# All text is policy-authorized and static except for the resource field
# and candidate labels, both of which are themselves policy/PEF-sourced.

def _render_ask_disambiguate(ctx: AmbiguityRendererContext) -> str:
    """P_ASK_DISAMBIGUATE: surface admissible PEF candidates, keep interaction open."""
    if ctx.candidates:
        if len(ctx.candidates) == 2:
            return f"Do you mean {ctx.candidates[0]} or {ctx.candidates[1]}?"
        options = ", ".join(ctx.candidates[:-1]) + f", or {ctx.candidates[-1]}"
        return f"Could you clarify which you mean: {options}?"
    return (
        "I need a bit more information before I can respond to that. "
        "Could you clarify which specific entity or subject you're referring to?"
    )


def _render_ask_missing_fact(ctx: AmbiguityRendererContext) -> str:
    """P_ASK_MISSING_FACT: name the missing fields, keep interaction open."""
    if ctx.missing_fields:
        if len(ctx.missing_fields) == 1:
            return f"I need {ctx.missing_fields[0]} before I can respond to that."
        fields = ", ".join(ctx.missing_fields[:-1]) + f", and {ctx.missing_fields[-1]}"
        return f"I need {fields} before I can respond to that."
    return (
        "I need additional context before I can respond. "
        "Could you provide the missing information?"
    )


def _render_refuse_explain_redirect(ctx: RefusalRendererContext) -> str:
    """P_REFUSE_EXPLAIN_REDIRECT: refusal + optional policy-sourced resource redirect."""
    primary = _hard_stop_text(ctx.flag_type, ctx.resource)
    if ctx.resource:
        return f"{primary} For assistance, you may contact: {ctx.resource}."
    return primary


def _render_refuse_escalate_pro(ctx: RefusalRendererContext) -> str:
    """P_REFUSE_ESCALATE_PRO: refusal + professional referral."""
    primary = _hard_stop_text(ctx.flag_type, ctx.resource)
    resource = ctx.resource or "a qualified professional in the relevant field"
    return f"{primary} I'd recommend consulting {resource}."


def _render_handoff_summary(ctx: RefusalRendererContext) -> str:
    """P_HANDOFF_SUMMARY: boundary notice, offer to summarise or redirect."""
    return (
        "I'm not able to continue with that determination. "
        "I can summarise what has been established in our conversation so far, "
        "or help you identify who can assist further."
    )


def _render_stop_terminal(ctx: HardStopRendererContext) -> str:
    """P_STOP_TERMINAL: hard stop with domain-appropriate continuation."""
    return _hard_stop_text(ctx.flag_type, ctx.resource, ctx.interaction_open, ctx.domain)


def _render_stop_forensic(ctx: HardStopRendererContext) -> str:
    """P_STOP_FORENSIC: hard stop; forensic obligations attach via audit layer."""
    return _hard_stop_text(ctx.flag_type, ctx.resource, ctx.interaction_open, ctx.domain)


def _render_stop_escalate(ctx: HardStopRendererContext) -> str:
    """P_STOP_ESCALATE: terminate the prohibited determination; emit immediate escalation.

    Legacy pathway — retained for BuiltinBridge and backward compatibility.
    New code should use P_STOP_ESCALATE_EMERGENCY or P_STOP_SUPPORTIVE_DEESCALATE.
    """
    target = ctx.resource or "emergency services or your nearest GP"
    return f"I can't make that determination. Please contact {target}."


def _render_stop_escalate_emergency(ctx: HardStopRendererContext) -> str:
    """P_STOP_ESCALATE_EMERGENCY: physical medical emergency — immediate, unconditional escalation.

    Used when the system detects emergency triage content (cardiac, respiratory, etc.).
    The conversation is closed (interaction_open=False from policy). The renderer:
      - States inability without conditional framing ("if this is a medical emergency")
      - Directs immediately to emergency care — no assessment, no ranking, no delay suggestion
      - Does not offer a follow-up question (this is a physical emergency, not a dialogue)
    """
    target = ctx.resource or "your nearest GP, nurse on call, or call an ambulance"
    return f"I can't make that determination. Please contact {target} immediately."


def _render_stop_supportive_deescalate(ctx: HardStopRendererContext) -> str:
    """P_STOP_SUPPORTIVE_DEESCALATE: psychological crisis — supportive refusal with open conversation.

    Used when the system detects self-harm or suicide content. The conversation
    remains open (interaction_open=True from policy). The renderer:
      - Blocks the determination unconditionally (no method, no assessment)
      - Acknowledges the person without clinical distance
      - Directs to crisis support — not to a GP or ambulance (wrong escalation target)
      - Explicitly keeps the conversation open ("I'm here if you want to talk")
    """
    target = ctx.resource or "a crisis support service or emergency services"
    return (
        f"I can't help with that. "
        f"If you're going through a difficult time, please reach out to {target} — "
        f"they are available to help. "
        f"I'm here if you want to talk about what's going on."
    )


def _render_stop_redirect_qualified(ctx: HardStopRendererContext) -> str:
    """P_STOP_REDIRECT_QUALIFIED: refusal with lawful professional/resource redirect.

    Used for flag classes that carry an appropriate redirect target (dosage, personalized
    medical/legal/financial advice). The refusal is clean and the redirect is surfaced.
    The conversation may remain open (per policy's interaction_open field).
    """
    return _hard_stop_text(ctx.flag_type, ctx.resource, ctx.interaction_open, ctx.domain)


def _render_stop_refuse_clean(ctx: HardStopRendererContext) -> str:
    """P_STOP_REFUSE_CLEAN: refusal without redirect or workaround adjacency.

    Used for flag classes where any redirect would create an exploit surface
    (e.g. ILLEGAL_INSTRUCTION). No escalation target is surfaced. The response
    is a flat, auditable refusal. The policy's interaction_open field is respected
    so that a boundary explanation can be offered without a redirect.
    """
    return _hard_stop_text(ctx.flag_type, None, ctx.interaction_open, ctx.domain)


def _hard_stop_text(
    flag_type: FlagType | None,
    resource: str | None,
    interaction_open: bool = False,
    domain: str | None = None,
) -> str:
    """Select the lawful user-visible continuation for a hard-stop flag class.

    When interaction_open=True, the renderer appends a bounded follow-up question
    that keeps the conversation inside the lawful procedural corridor without
    recovering any part of the prohibited determination.

    ILLEGAL_INSTRUCTION, TARGETED_DEFAMATION, and SENSITIVE_PII_EXPOSURE are
    always clean stops — interaction_open is ignored for these flag classes.

    Unknown/future flag types fall through to a domain-appropriate generic
    fallback (medical / legal / finance / harmful / None). The fallback always
    produces a lawful, auditable response — never silent failure.

    Branches on flag_type (enum value only — no content).
    resource and domain are policy/taxonomy-sourced. No field carries blocked content.
    """
    if flag_type in (
        FlagType.MEDICAL_DOSAGE_RECOMMENDATION,
        FlagType.PEDIATRIC_DOSAGE_RECOMMENDATION,
        FlagType.NUMERIC_MEDICAL_INSTRUCTION,
    ):
        target = resource or "a pharmacist, prescribing clinician, or urgent care provider"
        refusal = (
            "I'm not able to provide medication dosage or treatment instructions. "
            f"Please speak with {target}."
        )
        if interaction_open:
            return (
                f"{refusal} "
                "Is there something about your situation I can help you prepare "
                "to discuss with them?"
            )
        return refusal

    if flag_type == FlagType.EMERGENCY_TRIAGE_GUIDANCE:
        target = resource or "emergency services (call 911 or your local emergency number) or urgent care"
        # Emergency triage: direct the person out immediately — no follow-up question.
        return (
            "I'm not able to provide emergency triage guidance. "
            f"If this is a medical emergency, please contact {target} immediately."
        )

    if flag_type == FlagType.SELF_HARM_INSTRUCTION:
        target = resource or "a crisis support service"
        refusal = (
            "I can't help with that. "
            f"If you're going through a difficult time, please reach out to {target} — "
            "they are available to help."
        )
        if interaction_open:
            return (
                f"{refusal} "
                "I'm here if you want to talk about what's going on."
            )
        return refusal

    if flag_type == FlagType.PERSONALIZED_MEDICAL_ADVICE:
        target = resource or "a qualified clinician or healthcare provider"
        refusal = (
            "I can't make that determination from the information here. "
            f"Please consult {target} for guidance specific to your circumstances."
        )
        if interaction_open:
            return (
                f"{refusal} "
                "Would you like me to help you prepare the key questions to raise with your clinician?"
            )
        return refusal

    if flag_type == FlagType.PERSONALIZED_LEGAL_ADVICE:
        target = resource or "a qualified lawyer, legal aid service, or citizens advice bureau"
        refusal = (
            "I can't determine that legal position from the facts as established here. "
            f"{_with_article(target)} may be able to help with your situation."
        )
        if interaction_open:
            return (
                f"{refusal} "
                "Would you like help preparing a timeline or questions for a qualified adviser?"
            )
        return refusal

    if flag_type == FlagType.UNRESOLVED_STATE_TRANSITION:
        if domain == "document":
            refusal = "I can't determine that from the material established so far."
            if interaction_open:
                return f"{refusal} I can help identify what still needs to be checked."
            return refusal
        # Default / procedural
        refusal = "That status is not confirmed yet."
        if interaction_open:
            return f"{refusal} I can help you write a message to verify it before you act on it."
        return refusal

    if flag_type == FlagType.PERSONALIZED_FINANCIAL_ADVICE:
        target = resource or "a licensed financial adviser"
        refusal = (
            "I'm not able to provide personalised financial advice. "
            f"Please consult {target} for guidance appropriate to your situation."
        )
        if interaction_open:
            return (
                f"{refusal} "
                "I can help with general financial concepts or help you prepare "
                "questions for an adviser if that would be useful."
            )
        return refusal

    if flag_type == FlagType.ILLEGAL_INSTRUCTION:
        # Content stop + determination stop.
        # Bounded transparency is not workaround adjacency — it makes the refusal
        # legible without narrowing toward the blocked output.
        # No "however", no alternatives, no path toward the blocked act.
        #
        # INTENTIONAL: resource (escalation_target) is NOT surfaced here, even
        # when the policy matrix supplies one (e.g. "a licensed lawyer or legal
        # aid service" for legal:GP:STOP).  Directing a user toward a professional
        # for a clearly illegal request implies the professional would facilitate
        # the act.  In DA/HS contexts the supervisor is notified via forensic
        # audit obligations — not via the user-facing message.
        if interaction_open:
            return (
                "I'm not able to assist with that request. "
                "I can explain the policy boundary if that would be useful."
            )
        return "I'm not able to assist with that."

    if flag_type == FlagType.TARGETED_DEFAMATION:
        # Content stop + determination stop.
        # Reporting-channel diversion does not launder defamation back in —
        # it moves from accusation-production to lawful process.
        #
        # When the policy supplies a resource (e.g. "a supervising lawyer" for
        # DA/HS contexts, or "a licensed lawyer or legal aid service" for GP),
        # include it as the mandatory handoff sentence — terminal does not mean
        # silent when the policy names an escalation target.
        if resource:
            return (
                "I'm not able to produce that content. "
                f"Please contact {resource} for guidance."
            )
        if interaction_open:
            return (
                "I'm not able to produce that content. "
                "If you have a concern about someone's conduct, I can help you "
                "think through an appropriate reporting channel."
            )
        return "I'm not able to produce that content."

    if flag_type == FlagType.SENSITIVE_PII_EXPOSURE:
        # Content stop + determination stop.
        # Data-governance transparency is lawful continuation.
        #
        # When the policy supplies a resource (e.g. "the supervising lawyer" for
        # legal:HS:STOP), include it — the data-protection escalation target is
        # the mandatory handoff.  Without a resource, name the governance boundary
        # and offer to connect the user to the right channel.
        if resource:
            return (
                "I'm not able to surface that information. "
                f"Please contact {resource} for assistance."
            )
        if interaction_open:
            return (
                "I'm not able to surface that information. "
                "I can help you contact the right support team or explain the "
                "data-governance boundary that applies here."
            )
        return "I'm not able to surface that information."

    # ── Domain-fallback ladder (Tier 2) ───────────────────────────────────────
    # Fires when the flag type has no specific handler above.
    # Tier 1: specific flag handler (above)
    # Tier 2: domain-appropriate REFUSE-family continuation (below)
    # Tier 3: harmful-domain STOP_TERMINAL
    # Tier 4: final clean stop
    #
    # Medical / legal / finance unknown → REFUSE_ESCALATE_PRO or
    # REFUSE_EXPLAIN_REDIRECT shape. Closes commitment. Keeps interaction
    # open procedurally. The Governor's role is to constrain, explain,
    # summarise, route, or escalate — not to be an unusually articulate bouncer.
    # Only intrinsically hostile domains get a true terminal stop.
    return _domain_fallback_continuation(domain, resource, interaction_open)


def _domain_fallback_continuation(
    domain: str | None,
    resource: str | None,
    interaction_open: bool,
) -> str:
    """Tier-2 fallback: domain-appropriate REFUSE-family continuation.

    Called when flag_type has no specific handler in _hard_stop_text.
    Never called for flag types with specific handlers.

    Fallback ladder:
      medical  → REFUSE_ESCALATE_PRO shape  (redirect to professional + optional prep question)
      legal    → REFUSE_EXPLAIN_REDIRECT shape (boundary + redirect + optional procedural help)
      finance  → REFUSE_EXPLAIN_REDIRECT shape (boundary + redirect + optional prep question)
      harmful  → STOP_TERMINAL shape (clean stop — intrinsically hostile domain)
      None     → final clean stop

    Commitment is always closed. interaction_open governs follow-up question only.
    """
    if domain == "medical":
        # REFUSE_ESCALATE_PRO shape: refusal + professional escalation + optional prep question.
        target = resource or "a qualified clinician, pharmacist, or healthcare provider"
        refusal = (
            "I can't make that determination from the information here. "
            f"Please consult {target} for advice specific to your situation."
        )
        if interaction_open:
            return (
                f"{refusal} "
                "Would you like me to help you prepare the key questions to raise with your clinician?"
            )
        return refusal

    if domain == "legal":
        # REFUSE_EXPLAIN_REDIRECT shape: boundary + redirect + optional procedural preparation.
        target = resource or "a qualified lawyer, legal aid service, or citizens advice bureau"
        refusal = (
            "I can't determine that legal position from the facts as established here. "
            f"{_with_article(target)} may be able to help."
        )
        if interaction_open:
            return (
                f"{refusal} "
                "Would you like help preparing a timeline or questions for a qualified adviser?"
            )
        return refusal

    if domain == "finance":
        # REFUSE_EXPLAIN_REDIRECT shape: boundary + redirect + optional general-concept help.
        target = resource or "a licensed financial adviser"
        refusal = (
            "I'm not able to provide financial guidance on that. "
            f"Please consult {target} for advice appropriate to your situation."
        )
        if interaction_open:
            return (
                f"{refusal} "
                "I can help with general financial concepts or help you prepare "
                "questions for an adviser if that would be useful."
            )
        return refusal

    # Tier 3: harmful domain (unknown illegal / exploitative / defamatory class)
    # → STOP_TERMINAL shape. No redirect. No follow-up. No workaround adjacency.
    # This path is an error-containment state — it signals that a hostile-class
    # flag type reached the system without a specific handler. Audit loudly.
    if domain == "harmful":
        _fallback_sentinel.reason = "unmapped_hostile_flag"
        return "I'm not able to help with that."

    # Tier 4: final clean stop — domain unknown, no safe corridor available.
    # This path signals a malformed decision: a HARD_STOP with no flags,
    # or a flag type that produced no domain signal. Audit loudly.
    _fallback_sentinel.reason = "malformed_decision"
    return "I'm not able to assist with that yet. I can help you identify what information is still needed."


# ── Pathway dispatch ──────────────────────────────────────────────────────────

_AMBIGUITY_PATHWAYS = frozenset({"P_ASK_DISAMBIGUATE", "P_ASK_MISSING_FACT"})
_REFUSAL_PATHWAYS = frozenset({
    "P_REFUSE_EXPLAIN_REDIRECT", "P_REFUSE_ESCALATE_PRO", "P_HANDOFF_SUMMARY",
})
_HARD_STOP_PATHWAYS = frozenset({"P_STOP_TERMINAL", "P_STOP_FORENSIC"})
_ESCALATE_PATHWAYS = frozenset({"P_STOP_ESCALATE", "P_STOP_ESCALATE_EMERGENCY"})
_SUPPORTIVE_PATHWAYS = frozenset({"P_STOP_SUPPORTIVE_DEESCALATE"})
_REDIRECT_PATHWAYS = frozenset({"P_STOP_REDIRECT_QUALIFIED"})
_CLEAN_STOP_PATHWAYS = frozenset({"P_STOP_REFUSE_CLEAN"})

# BuiltinBridge default pathway_id mapping.
# PASS and SOFT_CORRECT are handled by the early gate in enforce() — no pathway needed.
# All other actions map to the most-specific canonical pathway available in the matrix.
_ACTION_TO_PATHWAY: dict[InterventionAction, str] = {
    InterventionAction.CONTAIN:      "P_ASK_DISAMBIGUATE",
    InterventionAction.FORCE_REVISE: "P_REFUSE_EXPLAIN_REDIRECT",
    InterventionAction.HARD_STOP:    "P_STOP_TERMINAL",
}


def enforce(decision: GovernanceDecision, model_output: str) -> str:
    """Deterministic enforcement: (policy_decision, model_output) -> final response.

    No LLM re-prompting. No revision loop.

    Branching priority:
      1. PASS / SOFT_CORRECT: return model_output unchanged.
         SOFT_CORRECT is "annotate and pass" — the correction lives in governance_note
         and the audit log; the user sees the original model output.
      2. pathway_id present: build typed renderer context, dispatch.
         The renderer never receives GovernanceDecision or blocked content.
         escalation_level is NOT the execution switch.
      3. pathway_id is None: error-containment. Both CanonicalScannerGateBridge and
         BuiltinBridge always produce a pathway_id; a None value signals a malformed
         decision object. Fail safe via domain-fallback ladder and audit loudly.

    Error-containment audit marking:
      When Tier 3/4 of the domain-fallback ladder fires (unmapped hostile flag,
      malformed decision, or missing pathway_id), decision.unexpected_unclassified_termination
      is set True and decision.fallback_reason names the gap. These fields are written
      to the audit log by _log_decision. They are error signals, not product state.
    """
    # Gate 1: PASS and SOFT_CORRECT — deliver model output, no intervention.
    if decision.action in (InterventionAction.PASS, InterventionAction.SOFT_CORRECT):
        return model_output

    # Reset the thread-local fallback sentinel before any render call.
    _fallback_sentinel.reason = None

    # Gate 2: pathway_id-driven dispatch (canonical path).
    if decision.pathway_id is not None:
        if decision.pathway_id in _AMBIGUITY_PATHWAYS:
            ctx = _build_ambiguity_context(decision)
            if decision.pathway_id == "P_ASK_DISAMBIGUATE":
                return _render_ask_disambiguate(ctx)
            return _render_ask_missing_fact(ctx)

        if decision.pathway_id in _REFUSAL_PATHWAYS:
            ctx = _build_refusal_context(decision)
            if decision.pathway_id == "P_REFUSE_EXPLAIN_REDIRECT":
                return _render_refuse_explain_redirect(ctx)
            if decision.pathway_id == "P_REFUSE_ESCALATE_PRO":
                return _render_refuse_escalate_pro(ctx)
            return _render_handoff_summary(ctx)

        if decision.pathway_id in _HARD_STOP_PATHWAYS:
            ctx = _build_hard_stop_context(decision)
            result = _render_stop_terminal(ctx)  # terminal and forensic produce identical text
            _mark_fallback_if_fired(decision)
            return result

        if decision.pathway_id in _ESCALATE_PATHWAYS:
            ctx = _build_hard_stop_context(decision)
            if decision.pathway_id == "P_STOP_ESCALATE_EMERGENCY":
                return _render_stop_escalate_emergency(ctx)
            return _render_stop_escalate(ctx)

        if decision.pathway_id in _SUPPORTIVE_PATHWAYS:
            ctx = _build_hard_stop_context(decision)
            return _render_stop_supportive_deescalate(ctx)

        if decision.pathway_id in _REDIRECT_PATHWAYS:
            ctx = _build_hard_stop_context(decision)
            return _render_stop_redirect_qualified(ctx)

        if decision.pathway_id in _CLEAN_STOP_PATHWAYS:
            ctx = _build_hard_stop_context(decision)
            return _render_stop_refuse_clean(ctx)

        # Unknown pathway_id — routing gap, not a hostile-class stop.
        # The decision has flags and a domain; use the domain-fallback ladder
        # rather than a bare wall. Always audit as an unexpected state.
        ctx = _build_hard_stop_context(decision)
        result = _domain_fallback_continuation(ctx.domain, ctx.resource, ctx.interaction_open)
        decision.unexpected_unclassified_termination = True
        decision.fallback_reason = getattr(_fallback_sentinel, "reason", None) or "unmapped_pathway_id"
        return result

    # Gate 3: pathway_id is None — error-containment.
    # This should never be reached: both the Governor bridge (CanonicalScannerGateBridge)
    # and BuiltinBridge always produce a pathway_id. If reached, the decision object is malformed.
    ctx = _build_hard_stop_context(decision)
    result = _domain_fallback_continuation(ctx.domain, ctx.resource, ctx.interaction_open)
    decision.unexpected_unclassified_termination = True
    decision.fallback_reason = "missing_pathway_id"
    return result


def _mark_fallback_if_fired(decision: GovernanceDecision) -> None:
    """Check the thread-local sentinel after a render call and mark decision if Tier 3/4 fired."""
    reason = getattr(_fallback_sentinel, "reason", None)
    if reason is not None:
        decision.unexpected_unclassified_termination = True
        decision.fallback_reason = reason


class GovernanceBridge(ABC):
    """Abstract interface for governance integration."""

    @abstractmethod
    async def decide(
        self,
        flags: list[Flag],
        response_text: str,
        pef: PEFState,
    ) -> GovernanceDecision:
        """Evaluate flags and produce a governance decision."""
        ...

    @abstractmethod
    async def intervene(
        self,
        decision: GovernanceDecision,
        adapter: LLMAdapter,
        user_input: str,
        pef_context: str,
        history: list[dict[str, str]] | None = None,
    ) -> str:
        """Execute the intervention. Returns the final response text."""
        ...

    def log_decision(
        self,
        decision: GovernanceDecision,
        turn: int = 0,
        *,
        stream: bool = False,
        stream_completed: bool = True,
        stream_abort_reason: str | None = None,
        stream_truncated: bool = False,
        stream_dropped_chars: int = 0,
        pef_context: str | None = None,
        pre_llm: bool = False,
        pef_snapshot: dict | None = None,
    ) -> None:
        """Log a governance decision. Override for audit trail."""
        pass


class BuiltinBridge(GovernanceBridge):
    """Lightweight built-in governance bridge.

    Uses InterventionPolicy for flag->action mapping.
    Logs every decision to append-only JSONL.
    Phase D: Optional HMAC signing, log rotation, hash chain (D2).
    """

    def __init__(
        self,
        policy: InterventionPolicy | None = None,
        audit_path: str | Path | None = None,
        audit_signing_key: str | bytes | None = None,
        audit_log_max_mb: int = 0,
        audit_checkpoint_interval: int = 0,
        mode: str = "public",
        policy_version: str = "1.0",
    ):
        self._policy = policy or DEFAULT_STRICT
        self._audit_path = Path(audit_path) if audit_path else None
        self._audit_signing_key = (
            audit_signing_key.encode("utf-8") if isinstance(audit_signing_key, str) else audit_signing_key
        ) if audit_signing_key else None
        self._audit_log_max_mb = max(0, audit_log_max_mb)
        self._audit_checkpoint_interval = max(0, audit_checkpoint_interval)
        self._mode = mode
        self._policy_version = policy_version
        self._chain_lock = threading.Lock()
        self._prev_cid = self._read_last_cid()
        self._entry_count = self._read_entry_count()
        if self._audit_path:
            self._audit_path.parent.mkdir(parents=True, exist_ok=True)
        # Stable process-startup identifier for log attribution across restarts.
        self._run_id: str = str(uuid.uuid4())

    def _read_last_cid(self) -> str:
        if not self._audit_path or not self._audit_path.exists():
            return CHAIN_GENESIS
        try:
            lines = [ln for ln in self._audit_path.read_text(encoding="utf-8").strip().splitlines() if ln.strip()]
            if not lines:
                return CHAIN_GENESIS
            last = json.loads(lines[-1])
            return last.get("cid") or CHAIN_GENESIS
        except (OSError, json.JSONDecodeError, IndexError):
            return CHAIN_GENESIS

    def _read_entry_count(self) -> int:
        if not self._audit_path or not self._audit_path.exists():
            return 0
        try:
            lines = [ln for ln in self._audit_path.read_text(encoding="utf-8").strip().splitlines() if ln.strip()]
            count = 0
            for ln in lines:
                try:
                    entry = json.loads(ln)
                    if entry.get("type") != "checkpoint":
                        count += 1
                except json.JSONDecodeError:
                    pass
            return count
        except OSError:
            return 0

    def _maybe_checkpoint(self) -> None:
        if self._audit_checkpoint_interval <= 0 or not self._audit_path or not self._audit_signing_key:
            return
        if self._entry_count % self._audit_checkpoint_interval != 0:
            return
        new_cid = append_checkpoint_entry(
            self._audit_path,
            self._prev_cid,
            signing_key=self._audit_signing_key,
            max_mb=self._audit_log_max_mb,
        )
        if new_cid is not None:
            self._prev_cid = new_cid

    async def decide(
        self,
        flags: list[Flag],
        response_text: str,
        pef: PEFState,
    ) -> GovernanceDecision:
        action, rule = self._policy.evaluate_with_rule(flags)
        rule_id = rule.rule_id if rule else None

        rationale = self._build_rationale(flags, action)
        note = self._build_governance_note(flags) if action == InterventionAction.SOFT_CORRECT else None

        safe_alt = rule.safe_alt if rule else None
        resource = None
        if rule and action == InterventionAction.HARD_STOP:
            route = ESCALATION_ROUTES.get(rule.flag_type.name, (None, None, None, None))
            resource = route[3] if len(route) > 3 else None

        return GovernanceDecision(
            action=action,
            flags=flags,
            rationale=rationale,
            policy=self._policy.name,
            attempt=0,
            governance_note=note,
            rule_id=rule_id,
            safe_alt=safe_alt,
            resource=resource,
            pathway_id=_ACTION_TO_PATHWAY.get(action),
        )

    async def intervene(
        self,
        decision: GovernanceDecision,
        adapter: LLMAdapter,
        user_input: str,
        pef_context: str,
        history: list[dict[str, str]] | None = None,
    ) -> str:
        """Execute the intervention. Uses enforce() for deterministic output."""
        model_output = decision.original_response or ""
        msg = enforce(decision, model_output)
        decision.corrected_response = msg
        decision.governed_response = msg
        return msg

    def _build_rationale(self, flags: list[Flag], action: InterventionAction) -> str:
        if not flags:
            return "No verification flags"
        flag_types = set(f.flag_type.name for f in flags)
        severities = set(f.severity for f in flags)
        worst_severity = "error" if "error" in severities else "warning"
        return (
            f"{action.name}: {len(flags)} flag(s) "
            f"[{', '.join(sorted(flag_types))}] "
            f"(worst severity: {worst_severity})"
        )

    def _build_governance_note(self, flags: list[Flag]) -> str:
        parts = []
        for flag in flags:
            parts.append(f"{flag.flag_type.name}: {flag.claim} — {flag.evidence}")
        return "; ".join(parts)

    def _log_decision(
        self,
        decision: GovernanceDecision,
        final_response: str | None = None,
        turn: int = 0,
        *,
        stream: bool = False,
        stream_completed: bool = True,
        stream_abort_reason: str | None = None,
        stream_truncated: bool = False,
        stream_dropped_chars: int = 0,
        pef_context: str | None = None,
        pre_llm: bool = False,
        pef_snapshot: dict | None = None,
    ) -> None:
        if self._audit_path is None:
            return

        ts = datetime.datetime.now(datetime.timezone.utc).isoformat()
        trace_id = trace_id_var.get(None) or ""
        if decision.action not in (InterventionAction.PASS, InterventionAction.SOFT_CORRECT):
            forensic_event = build_forensic_event(
                decision,
                pre_llm=pre_llm,
                pef_snapshot=pef_snapshot,
                trace_id=trace_id,
                timestamp=ts,
                audit_id=decision.cid,
            )
            decision.forensic_event = forensic_event
        tenant_label = auth_label_var.get(None)
        request_hash = get_request_hash()
        ctx = pef_context or ""
        state_hash = hashlib.sha256(ctx.encode()).hexdigest()[:32] if ctx else None

        failed_constraints = [f.flag_type.name for f in decision.flags] if decision.flags else []

        entry: dict[str, object] = {
            "schema_version": _AUDIT_SCHEMA_VERSION,
            "run_id": self._run_id,
            "trace_id": trace_id,
            "timestamp": ts,
            "session_id": session_id_var.get(None),
            "turn": turn,
            "tenant_label": tenant_label,
            "mode": self._mode,
            "policy_profile": decision.policy,
            "policy_version": self._policy_version,
            "outcome": decision.action.name,
            "failed_constraints": failed_constraints,
            "original_response": decision.original_response,
            "governed_response": decision.governed_response,
            "state_hash": state_hash,
            "request_hash": request_hash,
            "log_slice_present": False,
        }

        log_slice = consume_log_slice()
        if log_slice is not None:
            entry["log_slice_present"] = True
            entry.update(log_slice)

        if decision.action != InterventionAction.PASS:
            flag_list = decision.flags
            trigger_spans = [f.evidence[:200] for f in flag_list[:3]]
            _LEVEL_TO_ACTION = {1: "CLARIFY", 2: "REFUSE", 3: "STOP"}
            action_taken = _LEVEL_TO_ACTION.get(decision.escalation_level, "REFUSE")
            route = ESCALATION_ROUTES.get(flag_list[0].flag_type.name, (None, None, None, None)) if flag_list else (None, None, None, None)
            entry["escalation_level"] = decision.escalation_level
            entry["domain"] = route[0] if route[0] else "governance"
            entry["subdomain"] = route[1]
            if decision.forensic_event is not None:
                entry["forensic_event"] = decision.forensic_event
                if pef_snapshot is not None:
                    entry["pef_snapshot"] = pef_snapshot
            entry["requires_role"] = route[2]
            entry["action_taken"] = action_taken
            entry["trigger_spans"] = trigger_spans
            if decision.rule_id is not None:
                entry["policy_rule"] = decision.rule_id
            entry["rationale"] = decision.rationale
            entry["final_response"] = final_response or decision.corrected_response
            if decision.governance_note:
                entry["governance_note"] = decision.governance_note
            entry["flags"] = [
                {
                    "type": f.flag_type.name,
                    "severity": f.severity,
                    "claim": f.claim,
                    **({"extraction_diagnostic": f.extraction_diagnostic} if f.extraction_diagnostic else {}),
                }
                for f in decision.flags
            ]
            # Surface canonical pathway metadata in audit entries when present.
            if decision.pathway_id is not None:
                entry["pathway_id"] = decision.pathway_id
                entry["output_mode"] = decision.output_mode
                entry["commitment_closed"] = decision.commitment_closed
                entry["interaction_open"] = decision.interaction_open
                entry["forensic_obligations"] = decision.forensic_obligations
                entry["resolution_mode"] = decision.resolution_mode
                entry["rendered_from_policy"] = True

            # Error-containment audit marking.
            # unexpected_unclassified_termination = True means a Tier 3/4 fallback
            # fired — either an unmapped hostile flag type or a malformed decision.
            # This is not normal operation; it requires operator review.
            if decision.unexpected_unclassified_termination:
                entry["unexpected_unclassified_termination"] = True
                entry["fallback_reason"] = decision.fallback_reason

        lock_meta = get_lock_metadata()
        if lock_meta is not None:
            entry["lock_wait_ms"] = lock_meta.get("lock_wait_ms")
            entry["lock_acquired"] = lock_meta.get("lock_acquired")
            if not lock_meta.get("lock_acquired"):
                entry["lock_timeout_ms"] = lock_meta.get("lock_timeout_ms")

        if stream:
            entry["stream"] = True
            entry["stream_completed"] = stream_completed
            if not stream_completed and stream_abort_reason:
                entry["stream_abort_reason"] = stream_abort_reason
            if stream_completed and stream_truncated:
                entry["stream_truncated"] = True
                entry["stream_dropped_chars"] = stream_dropped_chars

        with self._chain_lock:
            new_cid = append_audit_entry(
                self._audit_path,
                entry,
                signing_key=self._audit_signing_key,
                max_mb=self._audit_log_max_mb,
                prev_cid=self._prev_cid,
            )
            if new_cid is not None:
                self._prev_cid = new_cid
                decision.cid = new_cid
            self._entry_count += 1
            self._maybe_checkpoint()

    def log_decision(
        self,
        decision: GovernanceDecision,
        turn: int = 0,
        *,
        stream: bool = False,
        stream_completed: bool = True,
        stream_abort_reason: str | None = None,
        stream_truncated: bool = False,
        stream_dropped_chars: int = 0,
        pef_context: str | None = None,
        pre_llm: bool = False,
        pef_snapshot: dict | None = None,
    ) -> None:
        final = decision.corrected_response or decision.original_response
        self._log_decision(
            decision,
            final_response=final,
            turn=turn,
            stream=stream,
            stream_completed=stream_completed,
            stream_abort_reason=stream_abort_reason,
            stream_truncated=stream_truncated,
            stream_dropped_chars=stream_dropped_chars,
            pef_context=pef_context,
            pre_llm=pre_llm,
            pef_snapshot=pef_snapshot,
        )


class PolicySelectingBridge(GovernanceBridge):
    """Phase B: Wraps strict and moderate bridges; delegates based on per-key policy override."""

    def __init__(
        self,
        strict_bridge: BuiltinBridge,
        moderate_bridge: BuiltinBridge,
        default_policy: str,
    ):
        self._strict = strict_bridge
        self._moderate = moderate_bridge
        self._default = default_policy if default_policy in ("strict", "moderate") else "strict"

    def _delegate(self) -> BuiltinBridge:
        override = auth_policy_var.get(None)
        if override == "moderate":
            return self._moderate
        if override == "strict":
            return self._strict
        return self._strict if self._default == "strict" else self._moderate

    async def decide(
        self,
        flags: list[Flag],
        response_text: str,
        pef: PEFState,
    ) -> GovernanceDecision:
        return await self._delegate().decide(flags, response_text, pef)

    async def intervene(
        self,
        decision: GovernanceDecision,
        adapter: LLMAdapter,
        user_input: str,
        pef_context: str,
        history: list[dict[str, str]] | None = None,
    ) -> str:
        return await self._delegate().intervene(
            decision, adapter, user_input, pef_context, history
        )

    def log_decision(
        self,
        decision: GovernanceDecision,
        turn: int = 0,
        *,
        stream: bool = False,
        stream_completed: bool = True,
        stream_abort_reason: str | None = None,
        stream_truncated: bool = False,
        stream_dropped_chars: int = 0,
        pef_context: str | None = None,
        pre_llm: bool = False,
        pef_snapshot: dict | None = None,
    ) -> None:
        self._delegate().log_decision(
            decision,
            turn=turn,
            stream=stream,
            stream_completed=stream_completed,
            stream_abort_reason=stream_abort_reason,
            stream_truncated=stream_truncated,
            stream_dropped_chars=stream_dropped_chars,
            pef_context=pef_context,
            pre_llm=pre_llm,
            pef_snapshot=pef_snapshot,
        )
