Implements the 4-phase documentation reorganization master plan. - Consolidation: Merged brief/, handoff/, planning/, and decisions/ into briefs/, handoffs/, plans/, and adr/ respectively (101 ADRs relocated) - Root Cleanup: Relocated HANDOFF-gpt55-*.md and key top-level docs (runtime_contracts.md, etc.) to canonical folders. Added superseded alerts. - Indices & Navigation: Created docs/README.md navigation document, docs/sessions/README.md index, docs/adr/README.md index - Note: Also includes prior commit adding ADR-0200+ corpus hygiene governance (ADR-0225, dependency map, backfilled cross-references)
8.4 KiB
ADR-0039: Audit Completeness — TurnVerdicts Bundle, Stub-Path TurnEvent, hedge_injected Signal
Status: Accepted (2026-05-17)
Author: Joshua Shay + planner pass
Companion docs: ADR-0035-turn-loop-verdict-surfacing.md, ADR-0036-safety-refusal-policy.md, ADR-0037-per-predicate-ethics-refusal.md, ADR-0038-hedge-injection.md
Context
After ADR-0035 → ADR-0038, the runtime emits three verdicts per turn (identity / safety / ethics) and two possible remediations (typed refusal, hedge injection). But auditing it had three rough edges:
- Per-field correlation. An audit consumer had to read three
separate fields (
identity_score,safety_verdict,ethics_verdict) and infer the remediation by inspecting the surface text (does it start with the refusal prefix? with the hedge phrase?). Surface-text inference is brittle. - Stub-path TurnEvent gap. Stub turns (cold start, unknown
domain) bypassed
turn_log.append(). ADR-0035 already noted this as a known limit. The result: audit consumers reading the turn stream couldn't see stub turns at all, even though stub turns did carry aChatResponse.safety_verdictandChatResponse.ethics_verdict. hedge_injectedinvisibility. Whether the runtime actually prepended a hedge this turn could only be detected by surface inspection (does it start withpreferred_hedge_soft?). An audit consumer wanting "count hedged turns this hour" had to re-implement runtime decision logic.
The three rough edges share a root cause: the runtime knew the answers but didn't surface them in a form callers could read.
Decision
Three changes land together. Each is small; together they close the audit gap.
1. TurnVerdicts bundle type
A new frozen dataclass in chat/verdicts.py:
@dataclass(frozen=True, slots=True)
class TurnVerdicts:
identity_score: object # IdentityScore | None
safety_verdict: object # SafetyVerdict | None
ethics_verdict: object # EthicsVerdict | None
refusal_emitted: bool # ADR-0036 / ADR-0037
hedge_injected: bool # ADR-0038
Fields are typed object for the same reason TurnEvent.safety_verdict
was: avoid coupling chat/verdicts.py to packs.* at module-resolution
time. Audit consumers downcast at use site.
The bundle is attached to both ChatResponse.verdicts and
TurnEvent.verdicts. The pre-existing individual fields
(safety_verdict, ethics_verdict) remain — back-compat with
ADR-0035 callers — but new consumers should read the bundle.
2. Stub-path TurnEvent emission
_stub_response now accepts an optional tokens kwarg. When invoked
from a real turn (with tokens non-empty), it constructs and appends
a TurnEvent to turn_log before returning the ChatResponse.
The stub event records:
| Field | Value |
|---|---|
turn |
self._context.turn - 1 (after finalize_turn already ran) |
input_tokens |
tokens passed in |
surface |
typed refusal if it fired, else the unknown-domain marker |
walk_surface |
unknown-domain marker (preserved) |
articulation_surface |
unknown-domain marker (preserved) |
identity_score |
None (no trajectory ran) |
cycle_cost_total |
0.0 |
vault_hits |
0 |
versor_condition |
versor_condition(field_state.F) |
flagged |
False |
safety_verdict / ethics_verdict / verdicts |
computed verdicts |
The correct() fallback path still calls _stub_response without
tokens — that's a defensive call where no real "turn" happened, and
appending a TurnEvent would mis-record the audit stream.
3. hedge_injected signal
The main turn path tracks whether the runtime actually mutated the surface during hedge injection:
before = response_surface
response_surface = inject_hedge(response_surface, hedge_prefix)
hedge_injected = response_surface != before
inject_hedge() is idempotent on prefix (ADR-0038) — if the surface
already begins with the hedge phrase, the function returns it
unchanged and hedge_injected stays False. This is the correct
audit semantic: "did the runtime ADD a hedge this turn?", not "is
there a hedge somewhere in the surface?"
Stub paths always report hedge_injected=False (ADR-0038 prohibits
hedge on stub).
Consequences
Positive
- One field, full picture. An audit consumer reads
response.verdicts(orevent.verdicts) and gets identity + safety + ethics + remediation flags. No correlation across fields. - Surface-inspection no longer needed.
refusal_emittedandhedge_injectedanswer the runtime-decision questions directly. - Stub turns are now first-class audit events.
turn_lognow covers the entire turn stream; downstream telemetry and replay systems can iterate overturn_logwithout missing stub paths. - Mutual exclusion is verifiable. Existing test
(
test_refusal_and_hedge_never_both_true) confirms the runtime contract holds at the bundle level. - No back-compat breakage. ADR-0035 / ADR-0036 / ADR-0037 / ADR-0038 individual fields and helpers still work.
Negative / risks
- Stub turns now have
identity_score=Nonein the audit stream. Downstream consumers that assumed every TurnEvent carried an IdentityScore need to handleNone. Mitigated: this was already true onChatResponse.identity_scorefor stub paths; consumers reading the stream just had no stream entries at all before, and now they have entries withidentity_score=None. The change is additive. - The bundle duplicates per-field state.
safety_verdictis reachable as bothresponse.safety_verdictandresponse.verdicts.safety_verdict. Acceptable cost for back-compat; a future ADR could remove the per-field accessors if no in-tree consumer still uses them. hedge_injecteddoesn't distinguish "would have fired but idempotent" from "didn't fire at all." Two distinct cases collapse tohedge_injected=False. Audit consumers wanting that distinction can read the ethics_verdict directly.- Hedge-injection test gate updated. The pre-ADR-0039 gate
("skip if
turn_logempty") no longer discriminates stub vs main path; the test updated to gate onwalk_surface == _UNKNOWN_DOMAIN_SURFACEinstead. No semantic change.
Verification
tests/test_turn_verdicts_bundle.py— 16 tests covering: bundle shape and frozen contract;ChatResponseandTurnEventcarry the bundle; stub path appends aTurnEventwith input tokens, unknown walk/articulation surfaces,identity_score=None, recorded versor condition;refusal_emittedflag toggles on forced safety violation, appears symmetrically onChatResponse.verdictsandTurnEvent.verdicts;hedge_injectedflag defaults False, stays False on stub paths even with opt-in; mutual exclusion (refusal supersedes hedge); response and event bundles agree on remediation flags and reference the same underlying verdicts.- Combined pack-layer suite: 170 tests, all green (was 154 after ADR-0038).
- CLI suites unchanged: smoke 67, runtime 19, cognition 121.
core eval cognition: intent 100%, versor_closure 100% — baseline preserved.
Open questions deferred to a future ADR
- Structured-logging sink that consumes
turn_log. Now that stub turns participate in the stream, a structured emitter could produce one log line per turn withpack_id,refusal_emitted,hedge_injected, and violated boundary ids. core chat --show-verdictsCLI flag. Print per-turn verdict bundle summaries to stdout/stderr for manual audit.- Drop per-field accessors after a deprecation cycle.
response.safety_verdictcould becomeresponse.verdicts.safety_verdict-only once internal callers migrate. identity_scoreon stub turns. Today stub paths skip the trajectory operator entirely and reportNone. A future ADR could compute a degenerate-but-valid IdentityScore for stubs so the audit stream is fully populated.- Bundle versioning. As more remediation tiers land (per-domain
default policies, score-decomposition surfaces), the bundle may
grow. Frozen-dataclass + optional defaults should scale fine,
but a
schema_versionfield could be added if downstream consumers need explicit versioning.