Introduces the presentation axis as a fourth pack class (sibling to identity /
safety / ethics), orthogonal to the truth path. Same input + same packs +
same register ⇒ bit-for-bit reproducible surface; varying any of the three ⇒
genuinely different output. No stochastic sampling.
ADR-0068 (R1): RegisterPack frozen dataclass, loader, ratify script, seam test.
- default_neutral_v1 ratified as null register.
ADR-0069 (R2): realizer register parameter threaded through 9 composer entry
points; RuntimeConfig.register_pack_id; three byte-identity invariants
(A: None ≡ pre-R2 unregistered; B: None ≡ default_neutral_v1; C: trace_hash
invariant under register). Amended to default-with-lint after 167-call-site
scout: composers default to UNREGISTERED, AST lint enforces explicit
register= at runtime call sites.
ADR-0070 (R3): terse_v1 register, first non-neutral pack. realizer_overrides
schema with known-keys allow-list (disclosure_domain_count ∈ {1,2,3}).
build_pack_surface_candidate reads override with fail-soft clamp. New
invariant register_invariant_grounding asserts grounding_source +
trace_hash byte-identical across {None, neutral, terse}.
ADR-0071 (R4): seeded surface variation via convivial_v1.
chat/register_variation.py applies SHA-256-seeded marker selection from
bounded discourse-marker buckets. ChatResponse.pre_decoration_surface routes
truth-path surface to core/cognition/pipeline.py so trace_hash stays
invariant under register (the load-bearing architectural fix — initially
invariant C failed under convivial because decoration was leaking into
trace_hash via response.surface). Empty-string marker entries now
legitimate ("no marker this turn" is a valid seed pick). realizer_overrides
schema widened with per_intent nested block (validated against IntentTag
whitelist; wired but not exercised by convivial). Two new invariants:
seeded_variation_replay_equivalence (fresh runtimes → byte-identical) and
seeded_variation_turn_distinct (same prompt across turns → ≥2 distinct
surfaces).
ADR-0072 (R5, draft): telemetry + operator surface — TurnEvent gains
register_id and register_variant_id, core chat --register flag, core demo
register-tour. Status: Proposed; not yet implemented.
Three ratified register packs ship: default_neutral_v1 (null), terse_v1
(disclosure_domain_count=1), convivial_v1 (3 openings × 3 closings).
Verification:
- 84 register tests pass + 1 documented skip
- Curated lanes green: smoke 67, cognition 120+1s, teaching 17, packs 6,
runtime 19, algebra 132
- Cognition eval byte-identical to pre-register baseline:
public 100/100/91.7/100, holdout 100/100/83.3/100
- Full lane: 2608 passed, 4 skipped, 1 failed (pre-existing
test_cli_demo.py "Combined Demo" → "Run Every Demo" rename, unrelated)
Truth-path isolation: chat/register_variation.py is realizer-side; the seam
test (tests/test_register_pack_seam.py) refuses imports of packs.register
from intent classification, propagation, vault recall, trace hashing, and
algebra.
14 KiB
ADR-0072 — Register telemetry + operator surface (Plan Phase R5)
Status: Proposed
Date: 2026-05-19
Author: Shay
Phase: Plan Phase R5 (operator-visible register)
Builds on: ADR-0068 (register pack class), ADR-0069 (realizer
register parameter), ADR-0070 (terse_v1), ADR-0071 (convivial_v1 +
seeded variation)
Context
R1–R4 built the register subsystem inside-out: pack class (R1), realizer wiring (R2), first non-neutral knob (R3), seeded variation (R4). At R4 the system can produce visibly different surfaces across turns while staying bit-for-bit reproducible, but the register is invisible to operators:
TurnEventcarriesgrounding_sourcebut notregister_id.- The audit JSONL stream has no field that distinguishes a neutral-register turn from a convivial-register turn that happens to have selected the empty closing.
- The CLI has no way to drive a register at all — every
core chatinvocation runs unregistered. - There is no narrative demo showing the seam holding under variation
the way
core demo audit-tourshows it holding under identity / safety / ethics.
R5 closes those gaps. The register-pack architecture becomes operator-observable, operator-driven, and demo-falsifiable. After R5, the presentation axis is feature-complete enough to compose against the substantive axis (Greek/Hebrew anchor lens) without further plumbing.
Decision
R5 ships three artifacts:
- Telemetry extension —
TurnEventgainsregister_idandregister_variant_id; the telemetry serializer surfaces them in every audit line. - Operator surface —
core chat --register <id>CLI flag wires intoRuntimeConfig.register_pack_id. - Narrative demo —
core demo register-tourwalks the same prompt sequence under {default_neutral_v1, terse_v1, convivial_v1} and prints a grid with per-cell(surface, grounding_source, trace_hash, register_id, register_variant_id). The demo asserts the load-bearing seam claim — grounding_source and trace_hash are byte-identical across registers, onlysurfacevaries — and exits non-zero if it fails.
Telemetry — TurnEvent shape extension
# core/physics/identity.py
@dataclass(frozen=True)
class TurnEvent:
...
grounding_source: str = "none"
# ADR-0072 (R5) — operator-visible register identity per turn.
register_id: str = ""
register_variant_id: str = ""
register_id: the loaded pack'sregister_id, or""for the in-memoryUNREGISTEREDsentinel. Pre-R4 deployments stay byte-identical (empty string is the default).register_variant_id: a stable 12-char SHA-256 digest of the selected(opening, closing)marker pair. Empty ("") when no decoration was applied (empty buckets, or empty surface). Different turns under the same register that select the same marker pair share the samevariant_id— useful for "how many distinct variants did this register actually produce across N turns?"
The variant_id is intentionally a digest, not the literal strings:
- The literal markers are operator-authored content already on disk
in
packs/register/<id>.json; reproducing them inline in every TurnEvent is redundant. - A digest is content-addressed: two different turns with the same variant_id provably picked the same marker pair without storing the pair twice.
- 12 chars (48 bits) is far more than enough to distinguish among at
most
len(openings) × len(closings)variants per register (currently ≤ 9 for convivial_v1).
Decoration return type widens
Today decorate_surface(surface, register, *, turn_idx, seed_text=None) -> str.
R5 needs the variant_id alongside the surface. Two options:
- (A) Return a small frozen dataclass
DecorationResult(surface, opening, closing, variant_id). - (B) Add a parallel
compute_decoration(...) -> DecorationResultand leavedecorate_surfaceas a thin wrapper.
R5 chooses (A) — a single source of truth for what decoration
happened. The R4 call sites in chat/runtime.py already expect a
string; they will be updated to read .surface. decorate_surface
gains a slim decorate_surface_str alias preserving the old return
type for off-runtime callers (tests, ad-hoc CLI tools).
@dataclass(frozen=True, slots=True)
class DecorationResult:
surface: str # post-decoration string
opening: str # marker chosen for the opening bucket
closing: str # marker chosen for the closing bucket
variant_id: str # 12-char sha256(f"{opening}|{closing}") prefix
def decorate_surface(
surface: str,
register: RegisterPack,
*,
turn_idx: int,
seed_text: str | None = None,
) -> DecorationResult: ...
def decorate_surface_str(...) -> str:
return decorate_surface(...).surface
When both opening and closing are empty, variant_id is ""
(the "no decoration applied" sentinel). This means
UNREGISTERED, default_neutral_v1, and terse_v1 all emit
variant_id="" — they don't pollute the audit stream with a
no-op digest.
CLI — core chat --register <id>
# core/cli.py — cmd_chat argparser
chat_parser.add_argument(
"--register",
metavar="REGISTER_ID",
default=None,
help=(
"Optional register pack ID (ADR-0068+). Default: no register "
"(unregistered sentinel, byte-identical to default_neutral_v1)."
),
)
Threads into _runtime_config_from_args as
RuntimeConfig(register_pack_id=args.register). An invalid
register_id raises RegisterPackError at ChatRuntime.__init__ — the
CLI catches and surfaces it as a clear error before the chat REPL
starts, not on the first turn.
Demo — core demo register-tour
A narrative demo that walks a fixed prompt sequence under each ratified register, prints a register × prompt grid of surfaces, and emits a JSON record with the load-bearing claim:
Registers exercised:
- default_neutral_v1
- terse_v1
- convivial_v1
Prompt sequence (4 prompts; deterministic order):
P1: "What is light?"
P2: "Define knowledge."
P3: "What is truth?"
P4: "Light reveals truth, right?"
Per cell, the demo records:
surface, grounding_source, trace_hash, register_id, register_variant_id.
Load-bearing claim (asserted before exit):
all_grounding_sources_identical: True
all_trace_hashes_identical: True
surfaces_vary_at_least_once: True (convivial vs neutral)
The demo prints a human-readable grid to stdout and a structured JSON
record (matching core demo audit-tour --json shape) for downstream
consumption. Exit code 0 iff every claim holds.
Files
core/physics/identity.py EDIT
- TurnEvent gains register_id and register_variant_id
(both default "" — pre-R4 byte-identical)
chat/register_variation.py EDIT
- DecorationResult dataclass added
- decorate_surface return type widened
- decorate_surface_str alias added for old callers
chat/runtime.py EDIT
- Both call sites of decorate_surface updated to read .surface
- TurnEvent construction gains register_id + register_variant_id
from the decoration result
- Stub path also fills the two new fields
chat/telemetry.py EDIT
- serialize_turn_event adds register_id and register_variant_id
to the output dict (always — pre-R4 emit "" for both)
core/cli.py EDIT
- cmd_chat adds --register flag
- _runtime_config_from_args reads args.register
evals/register_tour/run_tour.py NEW
- Narrative demo runner; mirrors evals/audit_tour/run_tour.py shape
evals/register_tour/__init__.py NEW
core/cli.py EDIT
- cmd_demo_register_tour subcommand wires to evals.register_tour
- EPILOG gains "core demo register-tour" example
tests/test_register_telemetry.py NEW
- TurnEvent fields populated on every chat() turn
- serialize_turn_event surfaces both fields
- register_id="" when register_pack_id=None
- register_variant_id="" for null/empty-bucket registers
- register_variant_id is a 12-char hex digest under convivial_v1
tests/test_register_cli.py NEW
- core chat --register convivial_v1 boots successfully
- core chat --register bogus_v999 exits with clear error
- flag default (no --register) preserves R4 behaviour
tests/test_register_tour_demo.py NEW
- core demo register-tour exits 0 under all three registers
- all_grounding_sources_identical == True
- all_trace_hashes_identical == True
- surfaces_vary_at_least_once == True
- JSON output schema is stable (snapshot pin)
docs/decisions/ADR-0072-register-telemetry-operator-surface.md NEW (this file)
Invariants pinned in CI at R5
invariants A, B, C (ADR-0069) — preserved
invariant_register_grounding (ADR-0070) — extended
invariant_seeded_variation_replay (ADR-0071) — preserved
invariant_seeded_variation_turn_distinct (ADR-0071) — preserved
invariant_telemetry_register_visible (NEW):
serialize_turn_event(event) always contains
register_id and register_variant_id keys; the values reflect the
runtime's loaded register at turn-emit time.
invariant_register_tour_seam (NEW):
evals/register_tour/run_tour.py asserts:
- grounding_source identical across registers per prompt
- trace_hash identical across registers per prompt
- surface differs at least once (convivial vs neutral)
Exits non-zero on any violation. Pinned by
tests/test_register_tour_demo.py.
The tour demo is the load-bearing R5 artifact. It is the operator-visible answer to "does the seam actually hold?" and it exits non-zero if it doesn't — turning the architectural claim into a falsifiable CI check.
Consequences
Capability unlocked at R5
- An operator can drive any ratified register from the CLI.
- Every audit JSONL line names which register was active and which variant fired — the audit trail can answer "did the operator's --register flag actually take effect?" without inferring it.
- A single demo proves end-to-end that switching registers does not move trace_hash or grounding_source, only surface text.
Cognition lane — unchanged
Empty defaults on the new TurnEvent fields preserve byte-identical output. The R1–R4 invariants continue to hold. The cognition eval public/holdout numbers stay byte-identical.
Backwards compatibility
TurnEventfields default to""— pre-R5 callers that constructTurnEvent(...)without the new fields keep working.decorate_surfacesignature is widening from-> strto-> DecorationResult. The two runtime call sites are updated in the same PR. Off-runtime callers (tests, ad-hoc CLI) use the newdecorate_surface_stralias when they only want the string.- Existing telemetry consumers that read JSONL by key access continue
to work; consumers that snapshot the exact set of keys may need
to update —
register_idandregister_variant_idare added. Snapshot tests intests/test_register_telemetry.pydocument the new shape.
Performance
One additional SHA-256 per turn (for variant_id), one new dict entry in the telemetry payload. Negligible.
Trust boundaries
--registerflag does not bypass ratification. The flag value is passed through_validate_pack_id(already in the loader) and through the ratify gate at load time. An unratified pack id raisesRegisterPackErrorexactly as a config-driven load would.- Variant_id is content-addressed. Two operators staring at the
same variant_id are looking at the same marker pair, full stop.
No content rendering required; no info leak beyond what
register_idalready implies. - CLI value sanitization.
args.registeris a user-controlled string entering the trust boundary. Path traversal and unsafe ids are rejected by the loader's existing_find_packguard. - Telemetry stays redact-safe. Neither
register_idnorregister_variant_idcarries content; both are pack identifiers / digests.include_content=Falsepaths surface them unconditionally because they're not content. - No new mutation surface. Pack files on disk are not modified by anything in R5.
Verification
tests/test_register_telemetry.py N passed
tests/test_register_cli.py N passed
tests/test_register_tour_demo.py N passed
Curated lanes (must remain green):
smoke / cognition / teaching / packs / runtime / algebra
Cognition eval byte-identical:
public 100 / 100 / 91.7 / 100
holdout 100 / 100 / 83.3 / 100
core demo register-tour exit 0
core demo register-tour --json stable schema
The tour exit code is the canonical R5 gate — if it ever exits non-zero in CI, the register subsystem has regressed.
Open questions deferred to later phases
- TurnVerdicts integration. Should
TurnVerdictscarry the register identity alongside safety/ethics/identity? Probably yes for a fully unified audit bundle, but R5 keeps the field on TurnEvent itself for now. Future ADR may consolidate. - Variant_id collision space. A 12-char hex prefix is 48 bits.
Two distinct
(opening, closing)pairs collide with probability ~2^-48 — effectively zero across any realistic operator-authored bucket. If buckets ever grow to thousands of entries, widen to 16 or 20 chars. Not an R5 concern. - Operator-driven register switching mid-session. Today the
register is loaded once at
ChatRuntime.__init__. Aruntime.set_ register(<id>)API would let an operator switch live. Deferred — needs careful thinking about replay equivalence across the switch.
Future ADRs unlocked
- ADR-0073+ (post-R5) — anchor lens / substantive variation
(Greek/Hebrew). The presentation axis is now operator-visible,
CI-falsifiable, and audit-traceable. Anchor lens composes against
it as an orthogonal axis (content variation, not surface
variation). See
[[greek-hebrew-pack-scout-2026-05-19]]for the content prerequisites (distinction-bearing lemmas, alignment files on cognition-tier packs, reviewed teaching corpus in non- English).