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.
123 lines
8.3 KiB
Markdown
123 lines
8.3 KiB
Markdown
# ADR-0034: EthicsCheck — Structural Surface for Ethics-Pack Commitments
|
|
|
|
**Status:** Accepted (2026-05-17)
|
|
**Author:** Joshua Shay + planner pass
|
|
**Companion docs:** [`../ethics_packs.md`](../ethics_packs.md), [`ADR-0032-safety-check-surface.md`](ADR-0032-safety-check-surface.md), [`ADR-0033-ethics-packs.md`](ADR-0033-ethics-packs.md)
|
|
|
|
## Context
|
|
|
|
[ADR-0033](ADR-0033-ethics-packs.md) 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
|
|
|
|
```python
|
|
@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.
|