core/docs/decisions/ADR-0038-hedge-injection.md
Shay ad8495d777 feat(adr-0037,adr-0038): per-predicate ethics refusal + hedge injection
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)
2026-05-17 21:23:28 -07:00

7.9 KiB

ADR-0038: Hedge Injection as a Runtime-Level Affordance

Status: Accepted (2026-05-17) Author: Joshua Shay + planner pass Companion docs: ADR-0028-surface-preferences.md, ADR-0036-safety-refusal-policy.md, ADR-0037-per-predicate-ethics-refusal.md

Context

ADR-0036 chose typed refusal over hedge injection for safety violations because conflating refusal with hedging would blur audit:

Hedge injection would blur the boundary between hedging (alignment-score driven) and refusing (predicate-driven). The same surface change could mean two different things. Audit becomes ambiguous.

That choice was correct for refusal. But it left an open question: once EthicsCheck predicates fire runtime-checkably for low alignment (acknowledge_uncertainty) or ungrounded scope (disclose_limitations), a deployment might want softer remediation than full refusal — some way to qualify the surface without replacing it.

ADR-0037 introduced refusal_commitments as the opt-in for per-predicate escalation to refusal. This ADR introduces its sibling hedge_commitments: opt-in for runtime-level hedge prepend.

Decision

Add an optional hedge_commitments field to the ethics pack JSON schema. Each entry must be a declared commitment_id. When any runtime-checkable violation of a commitment in hedge_commitments fires this turn, the runtime prepends the manifold's preferred hedge phrase (preferred_hedge_soft, falling back to preferred_hedge_strong) to ChatResponse.surface.

Mutual exclusion with refusal

A commitment cannot appear in both refusal_commitments and hedge_commitments. This is enforced at load time:

overlap = refusal & hedge
if overlap:
    raise EthicsPackError("commitments cannot appear in both ...")

The two remediations are escalation siblings, not stackable layers. Pack authors pick one per commitment: hedge (soft) or refuse (hard).

Refusal supersedes hedge in code path order

Even though pack schema forbids per-commitment overlap, the runtime still gives refusal priority globally: if any safety boundary or opted-in ethics commitment fires refusal, the surface is the typed refusal — hedge injection is skipped for the turn. This preserves the invariant "refusal is total."

Stub path does not hedge

The stub-path surface (_UNKNOWN_DOMAIN_SURFACE = "I don't know — insufficient grounding for that yet.") is already a disclosure surface. Prepending a hedge ("Perhaps I don't know — …") would read as a confused double-disclosure. Hedge injection runs only on the main articulation path. Stub-path refusal does still fire (per ADR-0036) because refusal is a hard stop, not a qualifier.

Evidence preservation

Same discipline as ADR-0036: hedge changes only the user-facing surface field. walk_surface (token-walk evidence) and articulation_surface (realizer output) are preserved unchanged. An auditor reading a hedged turn sees:

  • original surface (walk_surface / articulation_surface),
  • hedged user-facing surface (with prepended hedge),
  • ethics_verdict (with the violating commitment).

Idempotent on prefix

inject_hedge() is idempotent: if the surface already begins with the hedge phrase (case-insensitive match), no double-prepend occurs. This is a defensive property — the assembler's existing SurfaceContext-driven hedge logic (ADR-0028) may have already hedged the surface, and runtime injection should not duplicate.

No effect on refusal bookkeeping

Hedge injection does not set _last_refusal_was_typed. Hedging is not a refusal — the no_silent_correction safety predicate cares about typed refusals specifically, and a hedge should not be miscounted as one.

Consequences

Positive

  • Soft remediation channel. A medical-domain pack can opt acknowledge_uncertainty into hedging without committing to full refusal. Deployment authors get a middle tier between audit-only and refuse.
  • Schema-enforced mutual exclusion. Load-time error makes it impossible to ship a pack where the same commitment claims both remediations.
  • Runtime path stays minimal. Three helper functions (should_inject_hedge, build_hedge_prefix, inject_hedge), all pure. ChatRuntime adds a single conditional after the refusal branch.
  • Evidence preserved. Same audit discipline as refusal: original surfaces retained on the response and turn event.
  • Backward compatible. Default pack ships hedge_commitments: []; no behavior change for unmodified deployments.

Negative / risks

  • Hedge phrase source is the identity manifold, not the ethics pack. This means swapping ethics packs while keeping identity packs fixed produces the same hedge phrasing. Acceptable today: the manifold's surface_preferences is the canonical hedge home (ADR-0028). A future ADR could let ethics packs override phrasing per commitment.
  • Hedge runs only on main path. Stub-path hedge would be a double-disclosure. Tests gate runtime-end-to-end hedge assertions on rt.turn_log populated.
  • Idempotent-on-prefix means assembler hedges suppress runtime hedges. Correct (no double-hedge), but it means the signal of "did the runtime inject this hedge or did the assembler?" is lost from the surface alone. Audit consumers should rely on the ethics_verdict, not on the surface, to determine whether the injection path fired.
  • Ratification round-trip on schema change. Same cost as ADR-0037: adding hedge_commitments to the default pack required re-ratifying its mastery report.

Verification

  • tests/test_hedge_injection.py — 22 tests covering: loader bounds (empty default, unknown id rejected, mutual exclusion rejected, split-allocation OK); pure helpers (should_inject_hedge with pack/verdict/opt-in/evidence combinations; build_hedge_prefix with default manifold + None; inject_hedge happy path, empty prefix, empty surface, idempotent on prefix, case-insensitive idempotency); ChatRuntime integration (default pack does not inject; opt-in pack injects on violation; walk_surface preserved; refusal supersedes hedge; hedge does not flip _last_refusal_was_typed).
  • Combined pack-layer suite: 154 tests, all green (safety pack + safety check + ethics pack + ethics check + turn-loop verdicts + safety refusal + ethics refusal opt-in + hedge injection).
  • CLI suites unchanged: smoke 67, runtime 19, cognition 121.
  • core eval cognition: intent_accuracy 100%, versor_closure_rate 100% — baseline preserved.

Open questions deferred to a future ADR

  1. Per-commitment hedge phrases sourced from the ethics pack. Today the manifold owns hedge phrasing. A future commitment-keyed override would let ethics packs say "for defer_high_stakes_to_human_review, use 'Before proceeding,' instead of 'Perhaps'."
  2. Hedge strength tiers. Today a single hedge fires regardless of how many commitments violated. A pack could opt commitments into specific strength tiers (hedge_soft vs hedge_strong).
  3. Verdict surface for "was hedge injected this turn." Today only the ethics_verdict carries the signal; downstream consumers inferring "hedge fired" must inspect both the verdict and the prefix. A hedge_injected: bool field on ChatResponse / TurnEvent would make audit simpler.
  4. Stub-path soft disclosure with hedge. The current "I don't know — insufficient grounding" surface is fixed. A pack might want to inject domain-specific disclosure phrasing on stub. Deferred until packs need it.
  5. Interaction with assembler hedges (ADR-0028). Today idempotent-on-prefix prevents double-hedging; a future ADR could make the relationship explicit (e.g., assembler is responsible for alignment-score-driven hedges; runtime is responsible for ethics-violation-driven hedges; never both fire on the same turn).