Closes the 'identity hedges are generic' gap. When IdentityCheck reports
that a specific axis is deviating AND the pack supplies an axis_hedges
entry for that axis, the assembler uses that axis's phrase instead of
ADR-0028's generic preferred_hedge_*. The hedge text now names what is
actually at issue.
Selection: lex-smallest axis_id in (ctx.deviation_axes ∩ axis_hedges).
Deterministic; loader emits axis_hedges in lex order on axis_id.
Example surface at alignment=0.30 (strong band) under default pack:
No deviation → 'It seems that truth reveals reality.'
truthfulness deviates → 'Evidence is thin that truth reveals reality.'
coherence deviates → 'This does not yet cohere: truth reveals reality.'
reverence deviates → 'Reports suggest truth reveals reality.'
Same trajectory + truthfulness deviation, three different packs:
default_general_v1 → 'Evidence is thin that truth reveals reality.'
precision_first_v1 → 'The evidence does not support that truth reveals reality.'
generosity_first_v1 → 'Truth reveals reality.' (above generosity's strong=0.20)
Schema (additive, optional):
surface_preferences.axis_hedges = {
<axis_id>: { 'strong': str, 'soft': str, 'qualifier': str },
...
}
Bounds: each phrase length 1–64; axis_id non-empty. Absent block →
ADR-0028 byte-for-byte fallback. Loader emits pairs in lex order on
axis_id for hashability + deterministic tie-break.
Files:
core/physics/identity.py
+ class AxisHedge (frozen: strong, soft, qualifier)
SurfacePreferences gains axis_hedges: Tuple = ()
packs/identity/loader.py
+ _build_axis_hedges(): parse + bounds-check + emit lex-ordered tuple
generate/surface.py
SurfaceContext gains deviation_axes: frozenset[str] + axis_hedges tuple
+ _axis_specific_phrase(ctx): lex-smallest match or None
_apply_hedge consults axis-specific phrase before ADR-0028 fallback
Depth languages (he, grc) unchanged — ADR-0030 canonical phrases
chat/runtime.py
_build_surface_context lifts identity_score.deviation_axes and
prefs.axis_hedges into SurfaceContext
packs/identity/*.json
Three v1 packs gain axis_hedges blocks (truthfulness, coherence,
reverence — each pack uses voice consistent with its character)
scripts/ratify_identity_packs.py (no change — idempotent)
packs/identity/*.mastery_report.json
Auto-refreshed. New SHAs:
default_general_v1 → 2ab7d469013509ba5030313ca9a609a443d0716e3ddcc5596f59858ce054f5d3
precision_first_v1 → 78aa1e6a68a35c2c8576b6196a52d421b94f6d11e006128986902a4fd08679af
generosity_first_v1 → 511f1ce20edd4266239da61443bfc93473a5433f20bfee6692a25a03073dc933
Tests: tests/test_identity_score_decomposition.py — 17 new tests:
per-axis phrase selection, band gating still applies, pack swap with
same deviation produces three different phrases, lex tie-break is
deterministic, depth-language fallback to ADR-0030, backward compat
with empty deviation_axes, and the contract that all three v1 packs
ship axis_hedges for all three default-pack axes.
Suite status (all green):
cognition 121, teaching 17, runtime 19, formation 182, smoke 67
identity+safety+English+depth divergence 71
score decomposition 17
Scope limits (documented in ADR-0031):
- English-only at v1 (depth languages use canonical ADR-0030 phrases)
- Lex tie-break is operational not semantic — pack authors can re-key
if they need a different priority
- No dominance-driven phrasing (Interpretation A); preserved as
forward-compatible follow-up
Docs: ADR-0031 (Accepted) recorded; docs/identity_packs.md gains
§Axis-specific hedge phrases section and updated v1-pack SHAs; memory
'identity-packs.md' refreshed.
494 lines
18 KiB
Python
494 lines
18 KiB
Python
"""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 ``<pack_id>.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"]
|