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.
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_disclosurefor a medical pack, etc.). WithoutEthicsCheck, 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 byno_hot_path_repairin SafetyCheck — structural commitments reportruntime_checkable=Falserather 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.mdcovers 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, andChatRuntimeintegration.- 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.