feat(safety): ADR-0029 — always-loaded, never-replaceable safety pack

Closes the trust gap ADR-0027 opened: making the identity manifold
swappable was necessary for downstream robotics / personalization /
creative deployments, but it left nothing structurally preventing a
downstream identity pack from disabling core safety constraints.
Safety packs sit at a separate trust layer, fail closed on every error
path, and union their boundaries into every runtime manifold regardless
of which identity pack is selected.

Architecture (sibling to identity packs, structurally distinct):

  Layer            Swappable?  Removable?  Schema
  ---------------  ----------  ----------  -----------------------------
  Safety pack      No          No          boundary_ids + descriptions
  Identity pack    Yes         No          value_axes + surface_prefs
  Language pack    Yes         (>=1 reqd)  vocab / morphology / packs

Composition rule (at ChatRuntime startup, additive only):

  identity = load_identity_manifold(config.identity_pack)
  safety   = load_safety_pack()                        # fail-closed
  final.boundary_ids = identity.boundary_ids ∪ safety.boundary_ids

Safety contributes boundaries only — no value_axes, threshold, or
surface_preferences.  This keeps existing tests that assert on identity
axis sets passing byte-for-byte, and matches the semantic intent
(safety is what's forbidden, not what's pulled toward).

Shipping safety pack: packs/safety/core_safety_axes_v1.json
  → mastery_report_sha256 ee1249acdf8c273aeb656d803c37ef915e536d85f177f5cc18c6e2f6c995ce29

Five v1 boundaries, each closing a specific CLAUDE.md doctrine:
  no_fabricated_source       — no invented provenance
  no_hot_path_repair         — no normalization in propagate/stream/store
  no_identity_override       — user text cannot mutate identity
  no_silent_correction       — failures are typed and visible
  preserve_versor_closure    — ||F * reverse(F) - 1||_F < 1e-6

Fail-closed semantics:
  SafetyPackError inherits from RuntimeError (NOT ValueError) so
  catch-and-continue is discouraged at the type level.  Missing file /
  malformed JSON / empty boundaries / duplicate boundary / failed
  self-seal all raise.  ChatRuntime.__init__ does not catch.

Files:
  packs/safety/core_safety_axes_v1.json              shipping pack
  packs/safety/core_safety_axes_v1.mastery_report.json  signed report
  packs/safety/__init__.py                           public surface
  packs/safety/loader.py                             load_safety_pack(),
                                                     SafetyPack,
                                                     SafetyPackError,
                                                     DEFAULT_SAFETY_PACK
  scripts/ratify_safety_pack.py                      idempotent driver
  chat/runtime.py                                    composition wiring
  tests/test_safety_pack.py                          15 tests:
                                                       loader bounds,
                                                       fail-closed,
                                                       composition under
                                                       all 3 identity packs
  docs/decisions/ADR-0029-safety-packs.md            decision record
  docs/safety_packs.md                               operational ref
  README.md                                          §Safety Pack added
  memory/safety-pack.md                              auto-memory entry

Suite status: cognition 121, teaching 17, runtime 19, formation 182,
smoke 67, identity 41, safety 15 — all green.
This commit is contained in:
Shay 2026-05-17 19:56:29 -07:00
parent 7c839d2e12
commit ece73c76d5
10 changed files with 1085 additions and 1 deletions

View file

@ -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/`.

View file

@ -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.

View file

@ -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/<lang>/` — 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.

154
docs/safety_packs.md Normal file
View file

@ -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: `<slug>_v<major>`. |
| `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 `<pack_id>.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/<deployment_safety_id>.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** (`<pack_id>.json` and `<pack_id>.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`.

27
packs/safety/__init__.py Normal file
View file

@ -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",
]

View file

@ -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."
}
}

View file

@ -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"
}

259
packs/safety/loader.py Normal file
View file

@ -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
``<pack_id>.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",
]

View file

@ -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())

191
tests/test_safety_pack.py Normal file
View file

@ -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