core/docs/decisions/ADR-0034-ethics-check-surface.md
Shay db5bc028f9 feat(adr-0034): EthicsCheck — structural surface parallel to SafetyCheck
Completes the predicate-surface layer for ethics packs, sibling to
ADR-0032's SafetyCheck.  Same registry-of-predicates shape; same
observational discipline; same honest reporting of runtime-checkable=False
for structural commitments that cannot be evaluated from per-turn evidence.

Five default predicates for the v1 commitments:

  acknowledge_uncertainty           — alignment < threshold ⇒ requires hedge
  defer_high_stakes_to_human_review — high_stakes ⇒ requires recommend_review
  disclose_limitations              — ungrounded ⇒ requires disclosure marker
  no_manipulation                   — structural; runtime_checkable=False
  respect_user_autonomy             — prescriptive ⇒ requires ≥2 options surfaced

`no_manipulation` is the ethics-side analogue of `no_hot_path_repair`
in SafetyCheck — an aggregate property enforced by realizer design and
review, not a per-turn metric.  Honest reporting rather than a silent
upheld pass.

ChatRuntime exposes `runtime.ethics_check`; turn loop does not
auto-invoke.  Refusal / re-articulation wiring is a future ADR.

Test coverage: 27 new tests; combined pack-layer surface suite
(identity + safety + ethics, loaders + checks) is now 108 tests, all
green.  Cognition (121), teaching (17), runtime (19), smoke (67)
unaffected.
2026-05-17 20:46:34 -07:00

8.3 KiB

ADR-0034: EthicsCheck — Structural Surface for Ethics-Pack Commitments

Status: Accepted (2026-05-17) Author: Joshua Shay + planner pass Companion docs: ../ethics_packs.md, ADR-0032-safety-check-surface.md, ADR-0033-ethics-packs.md

Context

ADR-0033 introduced ethics packs as the third pack-layer sibling to identity and safety. The pack contributes commitment_ids to the runtime manifold's boundary_ids. What ADR-0033 did not establish was a structural surface for evaluating those commitments per turn — the parallel to SafetyCheck (ADR-0032) for the ethics layer.

The argument for adding the surface now (rather than deferring) is the same as it was for safety:

  • Commitments without an observation surface decay into labels. The runtime declares it commits to acknowledge_uncertainty, but nothing produces a per-turn verdict on whether the commitment held.
  • Downstream domain deployments need a registration point to add deployment-specific predicates (informed_consent_required_before_disclosure for a medical pack, etc.). Without EthicsCheck, the registration point doesn't exist.
  • The shape of the surface is already known and tested (SafetyCheck is the precedent). Building the parallel keeps the architecture coherent.

Decision

EthicsCheck is a registry of named predicates, one per commitment id, with defaults for the five v1 commitments. Observational at v1: it produces an EthicsVerdict; it does not refuse and does not auto-invoke in the turn loop. Wiring verdicts into refusal / re-articulation paths is a future ADR (parallel scope to the future safety-auto-invocation ADR).

Why a parallel surface rather than a shared one

The temptation to fold ethics into SafetyCheck is real — same shape, same registry pattern, same fallback semantics. We resist it for the same reason ethics is a separate pack layer:

  • Safety verdicts are floor violations. A safety violation is a system fault.
  • Ethics verdicts are pledge failures. An ethics violation is a deployment-commitment failure, not a fault of the floor.

Conflating them in a single surface would obscure the structural difference. An auditor reviewing a turn benefits from reading two distinct verdicts: "did the floor hold?" and "did the deployment honor its pledges?" One verdict object mixing both flattens that distinction.

Default predicates per v1 commitment

Commitment Runtime-checkable? What it checks
acknowledge_uncertainty Yes (when alignment_score + hedge_emitted supplied) alignment < hedge_threshold_soft requires hedge_emitted=True
defer_high_stakes_to_human_review Yes (when flags supplied) high_stakes_topic=True requires recommended_human_review=True
disclose_limitations Yes (when flags supplied) grounded_in_evidence=False requires disclosure_emitted=True
no_manipulation No aggregate property; enforced by realizer design + review
respect_user_autonomy Yes (when flags supplied) prescribed_single_answer=True requires presented_options_count >= 2

no_manipulation is the structural analogue of no_hot_path_repair in SafetyCheck: an aggregate property that cannot be evaluated from per-turn evidence. A predicate that silently reported upheld=True would be the kind of small lie CLAUDE.md forbids. The honest answer is runtime_checkable=False, upheld=True with a reason that names where enforcement actually lives.

API shape

@dataclass(frozen=True, slots=True)
class EthicsContext:
    # acknowledge_uncertainty
    alignment_score: float | None = None
    hedge_threshold_soft: float = 0.65
    hedge_emitted: bool | None = None
    # defer_high_stakes_to_human_review
    high_stakes_topic: bool | None = None
    recommended_human_review: bool | None = None
    # disclose_limitations
    grounded_in_evidence: bool | None = None
    disclosure_emitted: bool | None = None
    # respect_user_autonomy
    prescribed_single_answer: bool | None = None
    presented_options_count: int | None = None

@dataclass(frozen=True, slots=True)
class EthicsCheckResult:
    commitment_id: str
    upheld: bool
    reason: str
    runtime_checkable: bool
    evidence: tuple[tuple[str, str], ...] = ()

@dataclass(frozen=True, slots=True)
class EthicsVerdict:
    pack_id: str
    results: tuple[EthicsCheckResult, ...]   # lex order on commitment_id
    upheld: bool
    violated_commitments: frozenset[str]
    runtime_checkable_count: int

class EthicsCheck:
    def __init__(self, predicates: Mapping[str, EthicsPredicate] | None = None) -> None: ...
    def register(self, commitment_id: str, predicate: EthicsPredicate) -> None: ...
    def check(self, ctx: EthicsContext, ethics_pack: EthicsPack) -> EthicsVerdict: ...

Every field on EthicsContext is optional; None defaults express "caller did not supply this evidence." Predicates over absent evidence return upheld=True, runtime_checkable=False — absence of evidence is not evidence of commitment violation. This is the same composability discipline as SafetyCheck.

Unknown-commitment behavior

When a pack declares a commitment for which no predicate is registered, the verdict records upheld=True, runtime_checkable=False, reason="no predicate registered for commitment". Downstream domain deployments can author packs with novel commitments; the runtime doesn't crash, the audit surfaces the gap.

Defensive: predicate-result rebinding

Identical to SafetyCheck: if a registered predicate returns a EthicsCheckResult whose commitment_id doesn't match the slot it was registered under, EthicsCheck.check rebinds the id. A buggy predicate should not silently misroute its verdict in audit.

ChatRuntime integration

ChatRuntime instantiates self.ethics_check = EthicsCheck() alongside self.safety_check. The turn loop does not auto-invoke either surface at v1. Callers (audit / logging / future enforcement) call runtime.ethics_check.check(ctx, runtime.ethics_pack) whenever they want a verdict.

Consequences

Positive

  • Three observation surfaces, three orthogonal verdicts. Identity (manifold score), safety (boundary verdict), ethics (commitment verdict). An auditor reviewing a turn can answer three distinct questions independently.
  • Honest reporting on no_manipulation. Following the precedent set by no_hot_path_repair in SafetyCheck — structural commitments report runtime_checkable=False rather than passing silently.
  • Extensible. Domain packs ship custom predicates that register without touching CORE code.
  • Forward-compatible with auto-invocation. When the future ADR wires ethics evaluation into the turn loop, the surface won't need to change.

Negative / risks

  • Observation isn't enforcement. A violation reported by EthicsCheck at v1 has no automatic consequence. Deliberate (same scope discipline as ADR-0032).
  • Predicate authoring is per-deployment work for any commitment beyond the five v1 defaults. Domain packs will need their own predicates — documentation in docs/ethics_packs.md covers the authoring pattern.
  • Two parallel surfaces (Safety + Ethics) is more API. Mitigated by the fact that they share exactly the same shape; a caller who understands one understands the other. A future "unified verdict bundle" type could group both verdicts for callers that want a single pass.

Scope limits (explicit non-goals)

  • No auto-invocation in the turn loop.
  • No refusal / re-articulation wiring.
  • No cross-surface aggregation (one unified verdict object combining safety + ethics + identity).
  • No structural difference between "violated" and "would-have-been-violated-if-checkable" within the verdict — same as ADR-0032.

Verification

  • tests/test_ethics_check.py — 27 tests covering each default predicate (positive / negative / not-supplied paths), the unknown-commitment fallback, custom predicate registration, defensive rebinding, verdict aggregation, and ChatRuntime integration.
  • Existing pack-layer suites unaffected; combined identity/safety/ethics surface suite is now 108 tests across loader + check surfaces, all green at this revision.
  • Cognition (121), teaching (17), runtime (19), smoke (67), formation suites continue green.