diff --git a/README.md b/README.md index 74426f82..fca77c5c 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,14 @@ Full evidence: --- +## Safety Pack + +Sibling to the identity packs but architecturally distinct: the safety pack at `packs/safety/core_safety_axes_v1.json` carries the boundaries CORE will **never** cross — `no_fabricated_source`, `no_hot_path_repair`, `no_identity_override`, `no_silent_correction`, `preserve_versor_closure`. The pack loads unconditionally at runtime startup (fail-closed on missing or unverified), and its boundaries are unioned into whatever identity pack is selected. Identity packs may *add* boundaries on top, but may never remove safety boundaries. + +This is the architecture downstream robotics, healthcare, and other high-stakes deployments will need before they can build CORE into anything that matters. Full doctrine: [`docs/safety_packs.md`](docs/safety_packs.md); decision record: [ADR-0029](docs/decisions/ADR-0029-safety-packs.md). + +--- + ## Identity Packs CORE's identity is load-bearing: every reasoning trajectory is scored against an `IdentityManifold` of value axes, and a `PersonaMotor` derived from those axes biases every field walk. As of [ADR-0027](docs/decisions/ADR-0027-identity-packs.md) the manifold is no longer hardcoded — it is loaded at runtime from a swappable, content-addressed pack under `packs/identity/`. diff --git a/chat/runtime.py b/chat/runtime.py index e8ec3150..ea7b5c63 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -19,6 +19,7 @@ from core.physics.identity import ( TurnEvent, ) from packs.identity.loader import load_identity_manifold +from packs.safety.loader import load_safety_pack from field.state import FieldState from generate.articulation import ArticulationPlan, realize from generate.dialogue import DialogueRole, classify_dialogue_blade, propose_dialogue @@ -207,7 +208,21 @@ class ChatRuntime: # 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) + identity_manifold = load_identity_manifold(identity_pack_id) + # ADR-0029: safety pack is always loaded; its boundary_ids are + # unioned into the runtime manifold. Identity packs may add + # boundaries but cannot remove safety boundaries. Failure to + # load the safety pack is fail-closed; SafetyPackError propagates + # and prevents runtime startup. + self.safety_pack = load_safety_pack() + self.identity_manifold = type(identity_manifold)( + value_axes=identity_manifold.value_axes, + boundary_ids=( + identity_manifold.boundary_ids | self.safety_pack.boundary_ids + ), + alignment_threshold=identity_manifold.alignment_threshold, + surface_preferences=identity_manifold.surface_preferences, + ) 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-0029-safety-packs.md b/docs/decisions/ADR-0029-safety-packs.md new file mode 100644 index 00000000..f92f34ac --- /dev/null +++ b/docs/decisions/ADR-0029-safety-packs.md @@ -0,0 +1,161 @@ +# ADR-0029: Safety Packs — Always-Loaded, Never-Replaceable Boundaries + +**Status:** Accepted (2026-05-17) +**Author:** Joshua Shay + planner pass +**Companion docs:** [`identity_packs.md`](../identity_packs.md), [`safety_packs.md`](../safety_packs.md), [`ADR-0027-identity-packs.md`](ADR-0027-identity-packs.md), [`ADR-0028-identity-surface-wiring.md`](ADR-0028-identity-surface-wiring.md) + +## Context + +ADR-0027 made the identity manifold swappable via packs. ADR-0028 made the swap visibly load-bearing at the surface. Both changes were necessary for downstream consumers (robotics, personalization, creative tools) who need to author their own identity profiles. But making identity swappable opens a question that the identity-pack ADRs explicitly deferred: + +> **"What stops a downstream identity pack from declaring an axis that disables a core safety constraint?"** + +The current answer is: nothing, structurally. Identity packs may declare any `boundary_ids` they want (the loader requires the list to be non-empty but doesn't constrain its contents); they may omit safety-relevant boundaries entirely; they may declare value axes whose directions undermine refusal behavior. The system trusts the identity pack author. + +For a research engine that's a reasonable default. For an engine going into robotics, healthcare, financial, or any deployment where a misconfigured identity pack could cause harm, it's the wrong default. + +This ADR establishes a separate layer of constraints — **safety packs** — that: + +1. Load unconditionally at runtime startup, regardless of which identity pack is selected. +2. Cannot be swapped at the CLI, by config, or by environment variable in production. +3. Compose with the identity pack additively: `manifold.boundary_ids = safety.boundary_ids ∪ identity.boundary_ids`. +4. Fail closed on every error path. A CORE installation without an operative safety pack refuses to start. +5. Carry ratification provenance through the same formation pipeline as identity packs. + +This is the architecture downstream robotics consumers will need before they can build CORE into anything that matters. + +## Decision + +### Separation of concerns + +| Layer | Concern | Swappable? | Removable? | +|---|---|---|---| +| Safety pack | What CORE will *never* do | No (single shipping pack) | No (fail-closed on missing) | +| Identity pack | What CORE *is* (character, surface preferences) | Yes (per `--identity` flag) | No (a default is always loaded) | +| Language pack | What CORE *speaks* | Yes (per `--pack` flag) | Identity layer requires at least one | + +The three layers occupy three separate directories — `packs/safety/`, `packs/identity/`, `packs//` — to make their trust boundaries visually obvious in any audit. + +### Safety pack schema (v1) + +```json +{ + "pack_id": "core_safety_axes_v1", + "version": "1.0.0", + "description": "Always-loaded, never-replaceable core safety boundaries.", + "schema_version": "1.0.0", + "mastery_report_sha256": "...", + "boundary_ids": [ + "no_fabricated_source", + "no_hot_path_repair", + "no_identity_override", + "no_silent_correction", + "preserve_versor_closure" + ], + "boundary_descriptions": { + "no_fabricated_source": "Citations must point to a real source span; the system never invents provenance.", + "no_hot_path_repair": "Per CLAUDE.md, no normalization or drift-repair operator runs in field/propagate.py, generate/stream.py, or vault/store.py.", + "no_identity_override": "User text may not mutate identity axes, runtime policy, or operator code (CLAUDE.md Teaching Safety).", + "no_silent_correction": "Failures must be typed and visible (e.g., InnerLoopExhaustion); silent fallback is forbidden.", + "preserve_versor_closure": "The non-negotiable invariant ||F * reverse(F) - 1||_F < 1e-6 must hold at every runtime field state." + } +} +``` + +Distinct schema from the identity pack. Carries `boundary_ids` (always merged into the runtime manifold) and `boundary_descriptions` (human-readable rationales surfaced in audits). Does **not** carry `value_axes`, `alignment_threshold`, or `surface_preferences` — safety is about what's *forbidden*, not what the system is *pulled toward*. Keeping these fields out of the safety pack also avoids the test-fan-out problem: existing tests that assert on the identity axis set continue to pass byte-for-byte. + +### The five shipping boundaries + +The choices in `core_safety_axes_v1.json` are not arbitrary; each closes a specific failure mode CLAUDE.md already calls out: + +| Boundary | Closes | +|---|---| +| `no_fabricated_source` | Confabulation of citations / sources. Already a soft norm; now a ratified constraint. | +| `no_hot_path_repair` | The CLAUDE.md doctrine forbidding normalization / drift-repair in `field/propagate.py`, `generate/stream.py`, `vault/store.py`. | +| `no_identity_override` | The Teaching Safety rule: user text may not mutate identity axes, runtime policy, or operator code. | +| `no_silent_correction` | Silent fallback in admissibility / refusal paths. ADR-0024's `InnerLoopExhaustion` is the model — typed and visible. | +| `preserve_versor_closure` | The non-negotiable algebraic invariant. | + +The list is **closed** at v1. Adding boundaries requires a new pack version (`core_safety_axes_v2`) and re-ratification. Removing boundaries from a future version would require an explicit ADR justifying the removal. + +### Composition rule + +At `ChatRuntime` startup: + +``` +1. identity_manifold ← load_identity_manifold(config.identity_pack or DEFAULT_IDENTITY_PACK) +2. safety_pack ← load_safety_pack() # fail-closed +3. final_manifold ← IdentityManifold( + value_axes=identity_manifold.value_axes, # safety contributes none + boundary_ids=identity_manifold.boundary_ids + ∪ safety_pack.boundary_ids, + alignment_threshold=identity_manifold.alignment_threshold, + surface_preferences=identity_manifold.surface_preferences, + ) +``` + +Safety boundaries are *additive only* — set union, not replace. If a (hypothetical malicious) identity pack omits or contradicts a safety boundary, the contradiction never reaches the runtime: the union is computed at composition time and the safety boundary is always present. + +### Fail-closed semantics + +The safety pack loader (`packs.safety.loader.load_safety_pack`) raises `SafetyPackError` — which inherits from `RuntimeError` rather than `ValueError` — on every error path: + +- Missing pack file. +- Malformed JSON. +- Empty `boundary_ids`. +- Duplicate boundary id. +- Path-traversal pack id. +- (Production mode) `mastery_report_sha256` empty, companion file missing, SHA mismatch, or self-seal verification fails. + +`ChatRuntime.__init__` does not catch `SafetyPackError`. A CORE installation without an operative safety pack refuses to start. + +The escape hatch `CORE_ALLOW_UNRATIFIED_SAFETY=1` exists for development of the safety pack itself (when authoring a new pack and the ratification step hasn't run yet). It bypasses **only** the seal-verification check; missing-file / empty-boundaries / malformed-JSON failures still fail closed. The env var deliberately mirrors `CORE_ALLOW_UNRATIFIED_IDENTITY` for consistency, but a separate variable is used so that loosening identity ratification cannot accidentally loosen safety ratification. + +### Ratification path + +Safety packs ratify through the existing `identity_anchor` template (no new template required). The ratification driver (`scripts/ratify_safety_pack.py`) expresses each boundary as a `ConceptCandidate` whose canonical term is the boundary id and whose definition is the boundary description. Three canned `CounterCandidate` rows act as override probes (counters targeting boundaries: context-pressure, operator-override-request, performance-optimization). The template's existing six gates plus the two paradigm-specific gates (`every_axis_seeded_at_least_once`, `every_override_rejected`) cover ratification. + +The script is idempotent, parallel to `scripts/ratify_identity_packs.py`. Re-running on an unchanged safety pack is a no-op. + +### CLI surface + +No new CLI flag. `core chat --identity X` continues to select the identity pack; the safety pack is always loaded alongside. `core chat --list-identity-packs` reports identity packs only (the safety pack is at a different path with a different schema and is intentionally not part of that listing — there's nothing to *select* among safety packs). A future `core chat --show-safety-pack` could surface the loaded safety pack's boundaries and description for audit; that's a small follow-up, not part of this ADR. + +## Consequences + +### Positive + +- **Robotics, healthcare, and other high-stakes deployments can adopt CORE** without each project hand-rolling boundary enforcement. The five v1 boundaries are a defensible baseline. +- **The trust boundary is visually obvious.** `packs/safety/` is one directory; modifying it requires editing the pack, re-running the ratification script, and updating tests. Casual edits are caught. +- **Provenance for the boundaries.** Each shipping safety pack carries a self-sealed `MasteryReport` proving the boundaries went through the same gates as identity packs. The `mastery_report_sha256` for `core_safety_axes_v1` is recorded below. +- **Existing tests stay green.** Because the safety pack contributes only `boundary_ids` and not `value_axes`, every test that asserts on the identity axis set (`{"truthfulness", "coherence", "reverence"}` for the default pack) continues to pass byte-for-byte. +- **Composition is set union, not replace.** Identity packs that want to add *more* boundaries on top can do so freely — safety boundaries remain untouchable. + +### Negative / risks + +- **Yet another schema to maintain.** v1 is small (five boundaries, descriptions, ratification fields) but it's another point of evolution. Versioning policy: bump major when removing boundaries; bump minor when adding boundaries; bump patch only for description text edits. Removing a boundary requires a new ADR. +- **Safety pack is "invisible" to many tests.** Most tests construct `IdentityManifold` directly with hardcoded boundary sets and don't exercise the safety-pack composition path. That's fine for unit-level tests but does mean the composition rule itself is only exercised in `tests/test_safety_pack.py::TestRuntimeComposition`. The test class explicitly walks all three identity packs to keep that coverage honest. +- **The escape hatch exists.** `CORE_ALLOW_UNRATIFIED_SAFETY=1` exists for development. Production deployments must ensure this env var is never set; this is operational discipline, not enforced by code. A future ADR could remove the escape hatch entirely once the formation-pipeline driver is stable enough that no safety pack ever ships unratified. +- **No safety pack swap means no per-deployment safety variation.** A robotics deployment that needs a strictly stricter safety pack must edit `packs/safety/core_safety_axes_v1.json` (or bump to a new version) — there's no `--safety-pack` flag. This is intentional: a runtime that lets you swap the safety layer is not a safety layer. Per-deployment variation is allowed by re-ratifying a custom safety pack in that deployment's `packs/safety/` directory. + +### Scope limits (explicit non-goals for this ADR) + +- **No surface-side differentiation by safety axis.** The safety pack doesn't contribute to phrasing the way ADR-0028 surface preferences do. A future surface concern (e.g., "if `no_silent_correction` would be violated, refusal text should be explicit") is out of scope here. +- **No safety scoring.** `IdentityCheck` checks alignment against value axes. There is no parallel `SafetyCheck` against boundary ids — boundaries are checked elsewhere in the pipeline (refusal paths, allowlist enforcement, etc.). Wiring a structural safety-score surface would be valuable but is a separate ADR. +- **No multi-tenant safety packs.** Each CORE installation has exactly one safety pack at any given time. Production deployments running multiple identity profiles (e.g., a hosted multi-tenant CORE) cannot have per-tenant safety packs without ADR-level architectural changes. +- **No human-in-the-loop safety pack updates.** The ratification path is automated. Future deployments may require a code-review gate on every safety pack change; for now, the operational discipline is "edit, re-ratify, commit, review the PR like any other change." + +## Verification + +This ADR is satisfied when: + +- `packs/safety/core_safety_axes_v1.json` exists with a non-empty `mastery_report_sha256` and a verifying companion `.mastery_report.json`. +- `ChatRuntime` startup loads the safety pack via `packs.safety.loader.load_safety_pack()` and unions the result into the runtime manifold's `boundary_ids`. +- Tests verify: shipping pack loads in production mode; missing file fails closed; tampered seal fails closed; empty boundaries fail closed; duplicate boundary fails closed; all three identity packs (default / precision / generosity) compose with the safety pack to produce a manifold containing the five safety boundaries; precision_first's `no_overstatement` boundary survives composition. +- The cognition, teaching, runtime, formation, and smoke suites are green at the same revision. + +### Shipping pack SHA (2026-05-17) + +`core_safety_axes_v1` → `ee1249acdf8c273aeb656d803c37ef915e536d85f177f5cc18c6e2f6c995ce29` + +Re-running `python scripts/ratify_safety_pack.py` on an unchanged pack is idempotent; re-running after editing the boundary set produces a new SHA which must be committed alongside. diff --git a/docs/safety_packs.md b/docs/safety_packs.md new file mode 100644 index 00000000..fee7049f --- /dev/null +++ b/docs/safety_packs.md @@ -0,0 +1,154 @@ +# Safety Packs — Reference + +**Status:** Operational reference. Update when pack format, loader contract, or composition rules change. +**Last updated:** 2026-05-17 +**Companion docs:** [`decisions/ADR-0029-safety-packs.md`](decisions/ADR-0029-safety-packs.md), [`identity_packs.md`](identity_packs.md) + +## What a safety pack is + +A safety pack carries the boundaries CORE will **never** cross, regardless of which identity pack is selected. Where identity packs encode *who* CORE is, safety packs encode *what CORE will not do*. The two layers compose at runtime: `manifold.boundary_ids = safety.boundary_ids ∪ identity.boundary_ids`. + +Three properties distinguish safety packs from identity packs: + +| Property | Identity pack | Safety pack | +|---|---|---| +| Swappable at runtime | Yes (`--identity X`) | **No** | +| Multiple packs available | Yes | **Exactly one** | +| Failure to load | Falls back to default; warns | **Fail-closed; refuses startup** | +| Schema | `value_axes`, `surface_preferences`, etc. | `boundary_ids`, `boundary_descriptions` | +| Directory | `packs/identity/` | `packs/safety/` | + +## Shipping safety pack (v1) + +| Pack id | Description | Ratified | +|---|---|---| +| `core_safety_axes_v1` | Always-loaded core boundaries: no fabricated source, no hot-path repair, no identity override, no silent correction, preserve versor closure. | `ee1249acdf8c273aeb656d803c37ef915e536d85f177f5cc18c6e2f6c995ce29` | + +## Pack format (v1) + +```json +{ + "pack_id": "core_safety_axes_v1", + "version": "1.0.0", + "description": "Always-loaded, never-replaceable core safety boundaries.", + "schema_version": "1.0.0", + "mastery_report_sha256": "...", + "boundary_ids": [ + "no_fabricated_source", + "no_hot_path_repair", + "no_identity_override", + "no_silent_correction", + "preserve_versor_closure" + ], + "boundary_descriptions": { + "no_fabricated_source": "Citations must point to a real source span; the system never invents provenance.", + "no_hot_path_repair": "...", + "no_identity_override": "...", + "no_silent_correction": "...", + "preserve_versor_closure": "..." + } +} +``` + +### Field semantics + +| Field | Required | Meaning | +|---|---|---| +| `pack_id` | yes | Pack identifier. Convention: `_v`. | +| `version` | yes | Semver. | +| `description` | yes | Human-facing one-liner. | +| `schema_version` | yes | Currently `"1.0.0"`. | +| `mastery_report_sha256` | yes (production) | SHA of the companion `.mastery_report.json`. Empty only in development; production refuses. | +| `boundary_ids` | yes | Non-empty list of unique boundary identifier strings. | +| `boundary_descriptions` | yes | Dict mapping each `boundary_id` to a human-readable rationale. | + +### Loader bounds (enforced) + +- `boundary_ids` must be a non-empty list of unique non-empty strings. +- `pack_id` must not contain `/` or `..`. +- `schema_version` must equal `"1.0.0"`. +- In production mode (default), `mastery_report_sha256` must be non-empty, the companion report must exist, its `report_sha256` must match, and its self-seal must verify via `formation.hashing.verify_seal`. + +## Loader contract + +```python +from packs.safety.loader import load_safety_pack, SafetyPackError, DEFAULT_SAFETY_PACK + +pack = load_safety_pack( + pack_id=DEFAULT_SAFETY_PACK, # default — callers should rarely pass anything else + search_paths=None, # default: ["./packs/safety"] + require_ratified=True, # production default +) +``` + +Returns a `SafetyPack` (frozen dataclass) with fields `pack_id`, `version`, `description`, `boundary_ids` (frozenset), `boundary_descriptions` (dict), `mastery_report_sha256`, `ratified`. + +`SafetyPackError` inherits from `RuntimeError`, not `ValueError`. Missing safety pack is a fail-closed runtime condition, not a recoverable input error. Do not catch and continue. + +### Development override + +```bash +CORE_ALLOW_UNRATIFIED_SAFETY=1 python -m core.cli chat +``` + +Bypasses **only** the seal-verification check. Missing file / empty boundaries / malformed JSON still fail closed. Use only while authoring or editing the safety pack; never set in production. + +## Composition rule + +At `ChatRuntime` startup: + +```python +identity_manifold = load_identity_manifold(config.identity_pack or DEFAULT_IDENTITY_PACK) +safety_pack = load_safety_pack() # fail-closed +final_manifold = IdentityManifold( + value_axes = identity_manifold.value_axes, + boundary_ids = identity_manifold.boundary_ids | safety_pack.boundary_ids, + alignment_threshold = identity_manifold.alignment_threshold, + surface_preferences = identity_manifold.surface_preferences, +) +``` + +Safety contributes boundaries only. Identity contributes axes, threshold, surface preferences, and may add further boundaries. The runtime exposes the loaded safety pack as `ChatRuntime.safety_pack` for audit. + +## Authoring a new safety pack + +A safety pack is unique to a deployment. The shipping default is `core_safety_axes_v1`; downstream deployments may author their own stricter pack and place it at `packs/safety/.json`. + +1. Author the pack JSON. List the boundary ids your deployment requires; supply descriptions explaining each. +2. Run `python scripts/ratify_safety_pack.py` (idempotent). Produces the companion `.mastery_report.json` and embeds the SHA in the pack. +3. **Test it under fail-closed semantics.** Run `python -m pytest tests/test_safety_pack.py` and verify all 15 tests pass. +4. **Commit both files** (`.json` and `.mastery_report.json`) atomically. + +### Anti-patterns + +- **Don't catch `SafetyPackError`.** A missing safety pack should crash the runtime, not silently degrade. The exception class deliberately doesn't inherit from `ValueError`. +- **Don't carry value axes in a safety pack.** Safety boundaries are not directional preferences. If you find yourself wanting axes, you want an identity pack. +- **Don't make boundary text user-facing without curation.** `boundary_descriptions` is for audit and operator visibility, not end-user prose. +- **Don't ship multiple safety packs.** The design is "exactly one shipping safety pack per CORE installation." Per-tenant safety packs are an architectural change requiring a future ADR. + +## Versioning policy + +| Change | Version bump | +|---|---| +| Description text edits | Patch (`v1.0.0` → `v1.0.1`) | +| Adding a boundary | Minor (`v1.0.0` → `v1.1.0`) | +| Removing a boundary | **Major + new ADR justifying the removal** (`core_safety_axes_v2`) | +| Schema format change | Major + new `schema_version` | + +A new major version means a new `pack_id`. The old pack remains in the repo for replay and audit; the runtime loads whichever pack id is shipped (currently hardcoded in `packs.safety.loader.DEFAULT_SAFETY_PACK`). + +## Known limits + +1. **No `SafetyCheck` parallel to `IdentityCheck`.** Boundaries are enforced elsewhere in the pipeline (refusal paths, allowlist enforcement). A future structural safety-score surface would be valuable but isn't in scope here. +2. **No per-tenant safety packs.** Multi-tenant CORE deployments share one safety pack. +3. **No human-in-the-loop ratification step.** Operational discipline lives in PR review, not the code. +4. **English-only boundary descriptions** at v1. + +## Cross-reference index + +- Pack format spec: this doc §"Pack format (v1)". +- Loader contract: this doc §"Loader contract". +- Decision record: [ADR-0029](decisions/ADR-0029-safety-packs.md). +- Identity pack composition: [`identity_packs.md`](identity_packs.md). +- Trust-boundary doctrine: [`runtime_contracts.md`](runtime_contracts.md), CLAUDE.md "Security and Trust Boundaries". +- The formation template used for ratification: `formation/templates/identity_anchor.py`. diff --git a/packs/safety/__init__.py b/packs/safety/__init__.py new file mode 100644 index 00000000..5d752b28 --- /dev/null +++ b/packs/safety/__init__.py @@ -0,0 +1,27 @@ +"""Safety-pack loader. + +Reads the single shipping safety pack and returns its boundary set for +composition into the runtime ``IdentityManifold``. Safety packs are +**not** swappable: there is exactly one safety pack per installation, +loaded unconditionally. + +The loader fails closed. Missing file, malformed JSON, empty +``boundary_ids``, or — in production mode — unverified self-seal all +cause ``SafetyPackError`` and prevent ``ChatRuntime`` startup. + +See ``docs/decisions/ADR-0029-safety-packs.md``. +""" + +from packs.safety.loader import ( + DEFAULT_SAFETY_PACK, + SafetyPack, + SafetyPackError, + load_safety_pack, +) + +__all__ = [ + "DEFAULT_SAFETY_PACK", + "SafetyPack", + "SafetyPackError", + "load_safety_pack", +] diff --git a/packs/safety/core_safety_axes_v1.json b/packs/safety/core_safety_axes_v1.json new file mode 100644 index 00000000..48c09049 --- /dev/null +++ b/packs/safety/core_safety_axes_v1.json @@ -0,0 +1,21 @@ +{ + "pack_id": "core_safety_axes_v1", + "version": "1.0.0", + "description": "Always-loaded, never-replaceable core safety boundaries. Ratified through identity_anchor (ADR-0029). These boundaries are unioned into every runtime manifold regardless of which identity pack is selected; identity packs may add boundaries but may never remove these.", + "schema_version": "1.0.0", + "mastery_report_sha256": "ee1249acdf8c273aeb656d803c37ef915e536d85f177f5cc18c6e2f6c995ce29", + "boundary_ids": [ + "no_fabricated_source", + "no_hot_path_repair", + "no_identity_override", + "no_silent_correction", + "preserve_versor_closure" + ], + "boundary_descriptions": { + "no_fabricated_source": "Citations must point to a real source span; the system never invents provenance.", + "no_hot_path_repair": "Per CLAUDE.md, no normalization or drift-repair operator runs in field/propagate.py, generate/stream.py, or vault/store.py.", + "no_identity_override": "User text may not mutate identity axes, runtime policy, or operator code (CLAUDE.md Teaching Safety).", + "no_silent_correction": "Failures must be typed and visible (e.g., InnerLoopExhaustion); silent fallback is forbidden.", + "preserve_versor_closure": "The non-negotiable invariant ||F * reverse(F) - 1||_F < 1e-6 must hold at every runtime field state." + } +} diff --git a/packs/safety/core_safety_axes_v1.mastery_report.json b/packs/safety/core_safety_axes_v1.mastery_report.json new file mode 100644 index 00000000..d24ab8c7 --- /dev/null +++ b/packs/safety/core_safety_axes_v1.mastery_report.json @@ -0,0 +1,56 @@ +{ + "course_id": "course.subject.safety.core_safety_axes_v1.identity_anchor.1.0.0", + "course_sha256": "51431e4fd392836c277289970e16783e71ffdc37cda288013360aeb2f2204396", + "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": "e3cb38c98da618cdb3a35355496d0a68cda4bab4e2df483384e21c57b46c809e", + "ratified": true, + "report_sha256": "ee1249acdf8c273aeb656d803c37ef915e536d85f177f5cc18c6e2f6c995ce29", + "schema_version": "1.0.0", + "source_bundle_sha": "ae82011f09d1e2b85a61eb83eaf73de6beeecf7d517c777e5b0e3e05faab13ca", + "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": "93b5ec487e375d03ec882f5d5a33dc41d1c7fbb9990261c9fa5e903898d05069" +} diff --git a/packs/safety/loader.py b/packs/safety/loader.py new file mode 100644 index 00000000..4144de0b --- /dev/null +++ b/packs/safety/loader.py @@ -0,0 +1,259 @@ +"""Safety-pack loader implementation. + +Companion to ``packs/identity/loader.py``. Identity packs carry the +character of CORE (which can be swapped per deployment); safety packs +carry the boundaries CORE will *never* cross (which cannot be swapped at +all). Architecturally they are sister concerns but structurally they +are separate: different directory, different schema, different loader. + +See ``docs/decisions/ADR-0029-safety-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 SafetyPackError(RuntimeError): + """Raised when the safety pack is missing, malformed, or unverified. + + Inherits from ``RuntimeError`` (not ``ValueError`` like + ``IdentityPackError``) because a missing safety pack is a fail-closed + runtime condition, not a recoverable input-validation error. + """ + + +DEFAULT_SAFETY_PACK: str = "core_safety_axes_v1" +_DEFAULT_SEARCH_PATHS: tuple[Path, ...] = ( + Path(__file__).resolve().parent, +) + + +@dataclass(frozen=True, slots=True) +class SafetyPack: + """Loaded safety pack. + + ``boundary_ids`` is the set of constraints to be unioned into the + runtime ``IdentityManifold.boundary_ids``. Identity packs may add + boundaries on top, but the loader composition step (see + ``chat/runtime.py``) ensures these are always present. + """ + + pack_id: str + version: str + description: str + boundary_ids: FrozenSet[str] + boundary_descriptions: dict[str, str] + mastery_report_sha256: str + ratified: bool + + +def load_safety_pack( + pack_id: str = DEFAULT_SAFETY_PACK, + *, + search_paths: Iterable[Path | str] | None = None, + require_ratified: bool | None = None, +) -> SafetyPack: + """Load the safety pack. Fails closed on any error. + + Args: + pack_id: Safety pack identifier. Defaults to + ``DEFAULT_SAFETY_PACK``. Callers should not pass anything + else in production; the argument exists for testing. + search_paths: Directories to search. Defaults to + ``packs/safety/``. + require_ratified: When ``True``, require the companion + ``.mastery_report.json`` and verify its self-seal. + ``None`` (default) → production mode unless + ``CORE_ALLOW_UNRATIFIED_SAFETY=1`` is set. ``False`` → + never require ratification (tests only). + + Raises: + SafetyPackError: On any failure. Callers must not catch and + continue — a CORE installation without an operative safety + pack must refuse to start. + """ + 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) + boundaries = _validate_boundaries(raw["boundary_ids"], pack_id) + descriptions = _validate_descriptions( + raw.get("boundary_descriptions", {}), pack_id, boundaries, + ) + return SafetyPack( + pack_id=str(raw["pack_id"]), + version=str(raw["version"]), + description=str(raw["description"]), + boundary_ids=frozenset(boundaries), + boundary_descriptions=descriptions, + mastery_report_sha256=str(raw.get("mastery_report_sha256", "")), + ratified=bool(raw.get("mastery_report_sha256")), + ) + + +# ---------- 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 SafetyPackError(f"invalid safety pack_id: {pack_id!r}") + for d in paths: + candidate = d / f"{pack_id}.json" + if candidate.is_file(): + return candidate + raise SafetyPackError( + f"safety pack {pack_id!r} not found in " + f"{[str(p) for p in paths]} — refusing to start without an " + "operative safety pack" + ) + + +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 SafetyPackError( + f"failed to read safety pack {path}: {exc}" + ) from exc + if not isinstance(data, dict): + raise SafetyPackError( + f"safety 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", "boundary_ids") + missing = [k for k in required if k not in raw] + if missing: + raise SafetyPackError( + f"safety pack {pack_id!r} missing required fields: {missing}" + ) + if raw.get("schema_version") != "1.0.0": + raise SafetyPackError( + f"safety pack {pack_id!r}: unsupported schema_version " + f"{raw.get('schema_version')!r}" + ) + if raw.get("pack_id") != pack_id: + raise SafetyPackError( + f"safety 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_SAFETY") != "1" + ) + if not require_ratified: + return + declared_sha = raw.get("mastery_report_sha256", "") + if not declared_sha: + raise SafetyPackError( + f"safety pack {pack_id!r} is not ratified " + "(mastery_report_sha256 empty); production refuses unratified " + "safety packs. Run scripts/ratify_safety_pack.py." + ) + report_path = pack_path.parent / f"{pack_id}.mastery_report.json" + if not report_path.is_file(): + raise SafetyPackError( + f"safety 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 SafetyPackError( + f"safety pack {pack_id!r}: failed to read companion report: {exc}" + ) from exc + if not isinstance(report, dict): + raise SafetyPackError( + f"safety pack {pack_id!r}: companion report is not a JSON object" + ) + if report.get("report_sha256") != declared_sha: + raise SafetyPackError( + f"safety 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 SafetyPackError( + f"safety pack {pack_id!r}: companion report failed self-seal " + "verification" + ) + if not report.get("ratified", False): + raise SafetyPackError( + f"safety pack {pack_id!r}: companion report has ratified=False" + ) + + +def _validate_boundaries(value: object, pack_id: str) -> list[str]: + if not isinstance(value, list) or len(value) < 1: + raise SafetyPackError( + f"safety pack {pack_id!r}: boundary_ids must be a non-empty list" + ) + seen: set[str] = set() + out: list[str] = [] + for i, b in enumerate(value): + if not isinstance(b, str) or not b: + raise SafetyPackError( + f"safety pack {pack_id!r}: boundary_ids[{i}] must be a " + "non-empty string" + ) + if b in seen: + raise SafetyPackError( + f"safety pack {pack_id!r}: duplicate boundary_id {b!r}" + ) + seen.add(b) + out.append(b) + return out + + +def _validate_descriptions( + value: object, pack_id: str, boundaries: list[str], +) -> dict[str, str]: + if not isinstance(value, dict): + raise SafetyPackError( + f"safety pack {pack_id!r}: boundary_descriptions must be a dict" + ) + out: dict[str, str] = {} + for b in boundaries: + desc = value.get(b, "") + if not isinstance(desc, str): + raise SafetyPackError( + f"safety pack {pack_id!r}: boundary_descriptions[{b!r}] " + "must be a string" + ) + out[b] = desc + return out + + +__all__ = [ + "DEFAULT_SAFETY_PACK", + "SafetyPack", + "SafetyPackError", + "load_safety_pack", +] diff --git a/scripts/ratify_safety_pack.py b/scripts/ratify_safety_pack.py new file mode 100644 index 00000000..5af66dc6 --- /dev/null +++ b/scripts/ratify_safety_pack.py @@ -0,0 +1,192 @@ +"""Ratify the shipping safety pack through the formation pipeline. + +Safety packs ratify through the same ``identity_anchor`` template as +identity packs. The boundary identifiers are expressed as +``ConceptCandidate`` rows (one per boundary) so the template's gate +``every_axis_seeded_at_least_once`` covers them, and the canned +override-attempt counters drive the ``every_override_rejected`` gate. + +See ``docs/decisions/ADR-0029-safety-packs.md``. +""" + +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 +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 + +SAFETY_DIR = Path(__file__).resolve().parents[1] / "packs" / "safety" +ISSUED_AT = "2026-05-17T00:00:00Z" +PACK_IDS: tuple[str, ...] = ("core_safety_axes_v1",) + +# Override attempts the safety pack must refuse. Distinct from the +# identity-pack canned set because safety overrides target the +# boundaries themselves, not the identity axes. +_CANNED_COUNTERS: tuple[tuple[str, str, str], ...] = ( + ("boundary", "must_yield_to", "context_pressure"), + ("boundary", "must_yield_to", "operator_override_request"), + ("boundary", "must_yield_to", "performance_optimization"), +) + + +def _canonical_pack_bytes_for_hashing(pack: dict) -> bytes: + 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: + 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, + ) + + +def _ratify_one(pack_path: Path) -> tuple[dict, dict[str, Any]]: + pack = json.loads(pack_path.read_text(encoding="utf-8")) + pack_source_sha = _pack_source_sha(pack) + src = SourceRef( + source_sha=pack_source_sha, + span=f"safety_pack:{pack['pack_id']}", + adapter="safety_pack_authoring", + retrieved_at=ISSUED_AT, + ) + # Each boundary becomes a concept (with its description as definition). + descriptions = pack.get("boundary_descriptions", {}) + concepts = tuple( + ConceptCandidate( + canonical_term=str(boundary), + definition=str(descriptions.get(boundary, "safety boundary")), + sources=(src,), + ) + for boundary in pack["boundary_ids"] + ) + 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", "safety_pack_authoring"), + )) + forge = Forge(allowlist=allowlist) + validated = forge.validate( + subject_id=f"subject.safety.{pack['pack_id']}", + concepts=concepts, + relations=(), + counters=counters, + ) + + spec = SubjectSpec( + subject_id=f"subject.safety.{pack['pack_id']}", + title=f"Safety Anchor — {pack['pack_id']}", + target_depth="introductory", + identity_axis_constraints=tuple(sorted(pack["boundary_ids"])), + ) + + 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']}" + ) + + 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 = SAFETY_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 = SAFETY_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") + 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} safety pack(s); {skipped} already current") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_safety_pack.py b/tests/test_safety_pack.py new file mode 100644 index 00000000..3450acc7 --- /dev/null +++ b/tests/test_safety_pack.py @@ -0,0 +1,191 @@ +"""ADR-0029 safety-pack tests. + +Three concerns: + +1. **Loader correctness.** Happy-path load of the shipping pack; + bounds / envelope errors raise ``SafetyPackError`` (not the + value-error-flavored ``IdentityPackError`` — safety failures are + fail-closed runtime conditions). +2. **Fail-closed semantics.** A missing safety pack, tampered companion + report, or unverified seal must prevent ``ChatRuntime`` startup. +3. **Composition.** The runtime ``IdentityManifold.boundary_ids`` is the + union of identity-pack boundaries and safety-pack boundaries, every + safety boundary is present regardless of which identity pack is + selected, and identity-pack boundary additions do not remove safety + boundaries. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from chat.runtime import ChatRuntime +from core.config import RuntimeConfig +from packs.safety.loader import ( + DEFAULT_SAFETY_PACK, + SafetyPackError, + load_safety_pack, +) + +SAFETY_BOUNDARIES: frozenset[str] = frozenset({ + "no_fabricated_source", + "no_hot_path_repair", + "no_identity_override", + "no_silent_correction", + "preserve_versor_closure", +}) + + +# ---------- loader ---------- + + +class TestLoader: + def test_loads_shipping_pack(self) -> None: + pack = load_safety_pack() + assert pack.pack_id == DEFAULT_SAFETY_PACK + assert pack.boundary_ids == SAFETY_BOUNDARIES + assert pack.ratified is True + # Descriptions exist for every boundary. + for b in SAFETY_BOUNDARIES: + assert pack.boundary_descriptions[b] + + def test_loads_in_production_mode_by_default(self) -> None: + # Loader's default require_ratified=None resolves to production + # mode unless CORE_ALLOW_UNRATIFIED_SAFETY=1 is set. Shipping + # pack is ratified so it loads cleanly. + pack = load_safety_pack() + assert pack.ratified is True + + def test_missing_pack_fails_closed(self, tmp_path: Path) -> None: + with pytest.raises(SafetyPackError, match="not found"): + load_safety_pack(search_paths=[tmp_path]) + + def test_path_traversal_rejected(self) -> None: + with pytest.raises(SafetyPackError, match="invalid safety pack_id"): + load_safety_pack(pack_id="../../etc/passwd") + + def test_unratified_pack_refused_in_production( + self, tmp_path: Path, + ) -> None: + bad = _write_pack(tmp_path, mastery_report_sha256="") + with pytest.raises(SafetyPackError, match="not ratified"): + load_safety_pack( + pack_id=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, mastery_report_sha256="0" * 64) + with pytest.raises(SafetyPackError, match="companion report"): + load_safety_pack( + pack_id=bad["pack_id"], + search_paths=[tmp_path], + require_ratified=True, + ) + + def test_companion_seal_failure_refused(self, tmp_path: Path) -> None: + bogus = "d" * 64 + bad = _write_pack(tmp_path, mastery_report_sha256=bogus) + report_path = tmp_path / f"{bad['pack_id']}.mastery_report.json" + report_path.write_text( + json.dumps({"report_sha256": bogus, "ratified": True, "x": 1}), + encoding="utf-8", + ) + with pytest.raises(SafetyPackError, match="self-seal"): + load_safety_pack( + pack_id=bad["pack_id"], + search_paths=[tmp_path], + require_ratified=True, + ) + + def test_empty_boundaries_refused(self, tmp_path: Path) -> None: + bad = _write_pack(tmp_path, boundary_ids=[]) + with pytest.raises(SafetyPackError, match="boundary_ids"): + load_safety_pack( + pack_id=bad["pack_id"], + search_paths=[tmp_path], + require_ratified=False, + ) + + def test_duplicate_boundary_refused(self, tmp_path: Path) -> None: + bad = _write_pack( + tmp_path, + boundary_ids=["a", "b", "a"], + ) + with pytest.raises(SafetyPackError, match="duplicate"): + load_safety_pack( + pack_id=bad["pack_id"], + search_paths=[tmp_path], + require_ratified=False, + ) + + +# ---------- runtime composition ---------- + + +class TestRuntimeComposition: + def test_default_runtime_has_safety_pack(self) -> None: + rt = ChatRuntime(config=RuntimeConfig()) + assert rt.safety_pack.pack_id == DEFAULT_SAFETY_PACK + assert rt.safety_pack.boundary_ids == SAFETY_BOUNDARIES + + def test_safety_boundaries_present_under_default_identity(self) -> None: + rt = ChatRuntime(config=RuntimeConfig()) + assert SAFETY_BOUNDARIES <= rt.identity_manifold.boundary_ids + + def test_safety_boundaries_present_under_precision_identity(self) -> None: + rt = ChatRuntime( + config=RuntimeConfig(identity_pack="precision_first_v1"), + ) + assert SAFETY_BOUNDARIES <= rt.identity_manifold.boundary_ids + + def test_safety_boundaries_present_under_generosity_identity(self) -> None: + rt = ChatRuntime( + config=RuntimeConfig(identity_pack="generosity_first_v1"), + ) + assert SAFETY_BOUNDARIES <= rt.identity_manifold.boundary_ids + + def test_precision_pack_adds_boundary_on_top(self) -> None: + # precision_first_v1 declares ``no_overstatement`` in its + # boundary_ids. After composition with safety pack, both + # safety boundaries AND ``no_overstatement`` must be present. + rt = ChatRuntime( + config=RuntimeConfig(identity_pack="precision_first_v1"), + ) + assert "no_overstatement" in rt.identity_manifold.boundary_ids + assert SAFETY_BOUNDARIES <= rt.identity_manifold.boundary_ids + + def test_identity_axes_unchanged_by_safety_pack(self) -> None: + # Safety pack does not contribute value_axes. Identity axis + # set is exactly what the identity pack declares. + rt = ChatRuntime(config=RuntimeConfig()) + axis_ids = {a.axis_id for a in rt.identity_manifold.value_axes} + assert axis_ids == {"truthfulness", "coherence", "reverence"} + + +# ---------- helpers ---------- + + +def _write_pack( + tmp_path: Path, + *, + pack_id: str = "test_safety_pack", + boundary_ids: list[str] | None = None, + 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, + "boundary_ids": ["test_boundary"] if boundary_ids is None else boundary_ids, + "boundary_descriptions": {}, + } + path = tmp_path / f"{pack_id}.json" + path.write_text(json.dumps(body), encoding="utf-8") + return body