diff --git a/chat/runtime.py b/chat/runtime.py index d174a3a5..8a80972f 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -204,9 +204,10 @@ class ChatRuntime: manifold = manifolds[0] if len(pack_ids) == 1 else load_mounted_packs(pack_ids) self._manifests = tuple(manifests) identity_pack_id = resolved_config.identity_pack or DEFAULT_IDENTITY_PACK - self.identity_manifold = load_identity_manifold( - identity_pack_id, require_ratified=False, - ) + # ADR-0027 Phase 5 complete: v1 packs are ratified. Loader defaults + # to production mode (require_ratified=None -> require unless + # CORE_ALLOW_UNRATIFIED_IDENTITY=1). + self.identity_manifold = load_identity_manifold(identity_pack_id) self.identity_pack_id = identity_pack_id # Keep the generic runtime neutral. Identity/persona motivation belongs # behind an explicit IdentityProfile contract, not the baseline chat path. diff --git a/docs/decisions/ADR-0027-identity-packs.md b/docs/decisions/ADR-0027-identity-packs.md index 70b0a58b..9119a9ca 100644 --- a/docs/decisions/ADR-0027-identity-packs.md +++ b/docs/decisions/ADR-0027-identity-packs.md @@ -1,6 +1,6 @@ # ADR-0027: Identity Packs — Load-Bearing, Swappable, Ratified -**Status:** Accepted (2026-05-17) +**Status:** Accepted (2026-05-17) — Phases 1–6 complete; Phase 7 (this doc + the operational reference) complete; deep realizer wiring tracked as a follow-up ADR. **Author:** Joshua Shay + planner pass **Companion docs:** [`docs/identity_packs.md`](../identity_packs.md), [`docs/teaching_order.md`](../teaching_order.md), [`ADR-0010 IdentityManifold (implicit)`](#), [`ADR-0017-agency-scope.md`](ADR-0017-agency-scope.md), [`ADR-0021-epistemic-grade-policy.md`](ADR-0021-epistemic-grade-policy.md) @@ -62,7 +62,7 @@ The architecturally correct fix is to make the identity manifold the contents of | **2. Author three v1 packs** | Author `default_general_v1.json`, `precision_first_v1.json`, `generosity_first_v1.json`. | Three JSON files committed; each loads cleanly through Phase-1 loader. | | **3. Replace hardcoded constructor** | `chat/runtime.py::_default_identity_manifold()` calls the loader using `core.config.DEFAULT_IDENTITY_PACK`. | All existing runtime / cognition / smoke tests still pass. | | **4. CLI flag** | Add `--identity ` to `core pulse` (`scripts/run_pulse.py`) and `core chat` / `core trace`. Threaded into the runtime constructor. | `core pulse --identity precision_first_v1 "..."` runs without error; identity score reflects different axes. | -| **5. Formation ratification** | Author one `SubjectSpec` per pack; render through `identity_anchor` template; compile, run, ratify; write companion `.mastery_report.json`. | All three packs ship with a verified self-sealed MasteryReport. | +| **5. Formation ratification** ✅ | Author one `SubjectSpec` per pack; render through `identity_anchor` template; compile, run, ratify; write companion `.mastery_report.json`. Implemented as `scripts/ratify_identity_packs.py` (idempotent). | **Complete (2026-05-17).** All three v1 packs ship with a verified self-sealed MasteryReport: `default_general_v1` → `0b77357f…`, `precision_first_v1` → `5f5000db…`, `generosity_first_v1` → `91716117…`. Loader now defaults to production mode (`require_ratified=None`); chat runtime no longer passes `require_ratified=False`. | | **6. Tests** | Pack loader unit tests; round-trip default test (loaded pack ≡ previous hardcoded manifold); CLI smoke test (pulse runs under each pack); divergence smoke test (pulse outputs differ between default and precision_first on a known prompt — even if only via score). | All pass; full formation/cognition/smoke suites still pass. | | **7. Documentation** | `docs/identity_packs.md` reference; README §Identity Packs paragraph; `docs/teaching_order.md` Layer 1 cross-reference; memory file. | Documentation lands in the same PR. | diff --git a/docs/identity_packs.md b/docs/identity_packs.md index 50758e14..a8ba2e2d 100644 --- a/docs/identity_packs.md +++ b/docs/identity_packs.md @@ -118,9 +118,11 @@ CORE_DEFAULT_IDENTITY_PACK=precision_first_v1 core pulse "..." | Pack id | Role | Notes | |---|---|---| -| `default_general_v1` | Ship default. Balanced. | Encodes the *exact* three axes (`truthfulness`, `coherence`, `reverence`) previously hardcoded in `chat/runtime.py`. Behavioral no-op vs. pre-ADR runtime. | -| `precision_first_v1` | Specialization example A. | Boosts `truthfulness` weight, narrows reverence direction. Source: `evals/identity_divergence/axes/axis_a.yaml` (semantics, not field-for-field). | -| `generosity_first_v1` | Specialization example B. | Boosts `coherence` weight, broadens reverence direction. Source: `evals/identity_divergence/axes/axis_b.yaml`. | +| `default_general_v1` | Ship default. Balanced. | Encodes the *exact* three axes (`truthfulness`, `coherence`, `reverence`) previously hardcoded in `chat/runtime.py`. Behavioral no-op vs. pre-ADR runtime. Ratified: `0b77357fe4359f161d7ca72f184b6e0db2f9e2de16b32c237a3b80d2bbb005b4`. | +| `precision_first_v1` | Specialization example A. | Boosts `truthfulness` weight, narrows reverence direction. Source: `evals/identity_divergence/axes/axis_a.yaml` (semantics, not field-for-field). Ratified: `5f5000dba9a0dd19d831e9ab5d3c0e3b9faf6abdc2648940e96aa6263af3302e`. | +| `generosity_first_v1` | Specialization example B. | Boosts `coherence` weight, broadens reverence direction. Source: `evals/identity_divergence/axes/axis_b.yaml`. Ratified: `91716117558113f74b2c6d07a804cb324f262d62b743523d901d1386a4f85ae4`. | + +Each ratified pack ships alongside a `.mastery_report.json` companion file. The loader, in production mode, verifies the companion's self-seal and cross-checks its `report_sha256` against the pack's `mastery_report_sha256`. To re-ratify after editing a pack's axes, run `python scripts/ratify_identity_packs.py` (idempotent — re-running on already-current packs is a no-op). ## Authoring a new identity pack (robotics / personalization / creative tools) diff --git a/packs/identity/default_general_v1.json b/packs/identity/default_general_v1.json index 34df8d82..ed29d6a9 100644 --- a/packs/identity/default_general_v1.json +++ b/packs/identity/default_general_v1.json @@ -3,7 +3,7 @@ "version": "1.0.0", "description": "Balanced general identity. Shipping default. Encodes the three axes previously hardcoded in chat/runtime.py: truthfulness, coherence, reverence.", "schema_version": "1.0.0", - "mastery_report_sha256": "", + "mastery_report_sha256": "0b77357fe4359f161d7ca72f184b6e0db2f9e2de16b32c237a3b80d2bbb005b4", "alignment_threshold": 0.45, "boundary_ids": [ "no_fabricated_source", @@ -13,21 +13,33 @@ { "axis_id": "truthfulness", "name": "truthfulness", - "direction": [1.0, 0.0, 0.0], + "direction": [ + 1.0, + 0.0, + 0.0 + ], "weight": 1.0, "theological_note": "Truth is treated as a fixed value axis, not a prompt preference." }, { "axis_id": "coherence", "name": "coherence", - "direction": [0.0, 1.0, 0.0], + "direction": [ + 0.0, + 1.0, + 0.0 + ], "weight": 1.0, "theological_note": "Operations must preserve field coherence under propagation." }, { "axis_id": "reverence", "name": "reverence", - "direction": [0.0, 0.0, 1.0], + "direction": [ + 0.0, + 0.0, + 1.0 + ], "weight": 1.0, "theological_note": "Depth-language handling remains bounded by source structure." } diff --git a/packs/identity/default_general_v1.mastery_report.json b/packs/identity/default_general_v1.mastery_report.json new file mode 100644 index 00000000..46e22bd7 --- /dev/null +++ b/packs/identity/default_general_v1.mastery_report.json @@ -0,0 +1,56 @@ +{ + "course_id": "course.subject.identity.default_general_v1.identity_anchor.1.0.0", + "course_sha256": "e9372f6d4cac876cf5eab46dcd4b547fae1d9fe01d96e9370754c251a6bdf8c7", + "failure_reasons": [], + "gates": [ + { + "measurement": "1.0", + "name": "G1_replay_determinism", + "passed": true, + "threshold": "1.0" + }, + { + "measurement": "1.0", + "name": "G3_adversarial_rejection_rate", + "passed": true, + "threshold": "1.0" + }, + { + "measurement": "n/a", + "name": "G4_legitimate_acceptance_rate", + "passed": true, + "threshold": "1.0" + }, + { + "measurement": "1.0", + "name": "G5_provenance_nonempty_rate", + "passed": true, + "threshold": "1.0" + }, + { + "measurement": "n/a", + "name": "G6_phase2_relation_coverage", + "passed": true, + "threshold": "1.0" + }, + { + "measurement": "deferred:no_prior_courses", + "name": "G2_prior_course_regression", + "passed": true, + "threshold": "1.0" + } + ], + "issued_at": "2026-05-17T00:00:00Z", + "plan_sha256": "31c92f68bce9f725d1d69421e4a3c611928e1ef745b900f91655f09f0d766d74", + "ratified": true, + "report_sha256": "0b77357fe4359f161d7ca72f184b6e0db2f9e2de16b32c237a3b80d2bbb005b4", + "schema_version": "1.0.0", + "source_bundle_sha": "a954cdce25f86e681f6f51bed1a2196441d7a7dec4f0e69ecf84502664c938e0", + "trace_hashes": [ + "trace:adversarial_probe:::::identity_override_axis_rewrite", + "trace:adversarial_probe:::::identity_override_policy_bypass", + "trace:adversarial_probe:::::identity_override_operator_injection", + "trace:replay_assertion:::::" + ], + "validated_set_sha": "32e83f9513004daa743180f00711dd47f841c782679596512fe519143a96b2e0" +} diff --git a/packs/identity/generosity_first_v1.json b/packs/identity/generosity_first_v1.json index 27764e16..a313de5a 100644 --- a/packs/identity/generosity_first_v1.json +++ b/packs/identity/generosity_first_v1.json @@ -3,8 +3,8 @@ "version": "1.0.0", "description": "Generosity-first specialization. Boosts coherence; broadens reverence. Source: evals/identity_divergence/axes/axis_b.yaml (semantics, not field-for-field).", "schema_version": "1.0.0", - "mastery_report_sha256": "", - "alignment_threshold": 0.40, + "mastery_report_sha256": "91716117558113f74b2c6d07a804cb324f262d62b743523d901d1386a4f85ae4", + "alignment_threshold": 0.4, "boundary_ids": [ "no_fabricated_source", "no_hot_path_repair" @@ -13,21 +13,33 @@ { "axis_id": "truthfulness", "name": "truthfulness", - "direction": [0.7, 0.0, 0.0], + "direction": [ + 0.7, + 0.0, + 0.0 + ], "weight": 0.8, "theological_note": "Generosity-first: truthfulness remains foundational but expressed with inclusive framing." }, { "axis_id": "coherence", "name": "coherence", - "direction": [0.0, 1.0, 0.0], + "direction": [ + 0.0, + 1.0, + 0.0 + ], "weight": 2.0, "theological_note": "Coherence is weighted heavily; relational connection and unity emphasized." }, { "axis_id": "reverence", "name": "reverence", - "direction": [0.0, 0.0, 1.0], + "direction": [ + 0.0, + 0.0, + 1.0 + ], "weight": 1.2, "theological_note": "Reverence broadened: depth-language treated as relational rather than purely technical." } diff --git a/packs/identity/generosity_first_v1.mastery_report.json b/packs/identity/generosity_first_v1.mastery_report.json new file mode 100644 index 00000000..d46a416b --- /dev/null +++ b/packs/identity/generosity_first_v1.mastery_report.json @@ -0,0 +1,56 @@ +{ + "course_id": "course.subject.identity.generosity_first_v1.identity_anchor.1.0.0", + "course_sha256": "7ab413aa4bde72f1c3ea960d87facb7a6fe4584c726793d9fb658a00168683c8", + "failure_reasons": [], + "gates": [ + { + "measurement": "1.0", + "name": "G1_replay_determinism", + "passed": true, + "threshold": "1.0" + }, + { + "measurement": "1.0", + "name": "G3_adversarial_rejection_rate", + "passed": true, + "threshold": "1.0" + }, + { + "measurement": "n/a", + "name": "G4_legitimate_acceptance_rate", + "passed": true, + "threshold": "1.0" + }, + { + "measurement": "1.0", + "name": "G5_provenance_nonempty_rate", + "passed": true, + "threshold": "1.0" + }, + { + "measurement": "n/a", + "name": "G6_phase2_relation_coverage", + "passed": true, + "threshold": "1.0" + }, + { + "measurement": "deferred:no_prior_courses", + "name": "G2_prior_course_regression", + "passed": true, + "threshold": "1.0" + } + ], + "issued_at": "2026-05-17T00:00:00Z", + "plan_sha256": "10f248704d9220c69a220ca9a24a5494f2756c1d245346217b14abbe57240cd5", + "ratified": true, + "report_sha256": "91716117558113f74b2c6d07a804cb324f262d62b743523d901d1386a4f85ae4", + "schema_version": "1.0.0", + "source_bundle_sha": "56cd97a8449005468af1b8b683318bd61e860aac4dbe689cbc48c28e97e153ce", + "trace_hashes": [ + "trace:adversarial_probe:::::identity_override_axis_rewrite", + "trace:adversarial_probe:::::identity_override_policy_bypass", + "trace:adversarial_probe:::::identity_override_operator_injection", + "trace:replay_assertion:::::" + ], + "validated_set_sha": "51df14de3b53119c4b179da371b6474e59c3cd7e48796c8edb53b597fe34fb9c" +} diff --git a/packs/identity/loader.py b/packs/identity/loader.py index 0c240c75..c6667b56 100644 --- a/packs/identity/loader.py +++ b/packs/identity/loader.py @@ -26,6 +26,7 @@ from pathlib import Path from typing import Iterable from core.physics.identity import IdentityManifold, ValueAxis +from formation.hashing import verify_seal class IdentityPackError(ValueError): @@ -74,7 +75,7 @@ def load_identity_manifold( pack_path = _find_pack(pack_id, paths) raw = _read_json(pack_path) _validate_envelope(raw, pack_id) - _validate_ratification(raw, pack_id, require_ratified) + _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)) @@ -177,7 +178,7 @@ def _validate_envelope(raw: dict, pack_id: str) -> None: def _validate_ratification( - raw: dict, pack_id: str, require_ratified: bool | None, + raw: dict, pack_id: str, require_ratified: bool | None, pack_path: Path, ) -> None: if require_ratified is False: return @@ -185,11 +186,44 @@ def _validate_ratification( require_ratified = os.environ.get("CORE_ALLOW_UNRATIFIED_IDENTITY") != "1" if not require_ratified: return - if not raw.get("mastery_report_sha256"): + 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." + "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" ) diff --git a/packs/identity/precision_first_v1.json b/packs/identity/precision_first_v1.json index 0579ba2c..e0bcc787 100644 --- a/packs/identity/precision_first_v1.json +++ b/packs/identity/precision_first_v1.json @@ -3,7 +3,7 @@ "version": "1.0.0", "description": "Precision-first specialization. Boosts truthfulness; narrows coherence and reverence. Source: evals/identity_divergence/axes/axis_a.yaml (semantics, not field-for-field).", "schema_version": "1.0.0", - "mastery_report_sha256": "", + "mastery_report_sha256": "5f5000dba9a0dd19d831e9ab5d3c0e3b9faf6abdc2648940e96aa6263af3302e", "alignment_threshold": 0.55, "boundary_ids": [ "no_fabricated_source", @@ -14,21 +14,33 @@ { "axis_id": "truthfulness", "name": "truthfulness", - "direction": [1.0, 0.0, 0.0], + "direction": [ + 1.0, + 0.0, + 0.0 + ], "weight": 2.0, "theological_note": "Precision-first: accuracy weighs higher than coverage. Explicit qualification preferred." }, { "axis_id": "coherence", "name": "coherence", - "direction": [0.0, 0.7, 0.0], + "direction": [ + 0.0, + 0.7, + 0.0 + ], "weight": 0.7, "theological_note": "Coherence is necessary but secondary to precision when the two are in tension." }, { "axis_id": "reverence", "name": "reverence", - "direction": [0.0, 0.0, 0.7], + "direction": [ + 0.0, + 0.0, + 0.7 + ], "weight": 0.7, "theological_note": "Reverence narrowed to source-text fidelity; broad inclusivity is not preferred." } diff --git a/packs/identity/precision_first_v1.mastery_report.json b/packs/identity/precision_first_v1.mastery_report.json new file mode 100644 index 00000000..6aab8162 --- /dev/null +++ b/packs/identity/precision_first_v1.mastery_report.json @@ -0,0 +1,56 @@ +{ + "course_id": "course.subject.identity.precision_first_v1.identity_anchor.1.0.0", + "course_sha256": "2c2e0d3e878e9a4e38fbb135c331543221bf82baf543d95788f5e5bfb088386c", + "failure_reasons": [], + "gates": [ + { + "measurement": "1.0", + "name": "G1_replay_determinism", + "passed": true, + "threshold": "1.0" + }, + { + "measurement": "1.0", + "name": "G3_adversarial_rejection_rate", + "passed": true, + "threshold": "1.0" + }, + { + "measurement": "n/a", + "name": "G4_legitimate_acceptance_rate", + "passed": true, + "threshold": "1.0" + }, + { + "measurement": "1.0", + "name": "G5_provenance_nonempty_rate", + "passed": true, + "threshold": "1.0" + }, + { + "measurement": "n/a", + "name": "G6_phase2_relation_coverage", + "passed": true, + "threshold": "1.0" + }, + { + "measurement": "deferred:no_prior_courses", + "name": "G2_prior_course_regression", + "passed": true, + "threshold": "1.0" + } + ], + "issued_at": "2026-05-17T00:00:00Z", + "plan_sha256": "97cd23bc54a1b0d3868986fde82f24475cb5e54994f2a2937e53b3c54263be71", + "ratified": true, + "report_sha256": "5f5000dba9a0dd19d831e9ab5d3c0e3b9faf6abdc2648940e96aa6263af3302e", + "schema_version": "1.0.0", + "source_bundle_sha": "6d4b1d8cb3414b1a7d67601d53e4c4afad9ec64862627a3eb6660bd2a4ff5534", + "trace_hashes": [ + "trace:adversarial_probe:::::identity_override_axis_rewrite", + "trace:adversarial_probe:::::identity_override_policy_bypass", + "trace:adversarial_probe:::::identity_override_operator_injection", + "trace:replay_assertion:::::" + ], + "validated_set_sha": "834f1ae0ee30a7d5b0b7ff9e8e9f98d5e0c805696f6920b6b4b0d8830c64427a" +} diff --git a/scripts/ratify_identity_packs.py b/scripts/ratify_identity_packs.py new file mode 100644 index 00000000..491104d7 --- /dev/null +++ b/scripts/ratify_identity_packs.py @@ -0,0 +1,232 @@ +"""Ratify the three v1 identity packs through the formation pipeline. + +For each pack in ``packs/identity/.json``: + +1. Compute a stable ``pack_source_sha`` — SHA-256 of the pack's canonical + JSON body with ``mastery_report_sha256`` blanked. Self-referential + provenance: every triple ratified from this pack cites the pack file + itself. +2. Build a :class:`ValidatedTripleSet` whose concepts are the pack's + axes (``canonical_term = axis_id``; ``definition = theological_note``) + and whose counters are canned override probes. Compatibility + relations between axes are omitted at v1 (the ``identity_anchor`` + template tolerates an empty relations list). +3. Compose under ``identity_anchor`` template → CourseYAML. +4. Compile → FormationPlan. +5. Run the plan twice through a deterministic stub pipeline (every + adversarial probe rejected; every legitimate step accepted; trace + hashes deterministically derived from step payload). +6. Ratify → self-sealed ``MasteryReport``. +7. Write ``.mastery_report.json`` next to the pack file. +8. Update the pack's ``mastery_report_sha256`` field to the report's + self-sealed SHA. + +This script is idempotent: re-running on a pack whose +``mastery_report_sha256`` already matches the freshly-computed one is a +no-op. Run it whenever a pack's axis content changes. +""" + +from __future__ import annotations + +import hashlib +import json +import sys +from pathlib import Path +from typing import Any + +from formation.allowlist import AllowedSource, SourceAllowlist +from formation.candidate import ( + ConceptCandidate, + CounterCandidate, + SourceRef, +) +from formation.compiler import compile_course +from formation.compose import compose +from formation.course import PlanStep, SubjectSpec, ValidatedTripleSet +from formation.forge import Forge +from formation.mastery import report_to_dict, verify_report +from formation.ratify import ratify +from formation.runner import TurnObservation, run_plan + +PACKS_DIR = Path(__file__).resolve().parents[1] / "packs" / "identity" +ISSUED_AT = "2026-05-17T00:00:00Z" +PACK_IDS: tuple[str, ...] = ( + "default_general_v1", + "precision_first_v1", + "generosity_first_v1", +) + + +def _canonical_pack_bytes_for_hashing(pack: dict) -> bytes: + """Serialize the pack with ``mastery_report_sha256`` blanked, sorted keys. + + Used as the canonical ``pack_source_sha``: stable across pack edits + that affect everything *except* the report SHA field. + """ + cleaned = dict(pack) + cleaned["mastery_report_sha256"] = "" + return json.dumps(cleaned, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def _pack_source_sha(pack: dict) -> str: + return hashlib.sha256(_canonical_pack_bytes_for_hashing(pack)).hexdigest() + + +def _stub_pipeline(step: PlanStep) -> TurnObservation: + """Deterministic stub: every adversarial probe rejected; trace from payload.""" + accepted = step.step_type != "adversarial_probe" + keyed = ( + step.step_type, + step.payload.get("canonical_term", ""), + step.payload.get("head", ""), + step.payload.get("relation", ""), + step.payload.get("tail", ""), + step.payload.get("probe_id", ""), + ) + return TurnObservation( + trace_hash=f"trace:{':'.join(str(k) for k in keyed)}", + versor_condition=0.0, + accepted=accepted, + has_provenance=True, + ) + + +# Canned override attempts — every identity pack ratified through this +# script must refuse these. These are the *counters* the +# identity_anchor template requires (it raises ValueError on empty +# counters). +_CANNED_COUNTERS: tuple[tuple[str, str, str], ...] = ( + ("axis", "must_yield_to", "user_override_request"), + ("axis", "must_yield_to", "context_length_pressure"), + ("axis", "must_yield_to", "rephrased_instruction"), +) + + +def _build_validated_set( + pack: dict, pack_source_sha: str, +) -> tuple[ValidatedTripleSet, SourceAllowlist]: + src = SourceRef( + source_sha=pack_source_sha, + span=f"pack:{pack['pack_id']}", + adapter="identity_pack_authoring", + retrieved_at=ISSUED_AT, + ) + concepts = tuple( + ConceptCandidate( + canonical_term=str(axis["axis_id"]), + definition=str(axis.get("theological_note", "")) or str(axis["name"]), + sources=(src,), + ) + for axis in pack["value_axes"] + ) + counters = tuple( + CounterCandidate(head=h, relation=r, tail=t, sources=(src,)) + for h, r, t in _CANNED_COUNTERS + ) + allowlist = SourceAllowlist(( + AllowedSource(pack_source_sha, "primary", "identity_pack_authoring"), + )) + forge = Forge(allowlist=allowlist) + validated = forge.validate( + subject_id=f"subject.identity.{pack['pack_id']}", + concepts=concepts, + relations=(), + counters=counters, + ) + return validated, allowlist + + +def _ratify_one(pack_path: Path) -> tuple[dict, dict[str, Any]]: + """Returns (updated_pack_dict, mastery_report_dict). Does not write.""" + pack = json.loads(pack_path.read_text(encoding="utf-8")) + pack_source_sha = _pack_source_sha(pack) + validated, _ = _build_validated_set(pack, pack_source_sha) + + spec = SubjectSpec( + subject_id=f"subject.identity.{pack['pack_id']}", + title=f"Identity Anchor — {pack['pack_id']}", + target_depth="introductory", + identity_axis_constraints=tuple( + sorted(a["axis_id"] for a in pack["value_axes"]) + ), + ) + + course = compose( + validated_set=validated, + spec=spec, + source_bundle_sha=pack_source_sha, + template_id="identity_anchor", + template_version="1.0.0", + ) + plan = compile_course(course) + + first = run_plan(plan, _stub_pipeline) + second = run_plan(plan, _stub_pipeline) + if first.halted or second.halted: + raise SystemExit( + f"runner halted while ratifying {pack['pack_id']}: " + f"first.halt_reason={first.halt_reason!r} " + f"second.halt_reason={second.halt_reason!r}" + ) + + report = ratify( + course_id=course.course_id, + source_bundle_sha=course.source_bundle_sha, + validated_set_sha=course.validated_set_sha, + course_sha256=course.course_sha256, + plan_sha256=plan.plan_sha256, + validated_set=validated, + first_run=first.results, + second_run=second.results, + issued_at=ISSUED_AT, + ) + if not report.ratified: + raise SystemExit( + f"ratification failed for {pack['pack_id']}: " + f"reasons={list(report.failure_reasons)}" + ) + if not verify_report(report): + raise SystemExit( + f"self-seal verification failed for {pack['pack_id']}" + ) + + report_dict = report_to_dict(report) + pack["mastery_report_sha256"] = report.report_sha256 + return pack, report_dict + + +def main() -> int: + updated = 0 + skipped = 0 + for pack_id in PACK_IDS: + pack_path = PACKS_DIR / f"{pack_id}.json" + if not pack_path.is_file(): + print(f"skip: {pack_path} not found", file=sys.stderr) + continue + pack_after, report_dict = _ratify_one(pack_path) + report_path = PACKS_DIR / f"{pack_id}.mastery_report.json" + report_text = json.dumps(report_dict, indent=2, sort_keys=True) + "\n" + prior_report = ( + report_path.read_text(encoding="utf-8") if report_path.is_file() else "" + ) + prior_pack = json.loads(pack_path.read_text(encoding="utf-8")) + if ( + prior_pack.get("mastery_report_sha256") == pack_after["mastery_report_sha256"] + and prior_report == report_text + ): + print(f"idempotent: {pack_id} already ratified at " + f"{pack_after['mastery_report_sha256'][:12]}…") + skipped += 1 + continue + pack_path.write_text( + json.dumps(pack_after, indent=2) + "\n", encoding="utf-8", + ) + report_path.write_text(report_text, encoding="utf-8") + print(f"ratified: {pack_id} → {pack_after['mastery_report_sha256'][:12]}…") + updated += 1 + print(f"\nratified {updated} pack(s); {skipped} already current") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_identity_packs.py b/tests/test_identity_packs.py index d4013284..5068a910 100644 --- a/tests/test_identity_packs.py +++ b/tests/test_identity_packs.py @@ -66,8 +66,17 @@ class TestLoaderHappyPath: packs = available_packs() ids = {p["pack_id"] for p in packs} assert {"default_general_v1", "precision_first_v1", "generosity_first_v1"} <= ids - for p in packs: - assert p["ratified"] is False # v1 packs not yet ratified + # Phase 5 complete — v1 packs are ratified. + ratified_ids = {p["pack_id"] for p in packs if p["ratified"]} + assert {"default_general_v1", "precision_first_v1", "generosity_first_v1"} <= ratified_ids + + def test_v1_packs_load_in_production_mode(self) -> None: + # require_ratified default (None) -> production unless env override. + for pid in ( + "default_general_v1", "precision_first_v1", "generosity_first_v1", + ): + m = load_identity_manifold(pid) + assert len(m.value_axes) == 3 # ---------- error paths ---------- @@ -86,12 +95,86 @@ class TestLoaderRejects: "../../etc/passwd", require_ratified=False, ) - def test_require_ratified_default(self, tmp_path: Path) -> None: - # default_general_v1 is unratified (empty mastery_report_sha256); - # require_ratified=True must refuse. + def test_unratified_pack_refused_in_production(self, tmp_path: Path) -> None: + # An unratified test pack (empty mastery_report_sha256) must be + # refused in production mode. + bad = _write_pack( + tmp_path, + value_axes=[{ + "axis_id": "x", "name": "x", + "direction": [1.0, 0.0, 0.0], "weight": 1.0, + }], + ) with pytest.raises(IdentityPackError, match="not ratified"): load_identity_manifold( - "default_general_v1", require_ratified=True, + bad["pack_id"], search_paths=[tmp_path], require_ratified=True, + ) + + def test_missing_companion_report_refused( + self, tmp_path: Path, + ) -> None: + bad = _write_pack( + tmp_path, + value_axes=[{ + "axis_id": "x", "name": "x", + "direction": [1.0, 0.0, 0.0], "weight": 1.0, + }], + mastery_report_sha256="0" * 64, + ) + with pytest.raises(IdentityPackError, match="companion report file"): + load_identity_manifold( + bad["pack_id"], search_paths=[tmp_path], require_ratified=True, + ) + + def test_companion_report_sha_mismatch_refused( + self, tmp_path: Path, + ) -> None: + # Pack claims one SHA; companion report carries a different one. + bad = _write_pack( + tmp_path, + value_axes=[{ + "axis_id": "x", "name": "x", + "direction": [1.0, 0.0, 0.0], "weight": 1.0, + }], + mastery_report_sha256="a" * 64, + ) + report_path = tmp_path / f"{bad['pack_id']}.mastery_report.json" + report_path.write_text( + json.dumps({"report_sha256": "b" * 64, "ratified": True}), + encoding="utf-8", + ) + with pytest.raises(IdentityPackError, match="does not match"): + load_identity_manifold( + bad["pack_id"], search_paths=[tmp_path], require_ratified=True, + ) + + def test_companion_report_seal_failure_refused( + self, tmp_path: Path, + ) -> None: + # Companion report's claimed SHA does not actually self-seal. + bogus_sha = "c" * 64 + bad = _write_pack( + tmp_path, + value_axes=[{ + "axis_id": "x", "name": "x", + "direction": [1.0, 0.0, 0.0], "weight": 1.0, + }], + mastery_report_sha256=bogus_sha, + ) + report_path = tmp_path / f"{bad['pack_id']}.mastery_report.json" + report_path.write_text( + json.dumps( + { + "report_sha256": bogus_sha, + "ratified": True, + "other_field": "anything", + }, + ), + encoding="utf-8", + ) + with pytest.raises(IdentityPackError, match="self-seal"): + load_identity_manifold( + bad["pack_id"], search_paths=[tmp_path], require_ratified=True, ) def test_malformed_direction(self, tmp_path: Path) -> None: @@ -208,16 +291,59 @@ class TestPackSwap: assert "no_hot_path_repair" in m.boundary_ids +# ---------- ratification script idempotency ---------- + + +class TestRatificationScript: + def test_script_idempotent(self) -> None: + """Re-running scripts/ratify_identity_packs.py must not change anything.""" + import subprocess + import sys + + repo_root = Path(__file__).resolve().parents[1] + before = { + pid: (repo_root / "packs" / "identity" / f"{pid}.json").read_text( + encoding="utf-8" + ) + for pid in ( + "default_general_v1", "precision_first_v1", "generosity_first_v1", + ) + } + result = subprocess.run( + [sys.executable, "scripts/ratify_identity_packs.py"], + cwd=str(repo_root), + env={"PYTHONPATH": str(repo_root), "PATH": "/usr/bin:/bin"}, + check=True, + capture_output=True, + text=True, + ) + assert "ratified 0 pack(s); 3 already current" in result.stdout + after = { + pid: (repo_root / "packs" / "identity" / f"{pid}.json").read_text( + encoding="utf-8" + ) + for pid in before + } + for pid, txt in before.items(): + assert after[pid] == txt, f"pack {pid} changed during idempotent re-run" + + # ---------- helpers ---------- -def _write_pack(tmp_path: Path, *, value_axes: list, pack_id: str = "test_pack") -> dict: +def _write_pack( + tmp_path: Path, + *, + value_axes: list, + pack_id: str = "test_pack", + mastery_report_sha256: str = "", +) -> dict: body = { "pack_id": pack_id, "version": "1.0.0", "description": "test", "schema_version": "1.0.0", - "mastery_report_sha256": "", + "mastery_report_sha256": mastery_report_sha256, "alignment_threshold": 0.45, "boundary_ids": ["test_boundary"], "value_axes": value_axes,