feat(anchor_lens): ADR-0073b — L1.2 class + loader + unanchored sentinel
L1.2 of the anchor-lens inside-out rollout — pack class, loader,
ratified sentinel pack, and runtime threading. Mirrors the
ADR-0068 register-class pattern exactly. No composer consumes the
lens yet — that's L1.3.
AnchorLens frozen dataclass (packs/anchor_lens/loader.py)
- lens_id / version / description / display_name
- primary_substrate ∈ {grc, he, en, none}
- semantic_domain_preferences: tuple[str, ...] (ordered, ≤64 atoms
of ≤64 chars each, no duplicates)
- cognitive_mode_label: str (≤64 chars)
- mastery_report_sha256
- is_unanchored() / is_null_lens() predicates
- unanchored() classmethod + module-level UNANCHORED singleton
Loader contract (mirror of packs/register/loader.py)
- safe_pack_id path-traversal rejection
- Schema validation + envelope bounds checks
- Companion mastery report self-seal + report_sha256 verification
- CORE_ALLOW_UNRATIFIED_ANCHOR_LENS=1 dev bypass
- require_ratified default True
- No truth-path imports (pinned by seam test)
default_unanchored_v1 ratified pack
- Null lens: primary_substrate="none", empty preferences,
empty cognitive_mode_label
- Self-sealed at b3235072fdbb2219...
- Ratification method: byte_identity_null_lift
- scripts/ratify_anchor_lens_packs.py L1.2 gate accepts only
null lenses; L1.3 will widen. Idempotent.
RuntimeConfig threading
- new field: anchor_lens_id: str | None = None
- new constant: DEFAULT_ANCHOR_LENS = "default_unanchored_v1"
- ChatRuntime.__init__ loads the lens (None → AnchorLens.
unanchored(); otherwise load_anchor_lens(id)) and stores as
self.anchor_lens + self.anchor_lens_id. Invalid ids fail-fast
at init via AnchorLensError, not at first turn.
- No composer reads the attribute yet.
Tests pinned (37 total)
- tests/test_anchor_lens_pack_loader.py (24) — load happy path,
sentinel structural identity, invalid id rejection (traversal,
empty, slashes, missing), ratification bypass paths, companion
SHA mismatch, bounds (substrate / preferences / atoms / label /
duplicates / capacity), field-missing, lens_id mismatch with
filename, unsupported schema_version.
- tests/test_anchor_lens_null_lift.py (4) — load-bearing L1.2
invariant `anchor_lens_byte_identity_null_lift`: full public
cognition lane byte-identical for surface, trace_hash, and
aggregate metrics between anchor_lens_id=None and
"default_unanchored_v1".
- tests/test_anchor_lens_pack_seam.py (9) — AST refuses any
`packs.anchor_lens` import from truth-path modules (cognition /
trace / pipeline / intent / propagation / vault / algebra) AND
refuses any truth-path import from the loader itself.
Lane evidence
- All 37 anchor-lens tests pass.
- python -m core.cli eval cognition → public 100/100/91.7/100
byte-identical (lens loaded but no composer reads it).
- core demo register-tour --json → all_claims_supported: True
(R5 seam still holds; L1.2 doesn't perturb register).
- Full lane: 2669 passed / 4 skipped / 1 pre-existing failure
(+37 over L1.1's 2632; the one failure remains
test_all_preamble_explains_combined_run, unrelated).
Trust boundaries (per CLAUDE.md / ADR-0051)
- safe_pack_id path-traversal rejection at loader entry.
- No dynamic imports.
- Loader is read-only; mutation only via ratify script.
- Seam test refuses any new anchor-lens import upstream of the
realizer. L1.3 will widen the allow-list to include composer
files at the same time it adds composer behaviour — exactly the
way the register seam was widened at R2.
What L1.2 deliberately does NOT do
- No composer consumes the lens (that's L1.3).
- No TurnEvent / ChatResponse telemetry fields (L1.4).
- No `core chat --anchor-lens` CLI flag (L1.4).
- No anchor-lens-tour demo (L1.4).