Two sibling escalation tiers above the audit-only ethics baseline, both opt-in per commitment via the ethics pack JSON. ADR-0037 — refusal_commitments - EthicsPack.refusal_commitments (frozenset[str]; subset of commitment_ids; validated at load time, unknown id rejected) - Generic refusal prefix: "I cannot proceed — boundary violated: " - Source-tagged refusal ids: "safety:<id>" / "ethics:<id>" - build_refusal_surface now takes (safety_verdict, ethics_verdict, ethics_pack); ADR-0036 single-arg call remains valid back-compat - Default pack ships refusal_commitments: [] — audit-only floor preserved - Re-ratified default pack (mastery sha changes with schema field) ADR-0038 — hedge_commitments - EthicsPack.hedge_commitments (sibling field; same validator) - Mutually exclusive with refusal_commitments at load time - Runtime prepends manifold's preferred_hedge_soft (fallback preferred_hedge_strong) when an opted-in commitment fires runtime-checkable - Refusal supersedes hedge globally; stub path skips hedge (already a disclosure surface); main path only - Idempotent on prefix (case-insensitive) — defends against ADR-0028 assembler hedges - Does NOT flip _last_refusal_was_typed — hedge is not refusal Surface contract: - ChatResponse.walk_surface + articulation_surface preserved unchanged on both refusal and hedge paths (same audit discipline as ADR-0036) - Only user-facing ChatResponse.surface (and TurnEvent.surface on main path) is mutated Files: - packs/ethics/loader.py — refusal_commitments + hedge_commitments fields; _validate_opt_in_subset; mutual-exclusion check - packs/ethics/default_general_ethics_v1.json — both opt-in lists empty; re-ratified - chat/refusal.py — generic prefix, source-tagged ids, violated_runtime_checkable_ethics, should_inject_hedge, build_hedge_prefix, inject_hedge - chat/runtime.py — passes ethics_verdict + ethics_pack to refusal builder; hedge injection branch after refusal check - tests/test_ethics_refusal_opt_in.py (new) — 16 tests - tests/test_hedge_injection.py (new) — 22 tests - docs/decisions/ADR-0037-per-predicate-ethics-refusal.md (new) - docs/decisions/ADR-0038-hedge-injection.md (new) Verification: - Combined pack-layer suite: 154 green (was 116 after ADR-0036) - CLI suites unchanged: smoke 67, runtime 19, cognition 121 - core eval cognition: intent 100%, versor_closure 100% (baseline)
5.4 KiB
ADR-0037: Per-Predicate Ethics Refusal Opt-In
Status: Accepted (2026-05-17)
Author: Joshua Shay + planner pass
Companion docs: ADR-0033-ethics-packs.md, ADR-0034-ethics-check-surface.md, ADR-0036-safety-refusal-policy.md
Context
ADR-0036 wired typed refusal for safety violations only. Ethics violations were left audit-only because:
- The pack layer's swappability semantics meant a pack-author flag could silently change refusal behavior on every deployment.
- Empirical violation rates for individual ethics commitments did not yet exist.
ADR-0036's deferred follow-up was per-predicate ethics refusal: a mechanism by which a pack author can opt specific commitments into refusal one at a time, without flipping a global ethics-refuses switch. That coupling is what this ADR introduces.
Decision
Add an optional refusal_commitments field to the ethics pack JSON
schema. Each entry must already appear in commitment_ids. At
runtime, an ethics commitment contributes to typed refusal only when
both:
- Its predicate fired
runtime_checkable=True, upheld=False. - Its id appears in
EthicsPack.refusal_commitments.
The default pack ships with an empty refusal_commitments.
Audit-only is the floor; opt-in is the ceiling.
Surface format change
The refusal prefix is generalised from
"I cannot proceed — safety boundary violated: " to
"I cannot proceed — boundary violated: ", and contributing ids are
source-tagged:
safety:<boundary_id>ethics:<commitment_id>
Source tags disambiguate sibling namespaces and avoid name collisions between the two pack types. Lex order is preserved across the merged list.
Pack-loader bounds
packs/ethics/loader.py now validates refusal_commitments:
- Optional; defaults to empty.
- Must be a list of strings if present.
- Every entry must be a declared
commitment_id(typo → load-time error; silent typos would be catastrophic given the behavioral consequences). - No duplicates.
The validator is shared with ADR-0038 (_validate_opt_in_subset) and
will be reused for hedge_commitments.
Ratification
Adding a field to the pack invalidates its prior mastery-report seal.
The default pack was re-ratified through
scripts/ratify_ethics_pack.py (idempotent re-run); the new
mastery_report_sha256 reflects the schema addition.
Backward compatibility
build_refusal_surface(safety_verdict) (the ADR-0036 single-arg
form) still works — with no ethics pack supplied, ethics contributes
nothing. Existing safety-only tests pass unchanged because their
assertions are substring-based on the boundary id (now appearing as
safety:<id>).
Consequences
Positive
- Audit-only remains the default. An operator who clones the default pack and deploys gets ADR-0036 behavior unchanged.
- Per-commitment granularity. A medical-domain pack can opt
defer_high_stakes_to_human_reviewinto refusal without flipping the rest of the ethics surface. - Schema-enforced safety. Typos in
refusal_commitmentsfail at load time, not at the first matching violation. - Unified refusal surface. Auditors see one refusal text per turn covering both safety and ethics violations, with source tags.
Negative / risks
- Pack mutation invalidates ratification. A deployment that
edits
refusal_commitmentsmust re-ratify. This is the intended cost: opting commitments into refusal is a deployment-level decision that deserves the ratification round-trip. - The opt-in list is JSON, which means it sits inside the swappable layer. This is correct semantically (refusal policy is a deployment choice) but means an operator can flip refusal on/off by editing a file. Mitigated by: ratification round-trip, schema validation, and the load-time error for unknown ids.
- Surface prefix changed. Downstream consumers parsing the refusal text by exact prefix needed an update. We chose to update the constant in place rather than maintain a parallel API because no in-tree consumer existed.
Verification
tests/test_ethics_refusal_opt_in.py— 16 tests covering: loader bounds (empty default, unknown id rejected, duplicate rejected, non-list rejected); pure builder paths (no opt-in → no refusal, opt-in + violation → refusal, opt-in subset semantics, non-runtime-checkable ignored, combined safety+ethics, ADR-0036 back-compat); helperviolated_runtime_checkable_ethics; ChatRuntime integration (default pack does not refuse, mutated pack refuses, combined safety+ethics in runtime).- Combined pack-layer suite: 132 tests, all green.
- CLI suites: smoke 67, runtime 19, cognition 121 — unchanged.
Open questions deferred to a future ADR
- Pack-schema-driven hedge injection. Sibling field
hedge_commitmentsfollows in ADR-0038 — same opt-in pattern, different remediation. - Mutual exclusion between
refusal_commitmentsandhedge_commitments. Encoded at load time in ADR-0038. - Per-domain default policies. Should the medical-domain pack
ship with
defer_high_stakes_to_human_reviewopted into refusal by default? Deferred until a medical pack actually exists. - Telemetry split by source. A future telemetry sink may want to count safety refusals and ethics refusals separately.