core/packs/ethics/loader.py
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

409 lines
14 KiB
Python

"""Ethics-pack loader.
ADR-0033. Ethics packs sit alongside identity (ADR-0027) and safety
(ADR-0029) but occupy a distinct architectural niche:
* **Identity packs** carry *who* CORE is — value axes, directions, weights.
* **Safety packs** carry *what CORE will never do* — universal red lines.
* **Ethics packs** carry *what this deployment commits to in its domain* —
propositional pledges scoped to a deployment context (general, medical,
legal, financial, robotics, ...).
Like identity packs, ethics packs are **swappable** per deployment. Unlike
safety packs, missing ratification is not fail-closed: an absent or
unratified ethics pack falls back to ``DEFAULT_ETHICS_PACK`` with a typed
``EthicsPackError`` raised only when the requested pack is missing
*and* the default cannot be loaded.
At composition time, ``commitment_ids`` from the ethics pack are unioned
into the runtime manifold's ``boundary_ids`` alongside identity and
safety contributions. The composition is monotone: ethics can add
constraints, never remove them.
See ``docs/decisions/ADR-0033-ethics-packs.md``.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import FrozenSet, Iterable
from formation.hashing import verify_seal
class EthicsPackError(ValueError):
"""Raised when an ethics pack is missing, malformed, or unverified.
Inherits from ``ValueError`` (like ``IdentityPackError``, not
``SafetyPackError``) — ethics packs are swappable, and a missing
one is recoverable by falling back to the default. Only when *both*
the requested pack and the default are unloadable does this
surface.
"""
DEFAULT_ETHICS_PACK: str = "default_general_ethics_v1"
_DEFAULT_SEARCH_PATHS: tuple[Path, ...] = (
Path(__file__).resolve().parent,
)
_ALLOWED_DOMAINS: frozenset[str] = frozenset(
{"general", "medical", "legal", "financial", "robotics", "custom"}
)
@dataclass(frozen=True, slots=True)
class EthicsPack:
"""Loaded ethics pack.
``commitment_ids`` is the set of propositional pledges contributed
to the runtime manifold's ``boundary_ids``. ``domain`` declares the
deployment context.
ADR-0037 — ``refusal_commitments`` is the opt-in subset of
commitments that, when violated runtime-checkably, trigger typed
refusal at the runtime level (ADR-0036 surface). The default pack
leaves this empty: ethics remains audit-only unless a deployment
explicitly opts a commitment in. Must be a subset of
``commitment_ids``.
"""
pack_id: str
version: str
description: str
domain: str
commitment_ids: FrozenSet[str]
commitment_descriptions: dict[str, str]
mastery_report_sha256: str
ratified: bool
refusal_commitments: FrozenSet[str] = frozenset()
hedge_commitments: FrozenSet[str] = frozenset()
def load_ethics_pack(
pack_id: str = DEFAULT_ETHICS_PACK,
*,
search_paths: Iterable[Path | str] | None = None,
require_ratified: bool | None = None,
) -> EthicsPack:
"""Load an ethics pack.
Args:
pack_id: Pack identifier. Defaults to ``DEFAULT_ETHICS_PACK``.
search_paths: Directories to search. Defaults to
``packs/ethics/``.
require_ratified: When ``True``, require the companion
``<pack_id>.mastery_report.json`` and verify its self-seal.
``None`` (default) → production mode unless
``CORE_ALLOW_UNRATIFIED_ETHICS=1``.
Raises:
EthicsPackError: On malformed pack, bounds violation, or — in
production mode — unverified seal. Missing-file errors
surface here so callers can decide whether to fall back to
the default.
"""
paths = _resolve_search_paths(search_paths)
pack_path = _find_pack(pack_id, paths)
raw = _read_json(pack_path)
_validate_envelope(raw, pack_id)
_validate_ratification(raw, pack_id, require_ratified, pack_path)
commitments = _validate_commitments(raw["commitment_ids"], pack_id)
descriptions = _validate_descriptions(
raw.get("commitment_descriptions", {}), pack_id, commitments,
)
domain = _validate_domain(raw.get("domain", "general"), pack_id)
refusal_commitments = _validate_opt_in_subset(
raw.get("refusal_commitments", []),
pack_id=pack_id,
commitments=commitments,
field_name="refusal_commitments",
)
hedge_commitments = _validate_opt_in_subset(
raw.get("hedge_commitments", []),
pack_id=pack_id,
commitments=commitments,
field_name="hedge_commitments",
)
overlap = set(refusal_commitments) & set(hedge_commitments)
if overlap:
raise EthicsPackError(
f"ethics pack {pack_id!r}: commitments cannot appear in both "
f"refusal_commitments and hedge_commitments: "
f"{sorted(overlap)}"
)
return EthicsPack(
pack_id=str(raw["pack_id"]),
version=str(raw["version"]),
description=str(raw["description"]),
domain=domain,
commitment_ids=frozenset(commitments),
commitment_descriptions=descriptions,
mastery_report_sha256=str(raw.get("mastery_report_sha256", "")),
ratified=bool(raw.get("mastery_report_sha256")),
refusal_commitments=frozenset(refusal_commitments),
hedge_commitments=frozenset(hedge_commitments),
)
def available_packs(
search_paths: Iterable[Path | str] | None = None,
) -> list[dict[str, object]]:
"""List discoverable ethics packs as
``{"pack_id", "version", "description", "domain", "ratified", "path"}``
dicts in lex order.
"""
paths = _resolve_search_paths(search_paths)
seen: dict[str, dict[str, object]] = {}
for d in paths:
if not d.is_dir():
continue
for entry in sorted(d.glob("*.json")):
if entry.name.endswith(".mastery_report.json"):
continue
try:
raw = _read_json(entry)
except EthicsPackError:
continue
if not isinstance(raw, dict):
continue
if "schema_version" not in raw or "commitment_ids" not in raw:
continue
pack_id = str(raw.get("pack_id", entry.stem))
if pack_id in seen:
continue
seen[pack_id] = {
"pack_id": pack_id,
"version": str(raw.get("version", "")),
"description": str(raw.get("description", "")),
"domain": str(raw.get("domain", "general")),
"ratified": bool(raw.get("mastery_report_sha256")),
"path": str(entry),
}
return sorted(seen.values(), key=lambda d: str(d["pack_id"]))
# ---------- internals ----------
def _resolve_search_paths(
search_paths: Iterable[Path | str] | None,
) -> tuple[Path, ...]:
if search_paths is None:
return _DEFAULT_SEARCH_PATHS
return tuple(Path(p) for p in search_paths)
def _find_pack(pack_id: str, paths: tuple[Path, ...]) -> Path:
if not pack_id or "/" in pack_id or ".." in pack_id:
raise EthicsPackError(f"invalid ethics pack_id: {pack_id!r}")
for d in paths:
candidate = d / f"{pack_id}.json"
if candidate.is_file():
return candidate
raise EthicsPackError(
f"ethics pack {pack_id!r} not found in {[str(p) for p in paths]}"
)
def _read_json(path: Path) -> dict:
try:
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError) as exc:
raise EthicsPackError(
f"failed to read ethics pack {path}: {exc}"
) from exc
if not isinstance(data, dict):
raise EthicsPackError(
f"ethics pack {path} did not deserialize to a dict"
)
return data
def _validate_envelope(raw: dict, pack_id: str) -> None:
required = (
"pack_id",
"version",
"description",
"schema_version",
"domain",
"commitment_ids",
)
missing = [k for k in required if k not in raw]
if missing:
raise EthicsPackError(
f"ethics pack {pack_id!r} missing required fields: {missing}"
)
if raw.get("schema_version") != "1.0.0":
raise EthicsPackError(
f"ethics pack {pack_id!r}: unsupported schema_version "
f"{raw.get('schema_version')!r}"
)
if raw.get("pack_id") != pack_id:
raise EthicsPackError(
f"ethics pack file declares pack_id={raw.get('pack_id')!r} "
f"but was requested as {pack_id!r}"
)
def _validate_ratification(
raw: dict, pack_id: str, require_ratified: bool | None, pack_path: Path,
) -> None:
if require_ratified is False:
return
if require_ratified is None:
require_ratified = (
os.environ.get("CORE_ALLOW_UNRATIFIED_ETHICS") != "1"
)
if not require_ratified:
return
declared_sha = raw.get("mastery_report_sha256", "")
if not declared_sha:
raise EthicsPackError(
f"ethics pack {pack_id!r} is not ratified "
"(mastery_report_sha256 empty); set "
"CORE_ALLOW_UNRATIFIED_ETHICS=1 for development, or ratify "
"via scripts/ratify_ethics_pack.py."
)
report_path = pack_path.parent / f"{pack_id}.mastery_report.json"
if not report_path.is_file():
raise EthicsPackError(
f"ethics pack {pack_id!r}: companion report "
f"{report_path.name!r} is missing"
)
try:
with report_path.open("r", encoding="utf-8") as f:
report = json.load(f)
except (OSError, json.JSONDecodeError) as exc:
raise EthicsPackError(
f"ethics pack {pack_id!r}: failed to read companion report: {exc}"
) from exc
if not isinstance(report, dict):
raise EthicsPackError(
f"ethics pack {pack_id!r}: companion report is not a JSON object"
)
if report.get("report_sha256") != declared_sha:
raise EthicsPackError(
f"ethics pack {pack_id!r}: companion report SHA "
f"{str(report.get('report_sha256'))[:12]}... does not match "
f"pack's declared {declared_sha[:12]}..."
)
if not verify_seal(report, sha_field="report_sha256"):
raise EthicsPackError(
f"ethics pack {pack_id!r}: companion report failed self-seal "
"verification"
)
if not report.get("ratified", False):
raise EthicsPackError(
f"ethics pack {pack_id!r}: companion report has ratified=False"
)
def _validate_commitments(value: object, pack_id: str) -> list[str]:
if not isinstance(value, list) or len(value) < 1:
raise EthicsPackError(
f"ethics pack {pack_id!r}: commitment_ids must be a non-empty list"
)
seen: set[str] = set()
out: list[str] = []
for i, c in enumerate(value):
if not isinstance(c, str) or not c:
raise EthicsPackError(
f"ethics pack {pack_id!r}: commitment_ids[{i}] must be a "
"non-empty string"
)
if c in seen:
raise EthicsPackError(
f"ethics pack {pack_id!r}: duplicate commitment_id {c!r}"
)
seen.add(c)
out.append(c)
return out
def _validate_descriptions(
value: object, pack_id: str, commitments: list[str],
) -> dict[str, str]:
if not isinstance(value, dict):
raise EthicsPackError(
f"ethics pack {pack_id!r}: commitment_descriptions must be a dict"
)
out: dict[str, str] = {}
for c in commitments:
desc = value.get(c, "")
if not isinstance(desc, str):
raise EthicsPackError(
f"ethics pack {pack_id!r}: commitment_descriptions[{c!r}] "
"must be a string"
)
out[c] = desc
return out
def _validate_opt_in_subset(
value: object,
*,
pack_id: str,
commitments: list[str],
field_name: str,
) -> list[str]:
"""Validate an opt-in list of commitment ids.
ADR-0037 (``refusal_commitments``) and ADR-0038
(``hedge_commitments``) both encode opt-in subsets of
``commitment_ids``. An unknown or duplicate id is a load-time
error — silent typos would be catastrophic given the behavioral
consequences.
"""
if value is None or value == []:
return []
if not isinstance(value, list):
raise EthicsPackError(
f"ethics pack {pack_id!r}: {field_name} must be a list"
)
seen: set[str] = set()
valid = set(commitments)
out: list[str] = []
for i, c in enumerate(value):
if not isinstance(c, str) or not c:
raise EthicsPackError(
f"ethics pack {pack_id!r}: {field_name}[{i}] must be a "
"non-empty string"
)
if c in seen:
raise EthicsPackError(
f"ethics pack {pack_id!r}: duplicate {field_name} entry {c!r}"
)
if c not in valid:
raise EthicsPackError(
f"ethics pack {pack_id!r}: {field_name} entry {c!r} is not "
"a declared commitment_id"
)
seen.add(c)
out.append(c)
return out
def _validate_domain(value: object, pack_id: str) -> str:
if not isinstance(value, str) or not value:
raise EthicsPackError(
f"ethics pack {pack_id!r}: domain must be a non-empty string"
)
if value not in _ALLOWED_DOMAINS:
raise EthicsPackError(
f"ethics pack {pack_id!r}: domain={value!r} not in "
f"{sorted(_ALLOWED_DOMAINS)}"
)
return value
__all__ = [
"DEFAULT_ETHICS_PACK",
"EthicsPack",
"EthicsPackError",
"available_packs",
"load_ethics_pack",
]