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:
parent
7c839d2e12
commit
ece73c76d5
10 changed files with 1085 additions and 1 deletions
|
|
@ -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/`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
161
docs/decisions/ADR-0029-safety-packs.md
Normal file
161
docs/decisions/ADR-0029-safety-packs.md
Normal 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
154
docs/safety_packs.md
Normal 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
27
packs/safety/__init__.py
Normal 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",
|
||||
]
|
||||
21
packs/safety/core_safety_axes_v1.json
Normal file
21
packs/safety/core_safety_axes_v1.json
Normal 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."
|
||||
}
|
||||
}
|
||||
56
packs/safety/core_safety_axes_v1.mastery_report.json
Normal file
56
packs/safety/core_safety_axes_v1.mastery_report.json
Normal 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
259
packs/safety/loader.py
Normal 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",
|
||||
]
|
||||
192
scripts/ratify_safety_pack.py
Normal file
192
scripts/ratify_safety_pack.py
Normal 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
191
tests/test_safety_pack.py
Normal 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
|
||||
Loading…
Reference in a new issue