# Truveil Python SDK v0.2.0 -- served copy.
#
# Source of truth: sdk/truveil.py in the Truveil repository. This file is a
# copy published at https://www.truveil.app/sdk/truveil_sdk.py so it can be
# downloaded and vendored directly; it is not built or generated. Fix bugs in
# the source file and re-copy. The only intentional differences from the
# source are this header, the TruveilSDK alias at the end of the file, and
# docstring wording for public readers.
"""
Truveil Python SDK — behavioral port of the Node.js v3 contract.

truveil.js (the JavaScript SDK) is the authoritative contract; this module
matches it behavior-for-behavior. Parity points:

  - Fail-soft default dispatch: log() NEVER raises into caller code,
    regardless of input. Validation failures AND send failures are written
    to stderr with a "[truveil] " prefix and the dispatch resolves to None.
  - Strict mode (wait=True): log() raises on BOTH validation and
    HTTP/transport failures — a single failure channel for the caller
    (the Python equivalent of awaiting the rejected promise).
  - run_id passthrough: optional; must be a non-empty string when provided
    (sent trimmed); omitted when absent so the server stores run_id = null.
  - Request timeout with abort: each send attempt is aborted after
    REQUEST_TIMEOUT_MS (10s), like the Node AbortController.
  - Delayed retry: one retry after RETRY_DELAY_MS (1s), and only for
    transport/timeout failures. HTTP non-2xx responses are never retried
    (the server responded; a 429 carries its own Retry-After).
  - register_agent is separate: it always raises on validation and on HTTP
    error, because registration is a setup step where silent failure is wrong.
  - risk is required on log entries — no Python-side auto-classification,
    matching the Node SDK.

The v-next capture contract (Truveil.run() -> TruveilRun) is included with
the same method-to-field mapping as truveil.js, snake_cased per Python
convention.

No dependencies beyond the Python standard library.
"""

import json
import socket
import sys
import time
import datetime
import random
import urllib.request
import urllib.error
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, List, Optional

__all__ = [
    'Truveil', 'TruveilSDK', 'TruveilRun',
    'OVERSIGHT_MODE_VALUES', 'AGENT_JURISDICTION_VALUES',
    'REQUEST_TIMEOUT_MS', 'RETRY_DELAY_MS',
]

OVERSIGHT_MODE_VALUES = ('human_in_loop', 'human_on_loop', 'human_out_of_loop')

AGENT_JURISDICTION_VALUES = (
    'EU', 'DIFC', 'ADGM', 'UAE-Federal', 'US',
    'India', 'UK', 'Singapore', 'Other',
)

V2_FIELDS = (
    # Transparency
    'ai_disclosure_text', 'explanation_provided', 'sources', 'confidence', 'reasoning_summary',
    # Accountability
    'risk_assessment_ref', 'oversight_mode', 'override_event',
    # Data Trust
    'data_provenance', 'freshness_check', 'validation_passed', 'bias_check_performed',
    'bias_check_applicable', 'bias_check_not_applicable_reason',
    'groundedness_score',
    # Reversibility
    'kill_switch_available', 'override_control', 'version_id', 'appeal_channel',
    'correction_workflow',
    # Chain v2, P1 (S5-IB-10). The deployer's claim about who decided, carried
    # structurally alongside the approver text the helpers still write into
    # detail (R-d: detail is unchanged by this). Recorded verbatim; Truveil
    # does not resolve or verify it, and it drives no engine signal.
    # Its companions approver_recorded_by and corpus_version_at_capture are
    # Truveil-computed server-side and are deliberately NOT sendable from here.
    'approver_ref',
    # v3 capture fields (S6-IB-24 B0), validated server-side.
    # checkpoint_ref: Reference to a preceding checkpoint: the approval_id returned by a gate_confirmed response. Recorded verbatim; string shape only at the write boundary, resolved against issued gate ids at report time.
    # event_type: What kind of event this row records, from the closed set output|synthesis|ingestion|decision|disclosure|correction. Optional; anything outside the set is rejected by name; never defaulted.
    'checkpoint_ref', 'event_type',
)

REQUEST_TIMEOUT_MS = 10000  # per-attempt send timeout (abort), matches Node
RETRY_DELAY_MS     = 1000   # delay before the single retry on a failed send

# Shared executor for fire-and-forget dispatch. Bounded so a misbehaving
# caller can't spawn unbounded threads.
_ASYNC_EXECUTOR = ThreadPoolExecutor(max_workers=4, thread_name_prefix='truveil-log')

# One-time registration nudge (mirrors the Node module-level flag).
_nudged_once = False


def _validate_unit_interval(name: str, value: Any) -> None:
    if not isinstance(value, (int, float)) or isinstance(value, bool):
        raise ValueError(f'{name} must be a number between 0.0 and 1.0')
    if value != value or value in (float('inf'), float('-inf')):
        raise ValueError(f'{name} must be a number between 0.0 and 1.0')
    if value < 0.0 or value > 1.0:
        raise ValueError(f'{name} must be between 0.0 and 1.0 (got {value})')


def _is_timeout(e: BaseException) -> bool:
    if isinstance(e, (TimeoutError, socket.timeout)):
        return True
    reason = getattr(e, 'reason', None)
    return isinstance(reason, (TimeoutError, socket.timeout))


def _mint_run_id() -> str:
    """Readable, session-unique run id: run_<YYYYMMDD>_<6 hex> (UTC)."""
    d = datetime.datetime.now(datetime.timezone.utc)
    return f"run_{d.strftime('%Y%m%d')}_{random.getrandbits(24):06x}"


class Truveil:
    """
    Lightweight agent audit logging.

    Contract parity: truveil.js v3. Same endpoints (POST /log,
    POST /register_agent), same x-client-secret auth header, same defaults,
    same failure semantics.
    """

    def __init__(self, endpoint: str, client_secret: str, project: Optional[str] = None):
        """
        Args:
            endpoint:      Truveil API base URL, e.g. https://api.truveil.app
            client_secret: Per-user Truveil API key from the dashboard.
            project:       Optional default project tag.
        """
        # Strip exactly one trailing slash, matching the Node SDK.
        self.endpoint = endpoint[:-1] if endpoint.endswith('/') else endpoint
        self.client_secret = client_secret
        self.project = project or None

    # ── Registration ─────────────────────────────────────────────────────────

    def register_agent(
        self,
        agent_category: Optional[int] = None,
        jurisdiction: Optional[str] = None,
        intended_purpose: Optional[str] = None,
        owner_email: Optional[str] = None,
        project_name: Optional[str] = None,
        display_name: Optional[str] = None,
        risk_assessment_ref: Optional[str] = None,
        risk_assessment_date: Optional[str] = None,
        retention_policy_ref: Optional[str] = None,
    ) -> dict:
        """
        Register the agent with Truveil. Idempotent: same (user, project_name)
        updates the existing registration rather than duplicating it.

        NOT fire-and-forget: registration is a setup step where silent failure
        is wrong, so this always raises on validation and on HTTP error
        (contract parity with the Node SDK's registerAgent). Like the Node
        SDK's plain fetch, there is no timeout or retry on this call.

        Args:
            agent_category:      Optional declared category 1-4.
            jurisdiction:        Optional. One of EU, DIFC, ADGM, UAE-Federal,
                                 US, India, UK, Singapore, Other.
            intended_purpose:    Optional one-sentence description.
            owner_email:         Optional accountable owner email.
            project_name:        Project name. Falls back to self.project.
            display_name:        Optional human-readable agent name for reports.
            risk_assessment_ref: Optional DPIA / risk-assessment reference
                                 declared at registration.

        Returns:
            Parsed JSON response: {agent_id, project_name, agent_category,
            jurisdiction, registered, note}.

        Raises:
            ValueError:   project_name resolution or validation failure.
            RuntimeError: the API returned a non-2xx response.
        """
        # Cache nudge hints before validation, mirroring the Node wrapper.
        self._reg_hints = {
            'has_purpose': intended_purpose is not None and str(intended_purpose).strip() != '',
            'has_owner':   owner_email is not None and str(owner_email).strip() != '',
        }

        resolved_project = project_name or self.project
        if not resolved_project:
            raise ValueError(
                'project_name is required; pass it explicitly or set '
                'Truveil(..., project) at construction time'
            )
        if agent_category is not None:
            if not isinstance(agent_category, int) or isinstance(agent_category, bool) \
                    or agent_category < 1 or agent_category > 4:
                raise ValueError(f'agent_category must be an integer 1-4 (got {agent_category})')
        if jurisdiction is not None and jurisdiction not in AGENT_JURISDICTION_VALUES:
            raise ValueError(
                f"jurisdiction must be one of {'|'.join(AGENT_JURISDICTION_VALUES)} (got '{jurisdiction}')"
            )
        if display_name is not None and not isinstance(display_name, str):
            raise ValueError(f'display_name must be a string (got {type(display_name).__name__})')
        if risk_assessment_ref is not None and not isinstance(risk_assessment_ref, str):
            raise ValueError(f'risk_assessment_ref must be a string (got {type(risk_assessment_ref).__name__})')

        body: Dict[str, Any] = {'project_name': resolved_project}
        if agent_category      is not None: body['agent_category']      = agent_category
        if jurisdiction        is not None: body['jurisdiction']        = jurisdiction
        if intended_purpose    is not None: body['intended_purpose']    = intended_purpose
        if owner_email         is not None: body['owner_email']         = owner_email
        if display_name        is not None: body['display_name']        = display_name
        if risk_assessment_ref is not None: body['risk_assessment_ref'] = risk_assessment_ref
        # S6-IB-24 B0: governance fields, recorded verbatim; the server
        # stores only well-formed values (risk_assessment_date YYYY-MM-DD).
        if risk_assessment_date is not None: body['risk_assessment_date'] = risk_assessment_date
        if retention_policy_ref is not None: body['retention_policy_ref'] = retention_policy_ref

        req = urllib.request.Request(
            url=f'{self.endpoint}/register_agent',
            data=json.dumps(body).encode('utf-8'),
            headers={
                'Content-Type': 'application/json',
                'x-client-secret': self.client_secret,
            },
            method='POST',
        )
        try:
            with urllib.request.urlopen(req) as res:
                return json.loads(res.read().decode('utf-8'))
        except urllib.error.HTTPError as e:
            try:
                text = e.read().decode('utf-8')
            except Exception:
                text = ''
            raise RuntimeError(f'Truveil register_agent failed ({e.code}): {text}') from e

    # ── Logging ──────────────────────────────────────────────────────────────

    def log(self, entry: Optional[Dict[str, Any]] = None, **fields: Any):
        """
        Log an agent action. Contract parity: truveil.js log().

        Accepts a single entry dict (the Node contract shape) and/or keyword
        fields; keywords are merged over the dict.

        v1 fields: agent, action, detail (required), risk (required:
        'HIGH'|'MEDIUM'|'LOW' — no auto-classification), and optional step,
        decision_type, human_checkpoint, reversible, code_reference, project,
        run_id. run_id groups logs into one session on the dashboard: it must
        be a non-empty string when provided (sent trimmed) and is omitted from
        the body when absent (server stores run_id = null).

        Schema-v2 fields (all optional): ai_disclosure_text,
        explanation_provided, sources, confidence (0.0-1.0),
        reasoning_summary, risk_assessment_ref, oversight_mode,
        override_event, data_provenance, freshness_check, validation_passed,
        bias_check_performed, bias_check_applicable,
        bias_check_not_applicable_reason, groundedness_score (0.0-1.0),
        kill_switch_available, override_control, version_id, appeal_channel,
        correction_workflow.

        Dispatch:
          wait absent/False (default, fail-soft): fire-and-forget on a
            background thread. NEVER raises into caller code, regardless of
            input — validation failures and send failures are written to
            stderr with a "[truveil] " prefix. Returns a Future whose
            .result() is the parsed response dict on success or None on a
            swallowed failure (the Future itself never raises).
          wait=True (strict): blocks and returns the parsed response dict.
            Raises ValueError on validation failure and RuntimeError on
            HTTP/transport failure — one failure channel, nothing swallowed.

        Send behavior (both modes): each attempt is aborted after
        REQUEST_TIMEOUT_MS; a transport/timeout failure gets one retry after
        RETRY_DELAY_MS; an HTTP non-2xx response is never retried.
        """
        if entry is None:
            entry = fields
        elif isinstance(entry, dict) and fields:
            entry = {**entry, **fields}

        # Determine dispatch mode without raising on malformed input.
        wait = isinstance(entry, dict) and entry.get('wait') is True

        if wait:
            # Strict mode: the caller handles BOTH validation and send
            # failures via a single channel. Nothing is swallowed.
            return self._build_and_send(entry)

        # Default fail-soft mode: never raise. A validation or send failure is
        # logged with a [truveil] prefix and the dispatch resolves to None.
        def _dispatch():
            try:
                return self._build_and_send(entry)
            except Exception as err:
                print(f'[truveil] {err}', file=sys.stderr)
                return None
        return _ASYNC_EXECUTOR.submit(_dispatch)

    def _build_and_send(self, entry: Any) -> dict:
        # Validate the entry and dispatch it. Raises on validation failure.
        # Never called directly — always through log().
        if not isinstance(entry, dict):
            raise ValueError('log() requires an entry object')

        agent  = entry.get('agent')
        action = entry.get('action')
        detail = entry.get('detail')
        risk   = entry.get('risk')
        if not agent:  raise ValueError('agent is required')
        if not action: raise ValueError('action is required')
        if not detail: raise ValueError('detail is required')
        if not risk:   raise ValueError('risk is required')
        if risk not in ('HIGH', 'MEDIUM', 'LOW'):
            raise ValueError('risk must be HIGH, MEDIUM, or LOW')

        # run_id is optional. When present it must be a non-empty string; when
        # absent it is omitted from the body and the server stores run_id = null.
        run_id = entry.get('run_id')
        if run_id is not None and (not isinstance(run_id, str) or run_id.strip() == ''):
            raise ValueError('run_id must be a non-empty string when provided')

        # Schema-v2 validation
        if entry.get('confidence') is not None:
            _validate_unit_interval('confidence', entry['confidence'])
        if entry.get('groundedness_score') is not None:
            _validate_unit_interval('groundedness_score', entry['groundedness_score'])
        oversight_mode = entry.get('oversight_mode')
        if oversight_mode is not None and oversight_mode not in OVERSIGHT_MODE_VALUES:
            raise ValueError(
                f"oversight_mode must be one of {'|'.join(OVERSIGHT_MODE_VALUES)} (got '{oversight_mode}')"
            )

        body: Dict[str, Any] = {'agent': agent, 'action': action, 'detail': detail, 'risk': risk}
        # S6-IB-36: the caller's own event time. Neither SDK sent one, so every
        # stored row carried the moment Truveil received it rather than the
        # moment the agent acted. POST /log has always accepted and validated a
        # client value and falls back to its own clock, so this forwards a field
        # the boundary already understood. Absent when the caller supplies none.
        for k in ('step', 'decision_type', 'human_checkpoint', 'reversible',
                  'code_reference', 'timestamp'):
            if k in entry:
                body[k] = entry[k]
        # project: an explicit entry key wins (even None), else the
        # constructor default when set — mirrors the Node undefined check.
        if 'project' in entry:
            body['project'] = entry['project']
        elif self.project is not None:
            body['project'] = self.project
        if run_id is not None:
            body['run_id'] = run_id.strip()
        for k in V2_FIELDS:
            if entry.get(k) is not None:
                body[k] = entry[k]

        return self._post_log(body)

    def _send_once(self, body: Dict[str, Any]) -> dict:
        # One send attempt with an abort timeout. Raises RuntimeError tagged
        # .retryable=True on transport/timeout failure and .retryable=False on
        # HTTP non-2xx (the server responded, so a retry won't help and a 429
        # carries its own Retry-After).
        req = urllib.request.Request(
            url=f'{self.endpoint}/log',
            data=json.dumps(body).encode('utf-8'),
            headers={
                'Content-Type': 'application/json',
                'x-client-secret': self.client_secret,
            },
            method='POST',
        )
        try:
            res = urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_MS / 1000.0)
        except urllib.error.HTTPError as e:
            try:
                text = e.read().decode('utf-8')
            except Exception:
                text = ''
            err = RuntimeError(f'Truveil log failed ({e.code}): {text}')
            err.retryable = False
            raise err from e
        except Exception as e:
            reason = f'timed out after {REQUEST_TIMEOUT_MS}ms' if _is_timeout(e) else str(e)
            err = RuntimeError(f'Truveil log failed: {reason}')
            err.retryable = True  # transport/timeout — worth one retry
            raise err from e
        with res:
            return json.loads(res.read().decode('utf-8'))

    def _post_log(self, body: Dict[str, Any]) -> dict:
        # Send with one delayed retry on a failed send, then give up — the
        # final failure propagates to log(), which surfaces it to stderr in
        # default mode or raises in wait mode. Only transport/timeout failures
        # are retried; HTTP errors propagate as-is.
        try:
            return self._send_once(body)
        except Exception as first_err:
            if not getattr(first_err, 'retryable', False):
                raise
            time.sleep(RETRY_DELAY_MS / 1000.0)
            return self._send_once(body)  # second failure propagates

    # ── v-next CAPTURE CONTRACT (additive; log/register_agent unchanged) ─────

    def run(self, run_id: Optional[str] = None, agent: Optional[str] = None) -> 'TruveilRun':
        """
        Open a run-scoped capture handle. Every call on the handle inherits
        this run_id (so A6 log_completeness is automatic).

        Args:
            run_id: Reuse an existing run_id; auto-minted when omitted/blank.
            agent:  Default agent name for handle calls; falls back to the
                    constructor project, then 'workspace-agent'.
        """
        global _nudged_once
        rid = run_id.strip() if isinstance(run_id, str) and run_id.strip() else _mint_run_id()
        hints = getattr(self, '_reg_hints', None)
        if not _nudged_once and hints and (not hints['has_purpose'] or not hints['has_owner']):
            _nudged_once = True
            miss = []
            if not hints['has_purpose']: miss.append('intended_purpose (T1)')
            if not hints['has_owner']:   miss.append('owner_email (A1)')
            print(f"[truveil] registration is missing {' and '.join(miss)}; "
                  'pass these to register_agent so those signals are captured.',
                  file=sys.stderr)
        return TruveilRun(self, rid, agent)


class _Stage:
    """Stage lifecycle: a single row is emitted only on complete() or failed()."""

    def __init__(self, run: 'TruveilRun', name: str):
        self._run = run
        self._name = name

    def complete(self, outcome: Any = None, risk=None):
        detail = str(outcome) if outcome is not None else f'{self._name} complete'
        return self._run._emit(action=f'stage_complete: {self._name}', detail=detail, risk=risk)

    def failed(self, error: Any = None, risk=None):
        detail = str(error) if error is not None else f'{self._name} failed'
        return self._run._emit(action=f'stage_failed: {self._name}', detail=detail, risk=risk)


class TruveilRun:
    """
    Run-scoped capture handle. Contract parity: truveil.js TruveilRun —
    the same method-to-field mapping (snake_cased), so a run captured through
    either SDK produces the same engine-read fields. Each method is sugar over
    log(): the same fail-soft dispatch, the same timeout, the same single
    retry. No new send path, no invented field names.
    """

    def __init__(self, truveil: Truveil, run_id: str, agent: Optional[str] = None):
        self._t = truveil
        self._run_id = run_id
        self._agent = agent or truveil.project or 'workspace-agent'

    @property
    def run_id(self) -> str:
        return self._run_id

    def _emit(self, **partial: Any):
        # Every named method funnels through here: stamp agent + run_id, send
        # via the parent log() (fail-soft, one retry). Returns what log() returns.
        return self._t.log({'agent': self._agent, 'run_id': self._run_id, **partial})

    # ── The locked decision record (subject-first, four fields) ──────────────
    def decision(self, subject, factors=None, outcome=None, confidence=None,
                 risk=None, decision_type=None):
        parts = []
        if outcome is not None:
            parts.append(str(outcome))
        if factors is not None:
            parts.append(factors if isinstance(factors, str)
                         else json.dumps(factors, separators=(',', ':')))
        extra = {'confidence': confidence} if confidence is not None else {}
        return self._emit(
            action=f'decision: {subject}',
            detail=' | '.join(parts) or f'decision for {subject}',
            risk=risk,
            decision_type=decision_type or 'recommendation',
            **extra,
        )

    def decisions(self, items) -> list:
        # Batch: one record per item, fail-soft, no waiting between sends.
        # Server insert order is authoritative for ordering. Unknown item keys
        # are ignored (Node destructuring parity) so a bad item never raises.
        out = []
        for it in (items if isinstance(items, list) else []):
            it = it if isinstance(it, dict) else {}
            out.append(self.decision(
                it.get('subject'),
                factors=it.get('factors'), outcome=it.get('outcome'),
                confidence=it.get('confidence'), risk=it.get('risk'),
                decision_type=it.get('decision_type'),
            ))
        return out

    # ── Stage lifecycle (records outcomes, never intent) ─────────────────────
    def stage(self, name: str) -> _Stage:
        return _Stage(self, name)

    # ── Transparency ─────────────────────────────────────────────────────────
    def disclosure(self, text, risk=None):
        return self._emit(action='ai_disclosure', detail=str(text), risk=risk,
                          ai_disclosure_text=str(text))  # T2

    def explanation(self, text, risk=None):
        return self._emit(action='explanation', detail=str(text), risk=risk,
                          explanation_provided=True, reasoning_summary=str(text))  # T3 + T6

    def sources(self, items, risk=None):
        arr = list(items) if isinstance(items, (list, tuple)) else []
        return self._emit(action='sources_cited', detail=f'{len(arr)} source(s)',
                          risk=risk, sources=arr)  # T4

    def confidence(self, x, risk=None):
        return self._emit(action='confidence', detail=f'confidence {x}', risk=risk,
                          confidence=x)  # T5

    # ── Accountability ───────────────────────────────────────────────────────
    def risk_assessment(self, ref, risk=None):
        return self._emit(action='risk_assessment', detail=str(ref), risk=risk,
                          risk_assessment_ref=str(ref))  # A2

    def oversight_mode(self, mode, risk=None):
        return self._emit(action='oversight_mode', detail=str(mode), risk=risk,
                          oversight_mode=mode)  # A3

    # The action is the deployer's own words, never a token this SDK chose.
    # human_checkpoint carries its claim in the human_checkpoint column, which
    # is structured and is the deployer's to assert; `action` defaults to a
    # neutral descriptor and is overridable. approval carries no structured
    # field at all, so its claim now travels entirely in the action the
    # deployer supplies: an SDK that emitted the literal string the engine
    # greps for was manufacturing the signal on their behalf (S4-IB-5 ruling 1).
        # S5-IB-10: approver_ref is DUAL-WRITTEN. detail keeps the exact
        # string it has always carried (R-d) and the approver now also
        # travels in its own column. Removing it from detail is a WS-B
        # decision, not this one.
    def human_checkpoint(self, approver, outcome=None, action=None, risk=None):
        return self._emit(action=str(action) if action is not None else 'checkpoint_recorded',
                          detail=f"{approver}: {outcome if outcome is not None else 'reviewed'}",
                          risk=risk, human_checkpoint=True,
                          approver_ref=str(approver) if approver is not None else None)

    def approval(self, context, approver=None, risk=None):
        detail = f'Approver: {approver}' if approver is not None else 'sign-off recorded'
        return self._emit(action=str(context), detail=detail, risk=risk,
                          approver_ref=str(approver) if approver is not None else None)

    # ── Override: capability (R3) versus exercised (A7 + R3) ─────────────────
    def override_available(self, mechanism, risk=None):
        return self._emit(action='control_surface_declared', detail=str(mechanism),
                          risk=risk, override_control=str(mechanism))  # R3 only

    def override(self, original_decision, by, risk=None):
        return self._emit(action='override',
                          detail=f'override of {original_decision} by {by}', risk=risk,
                          override_event={'override_by': by,
                                          'original_decision': original_decision})  # A7 + R3

    # ── Data Trust ───────────────────────────────────────────────────────────
    def provenance(self, entries, risk=None):
        arr = list(entries) if isinstance(entries, (list, tuple)) else []
        return self._emit(action='source_fetch', detail=f'{len(arr)} provenance entr(ies)',
                          risk=risk, data_provenance=arr)  # D1

    def freshness(self, ok, risk=None):
        return self._emit(action='freshness_check',
                          detail=f"freshness {'true' if ok else 'false'}", risk=risk,
                          freshness_check=bool(ok))  # D2

    def validation(self, source, ok, risk=None):
        return self._emit(action='validation', detail=f"{source}: {'ok' if ok else 'failed'}",
                          risk=risk, validation_passed=bool(ok))  # D3

    def bias_check(self, result, risk=None):
        detail = result if isinstance(result, str) else json.dumps(result, separators=(',', ':'))
        return self._emit(action='bias_check', detail=detail, risk=risk,
                          bias_check_performed=True)  # D4 (result -> detail, never a novel field)

    def bias_not_applicable(self, reason, risk=None):
        return self._emit(action='bias_check', detail=f'not applicable: {reason}', risk=risk,
                          bias_check_applicable=False,
                          bias_check_not_applicable_reason=str(reason))  # D4 not-applicable

    def groundedness(self, score, risk=None):
        return self._emit(action='groundedness', detail=f'groundedness {score}', risk=risk,
                          groundedness_score=score)  # D5

    # ── Reversibility ────────────────────────────────────────────────────────
    def kill_switch(self, available, risk=None):
        return self._emit(action='kill_switch',
                          detail=f"kill switch {'true' if available else 'false'}", risk=risk,
                          kill_switch_available=bool(available))  # R1

    def reversible(self, value, risk=None):
        return self._emit(action='reversibility_declared',
                          detail=f"reversible {'true' if value else 'false'}", risk=risk,
                          reversible=bool(value))  # R2 only

    def versioned(self, version_id, risk=None):
        return self._emit(action='versioned_output', detail=f'version {version_id}', risk=risk,
                          version_id=str(version_id))  # R4

    def appeal_channel(self, ref, risk=None):
        return self._emit(action='appeal_channel', detail=str(ref), risk=risk,
                          appeal_channel=str(ref))  # R5

    def correction_workflow(self, ref, risk=None):
        return self._emit(action='correction_workflow', detail=str(ref), risk=risk,
                          correction_workflow=str(ref))  # R6


# Alias so the published snippets, which read "from truveil_sdk import
# TruveilSDK", work against this file. Same class, two names.
TruveilSDK = Truveil
