"""Identity-pack loader. Reads a content-addressed identity pack from disk and constructs an :class:`IdentityManifold` for the runtime. See ``docs/decisions/ADR-0027-identity-packs.md`` for context. Loader contract (read carefully — this is a trust boundary): * The loader never mutates a pack on disk. Pack creation goes through the formation pipeline (``formation.templates.identity_anchor`` -> compose -> ratify -> promote). * Bounds checks (axis count, direction magnitude, weight, threshold range, axis-id uniqueness) are enforced before any field of the returned manifold is observable to runtime code. * When ``require_ratified=True`` and the pack's ``mastery_report_sha256`` is empty, the loader refuses. Development environments may set ``CORE_ALLOW_UNRATIFIED_IDENTITY=1`` to bypass — this is a development-only escape hatch and is logged. """ from __future__ import annotations import json import os from pathlib import Path from typing import Iterable from core.physics.identity import ( AxisHedge, IdentityManifold, SurfacePreferences, ValueAxis, ) from formation.hashing import verify_seal class IdentityPackError(ValueError): """Raised when an identity pack is missing, malformed, or out of bounds.""" _DEFAULT_SEARCH_PATHS: tuple[Path, ...] = ( Path(__file__).resolve().parent, ) _MAX_WEIGHT: float = 10.0 _MIN_AXES: int = 1 _DIRECTION_LEN: int = 3 _DIRECTION_BOUND: float = 1.0 _THRESHOLD_LO: float = 0.0 _THRESHOLD_HI: float = 1.0 def load_identity_manifold( pack_id: str, *, search_paths: Iterable[Path | str] | None = None, require_ratified: bool | None = None, ) -> IdentityManifold: """Load an identity pack and construct its :class:`IdentityManifold`. Args: pack_id: Pack identifier (e.g. ``"default_general_v1"``). The loader looks for ``.json`` in each search path. search_paths: Iterable of directories to search. Default is the built-in ``packs/identity/`` directory. Earlier paths take precedence — pass overlay directories first. require_ratified: When ``True``, refuse packs whose ``mastery_report_sha256`` is empty. When ``None`` (default), require ratification unless the env var ``CORE_ALLOW_UNRATIFIED_IDENTITY=1`` is set. When ``False``, never require ratification (for tests and development). Returns: A constructed :class:`IdentityManifold`. Raises: IdentityPackError: On any bounds violation, missing file, malformed JSON, or — in production mode — unverified self-seal. """ 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) axes = _build_axes(raw["value_axes"], pack_id) threshold = _validate_threshold(raw["alignment_threshold"], pack_id) boundaries = frozenset(_validate_boundaries(raw["boundary_ids"], pack_id)) surface_prefs = _build_surface_preferences( raw.get("surface_preferences"), pack_id, ) return IdentityManifold( value_axes=axes, boundary_ids=boundaries, alignment_threshold=threshold, surface_preferences=surface_prefs, ) def available_packs( search_paths: Iterable[Path | str] | None = None, ) -> list[dict[str, object]]: """Return a list of ``{"pack_id", "version", "description", "ratified"}`` dicts for every JSON pack discoverable on the search paths. Sorted by ``pack_id``. """ 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")): # Skip companion artifacts (mastery reports, etc.) — only # real identity packs declare ``schema_version`` and # ``value_axes``. if entry.name.endswith(".mastery_report.json"): continue try: raw = _read_json(entry) except IdentityPackError: continue if not isinstance(raw, dict): continue if "schema_version" not in raw or "value_axes" 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", "")), "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 IdentityPackError(f"invalid pack_id: {pack_id!r}") for d in paths: candidate = d / f"{pack_id}.json" if candidate.is_file(): return candidate raise IdentityPackError( f"identity 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 IdentityPackError(f"failed to read pack {path}: {exc}") from exc if not isinstance(data, dict): raise IdentityPackError(f"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", "alignment_threshold", "boundary_ids", "value_axes", ) missing = [k for k in required if k not in raw] if missing: raise IdentityPackError( f"pack {pack_id!r} missing required fields: {missing}" ) if raw.get("schema_version") != "1.0.0": raise IdentityPackError( f"pack {pack_id!r}: unsupported schema_version " f"{raw.get('schema_version')!r}" ) if raw.get("pack_id") != pack_id: raise IdentityPackError( f"pack file declares pack_id={raw.get('pack_id')!r} but was " f"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_IDENTITY") != "1" if not require_ratified: return declared_sha = raw.get("mastery_report_sha256", "") if not declared_sha: raise IdentityPackError( f"pack {pack_id!r} is not ratified (mastery_report_sha256 empty); " "set CORE_ALLOW_UNRATIFIED_IDENTITY=1 for development, or " "ratify the pack through the formation pipeline " "(scripts/ratify_identity_packs.py)." ) report_path = pack_path.parent / f"{pack_id}.mastery_report.json" if not report_path.is_file(): raise IdentityPackError( f"pack {pack_id!r} declares mastery_report_sha256={declared_sha[:12]}" f"... but companion report file {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 IdentityPackError( f"pack {pack_id!r}: failed to read companion report: {exc}" ) from exc if not isinstance(report, dict): raise IdentityPackError( f"pack {pack_id!r}: companion report is not a JSON object" ) if report.get("report_sha256") != declared_sha: raise IdentityPackError( f"pack {pack_id!r}: companion report SHA " f"{str(report.get('report_sha256'))[:12]}... does not match pack's " f"declared {declared_sha[:12]}..." ) if not verify_seal(report, sha_field="report_sha256"): raise IdentityPackError( f"pack {pack_id!r}: companion report failed self-seal verification" ) if not report.get("ratified", False): raise IdentityPackError( f"pack {pack_id!r}: companion report has ratified=False" ) def _build_axes(axes_raw: list, pack_id: str) -> tuple[ValueAxis, ...]: if not isinstance(axes_raw, list) or len(axes_raw) < _MIN_AXES: raise IdentityPackError( f"pack {pack_id!r}: value_axes must be a list with at least " f"{_MIN_AXES} axis" ) seen_ids: set[str] = set() axes: list[ValueAxis] = [] for i, axis_raw in enumerate(axes_raw): if not isinstance(axis_raw, dict): raise IdentityPackError( f"pack {pack_id!r}: axis[{i}] must be a dict, got " f"{type(axis_raw).__name__}" ) for field in ("axis_id", "name", "direction", "weight"): if field not in axis_raw: raise IdentityPackError( f"pack {pack_id!r}: axis[{i}] missing field {field!r}" ) axis_id = str(axis_raw["axis_id"]) if axis_id in seen_ids: raise IdentityPackError( f"pack {pack_id!r}: duplicate axis_id {axis_id!r}" ) seen_ids.add(axis_id) direction = axis_raw["direction"] if not isinstance(direction, list) or len(direction) != _DIRECTION_LEN: raise IdentityPackError( f"pack {pack_id!r}: axis {axis_id!r} direction must be a " f"list of {_DIRECTION_LEN} floats" ) for j, comp in enumerate(direction): if not isinstance(comp, (int, float)): raise IdentityPackError( f"pack {pack_id!r}: axis {axis_id!r} direction[{j}] " "must be numeric" ) if not -_DIRECTION_BOUND <= float(comp) <= _DIRECTION_BOUND: raise IdentityPackError( f"pack {pack_id!r}: axis {axis_id!r} direction[{j}]=" f"{comp} out of bounds [-{_DIRECTION_BOUND}, " f"{_DIRECTION_BOUND}]" ) weight = axis_raw["weight"] if not isinstance(weight, (int, float)): raise IdentityPackError( f"pack {pack_id!r}: axis {axis_id!r} weight must be numeric" ) if not 0.0 <= float(weight) <= _MAX_WEIGHT: raise IdentityPackError( f"pack {pack_id!r}: axis {axis_id!r} weight={weight} " f"out of bounds [0, {_MAX_WEIGHT}]" ) axes.append( ValueAxis( name=str(axis_raw["name"]), direction=tuple(float(x) for x in direction), axis_id=axis_id, weight=float(weight), theological_note=str(axis_raw.get("theological_note", "")), ) ) return tuple(axes) def _validate_threshold(value: object, pack_id: str) -> float: if not isinstance(value, (int, float)): raise IdentityPackError( f"pack {pack_id!r}: alignment_threshold must be numeric" ) fv = float(value) if not _THRESHOLD_LO <= fv <= _THRESHOLD_HI: raise IdentityPackError( f"pack {pack_id!r}: alignment_threshold={fv} out of bounds " f"[{_THRESHOLD_LO}, {_THRESHOLD_HI}]" ) return fv _ALLOWED_CLAIM_STRENGTHS: frozenset[str] = frozenset( {"balanced", "qualified", "affirmative"} ) _MIN_HEDGE_LEN: int = 1 _MAX_HEDGE_LEN: int = 64 def _build_surface_preferences( value: object, pack_id: str, ) -> SurfacePreferences: """Parse and bounds-check the optional ``surface_preferences`` block. Absent block → defaults that reproduce pre-ADR-0028 behavior. """ if value is None: return SurfacePreferences() if not isinstance(value, dict): raise IdentityPackError( f"pack {pack_id!r}: surface_preferences must be a dict, got " f"{type(value).__name__}" ) defaults = SurfacePreferences() strong = _validate_threshold_field( value.get("hedge_threshold_strong", defaults.hedge_threshold_strong), pack_id, "hedge_threshold_strong", ) soft = _validate_threshold_field( value.get("hedge_threshold_soft", defaults.hedge_threshold_soft), pack_id, "hedge_threshold_soft", ) band_high = _validate_threshold_field( value.get("qualified_band_high", defaults.qualified_band_high), pack_id, "qualified_band_high", ) if not (strong <= soft <= band_high): raise IdentityPackError( f"pack {pack_id!r}: surface_preferences thresholds must satisfy " f"hedge_threshold_strong ({strong}) <= " f"hedge_threshold_soft ({soft}) <= " f"qualified_band_high ({band_high})" ) claim_strength = str( value.get("claim_strength", defaults.claim_strength) ) if claim_strength not in _ALLOWED_CLAIM_STRENGTHS: raise IdentityPackError( f"pack {pack_id!r}: claim_strength={claim_strength!r} not in " f"{sorted(_ALLOWED_CLAIM_STRENGTHS)}" ) axis_hedges = _build_axis_hedges(value.get("axis_hedges"), pack_id) return SurfacePreferences( hedge_threshold_strong=strong, hedge_threshold_soft=soft, preferred_hedge_strong=_validate_hedge_phrase( value.get("preferred_hedge_strong", defaults.preferred_hedge_strong), pack_id, "preferred_hedge_strong", ), preferred_hedge_soft=_validate_hedge_phrase( value.get("preferred_hedge_soft", defaults.preferred_hedge_soft), pack_id, "preferred_hedge_soft", ), claim_strength=claim_strength, qualified_band_high=band_high, preferred_qualifier=_validate_hedge_phrase( value.get("preferred_qualifier", defaults.preferred_qualifier), pack_id, "preferred_qualifier", ), axis_hedges=axis_hedges, ) def _build_axis_hedges( value: object, pack_id: str, ) -> tuple[tuple[str, AxisHedge], ...]: """Parse and bounds-check the optional ``axis_hedges`` sub-block. Returns a tuple of ``(axis_id, AxisHedge)`` pairs in lex order on ``axis_id``. Absent block → empty tuple → ADR-0028 fallback. """ if value is None: return () if not isinstance(value, dict): raise IdentityPackError( f"pack {pack_id!r}: surface_preferences.axis_hedges must be a dict" ) pairs: list[tuple[str, AxisHedge]] = [] for axis_id in sorted(value): if not isinstance(axis_id, str) or not axis_id: raise IdentityPackError( f"pack {pack_id!r}: axis_hedges keys must be non-empty strings" ) entry = value[axis_id] if not isinstance(entry, dict): raise IdentityPackError( f"pack {pack_id!r}: axis_hedges[{axis_id!r}] must be a dict" ) missing = [k for k in ("strong", "soft", "qualifier") if k not in entry] if missing: raise IdentityPackError( f"pack {pack_id!r}: axis_hedges[{axis_id!r}] missing fields: " f"{missing}" ) pairs.append( ( axis_id, AxisHedge( strong=_validate_hedge_phrase( entry["strong"], pack_id, f"axis_hedges[{axis_id!r}].strong", ), soft=_validate_hedge_phrase( entry["soft"], pack_id, f"axis_hedges[{axis_id!r}].soft", ), qualifier=_validate_hedge_phrase( entry["qualifier"], pack_id, f"axis_hedges[{axis_id!r}].qualifier", ), ), ) ) return tuple(pairs) def _validate_threshold_field(value: object, pack_id: str, field: str) -> float: if not isinstance(value, (int, float)): raise IdentityPackError( f"pack {pack_id!r}: surface_preferences.{field} must be numeric" ) fv = float(value) if not _THRESHOLD_LO <= fv <= _THRESHOLD_HI: raise IdentityPackError( f"pack {pack_id!r}: surface_preferences.{field}={fv} out of " f"bounds [{_THRESHOLD_LO}, {_THRESHOLD_HI}]" ) return fv def _validate_hedge_phrase(value: object, pack_id: str, field: str) -> str: if not isinstance(value, str): raise IdentityPackError( f"pack {pack_id!r}: surface_preferences.{field} must be a string" ) if not _MIN_HEDGE_LEN <= len(value) <= _MAX_HEDGE_LEN: raise IdentityPackError( f"pack {pack_id!r}: surface_preferences.{field} length " f"{len(value)} out of bounds [{_MIN_HEDGE_LEN}, {_MAX_HEDGE_LEN}]" ) return value def _validate_boundaries(value: object, pack_id: str) -> list[str]: if not isinstance(value, list): raise IdentityPackError( f"pack {pack_id!r}: boundary_ids must be a list" ) out: list[str] = [] for i, b in enumerate(value): if not isinstance(b, str) or not b: raise IdentityPackError( f"pack {pack_id!r}: boundary_ids[{i}] must be a non-empty " "string" ) out.append(b) return out __all__ = ["IdentityPackError", "available_packs", "load_identity_manifold"]