Skip to content

feat(37-13): encounter creation gate — every branch observable#451

Merged
slabgorb merged 4 commits intodevelopfrom
feat/37-13-encounter-gate-telemetry
Apr 14, 2026
Merged

feat(37-13): encounter creation gate — every branch observable#451
slabgorb merged 4 commits intodevelopfrom
feat/37-13-encounter-gate-telemetry

Conversation

@slabgorb
Copy link
Copy Markdown
Owner

Summary

  • Extracts the inline encounter-creation gate from dispatch_player_action into a new dispatch::encounter_gate module with a pure apply_confrontation_gate helper.
  • Covers all six decision cases (A-F) from context-story-37-13.md — every branch emits a distinct WatcherEvent on the encounter channel so the GM panel can see every gate decision.
  • Fixes the CLAUDE.md "No Silent Fallbacks" violation that was the root cause for 37-12 (narrator re-declaring confrontation while state stayed locked).

Case matrix

Case Current state Action OTEL event
A None Create encounter.created
B Some(resolved) Create encounter.created
C Some(unresolved, same type) No-op encounter.redeclare_noop
D Some(unresolved, diff, beat==0) Replace encounter.replaced_pre_beat
E Some(unresolved, diff, beat>0) Reject encounter.new_type_rejected_mid_encounter
F Any, def missing No-op + warn encounter.creation_failed_unknown_type

Scope

  • Production: dispatch/encounter_gate.rs (new, 198 lines), dispatch/mod.rs (call site replacement + pub(crate) mod registration), lib.rs (test module registration).
  • Tests: encounter_gate_story_37_13_tests.rs (717 lines, 13 tests covering A-F + wiring + 4 regression guards), test_support.rs (new shared telemetry-lock module), otel_dice_spans_34_11_tests.rs (refactored to use shared lock).
  • No changes to: apply_beat, find_confrontation_def, beat dispatch, genre packs, narrator prompt.

Test plan

  • 57 sidequest-server lib tests pass (up from 53)
  • 412 sidequest-server integration tests pass
  • cargo clippy -- -D warnings clean
  • cargo fmt --check clean
  • Case A-F branches each emit exactly one WatcherEvent
  • source = "narrator_confrontation" asserted on every canonical event test
  • Wiring test verifies the helper is called from dispatch_player_action and the old inline branch is gone

Review history

  • TEA (red): 9 failing tests specced against the to-be-extracted helper
  • Dev (green): helper implementation, minimal scope
  • Reviewer pass 1: REJECTED — 6 HIGH (rule violations, telemetry-lock race, loose wiring test, stale header)
  • Dev (rework 1): 4 new regression guards, shared telemetry lock, #[non_exhaustive], bound outcome, header rewrite
  • Reviewer pass 2: REJECTED — 3 HIGH (lying doc on #[non_exhaustive], Case B missing source assertion, aspirational comment) + 2 MEDIUM
  • Dev (rework 2): honest doc, Case B fix, sentence removal, Case F hardening, wiring OR-split
  • Reviewer pass 3: APPROVED — zero HIGH/MEDIUM findings, rule-checker clean on all 22 rules

🤖 Generated with Claude Code

slabgorb and others added 4 commits April 14, 2026 08:02
Spec a to-be-extracted helper `dispatch::apply_confrontation_gate` and
`ConfrontationGateOutcome` enum. Tests cover the full Case A-F matrix:

- A: no current encounter -> Created (encounter.created)
- B: resolved current     -> Created
- C: same-type redeclare  -> Redeclared (encounter.redeclare_noop)
- D: diff type, beat == 0 -> ReplacedPreBeat (encounter.replaced_pre_beat)
     + actor re-population from snapshot + narrator NPCs
     + old per_actor_state dropped
- E: diff type, beat  > 0 -> RejectedMidEncounter (ValidationWarning,
     encounter.new_type_rejected_mid_encounter) — state untouched
- F: unknown type         -> UnknownType (ValidationWarning,
     encounter.creation_failed_unknown_type)

Plus a source-scanning wiring test that verifies dispatch/mod.rs calls
the helper and that the old inline silent-drop branch is gone.

RED: unresolved imports for apply_confrontation_gate +
ConfrontationGateOutcome. Dev extracts gate logic during GREEN.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract the inline gate block from dispatch_player_action into a new
dispatch::encounter_gate module with a pure helper that covers all six
cases and emits a distinct WatcherEvent for each:

  A: None                         -> Created   (encounter.created)
  B: Some(resolved)               -> Created   (encounter.created)
  C: Some(unresolved, same type)  -> Redeclared  (encounter.redeclare_noop)
  D: Some(unresolved, diff, 0)    -> ReplacedPreBeat
       (encounter.replaced_pre_beat)
  E: Some(unresolved, diff, >0)   -> RejectedMidEncounter
       (encounter.new_type_rejected_mid_encounter, ValidationWarning)
  F: def missing                  -> UnknownType
       (encounter.creation_failed_unknown_type, ValidationWarning)

The previous code silently dropped the new confrontation type whenever
an unresolved encounter already existed: no OTEL event, no warning, no
state transition. That was a CLAUDE.md "No Silent Fallbacks" violation
and the root cause for 37-12 (narrator never re-declares because state
never accepts a new type).

Case D (replace-before-any-beat-fired) preserves the invariant that
mid-encounter mechanical state is never clobbered. Case E surfaces
mid-encounter narrator divergence as ValidationWarning so the GM panel
can see the drift without corrupting beat/metric state.

All 9 TEA tests green. 53 lib + 412 integration tests pass. No clippy
warnings. The inline block at the call site shrank from 47 lines to 8.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Addresses all six HIGH findings from Colonel Potter's review plus the
mediums on test coverage and doc accuracy.

Rule violations
- Add #[non_exhaustive] to ConfrontationGateOutcome (Rule #2). The
  encounter lifecycle is a growing domain; the enum will gain variants.
- Fix `let _ = apply_confrontation_gate(...)` at the Case D per-actor
  leak test (Rule #6). Bind the outcome and assert ReplacedPreBeat so
  the leak check cannot pass through a silently-wrong variant.

Telemetry-lock race
- Extract TELEMETRY_LOCK + fresh_subscriber + drain_events into a new
  `test_support::telemetry` module shared by both 34-11 OTEL tests and
  37-13 gate tests. Previously each module had its own lock and could
  drain each other's events under parallel test execution.

Test rigor
- Add `assert_source_is_narrator` helper and apply it to every canonical
  event test (Cases A, C, D, E, F). The production code sets
  source = "narrator_confrontation" on every branch; no test asserted
  it until now.
- Tighten the wiring test: positive branch scans for
  `apply_confrontation_gate(` (with the opening paren) so a bare comment
  mention can't satisfy it; negative branch uses a semantic token pair
  (`is_none()` + `is_some_and(|e| e.resolved)`) instead of a whitespace-
  exact multiline match that cargo fmt could silently break.
- Rewrite the test-file header: drop RED-phase language, drop the
  dead `dispatch/mod.rs:1773-1819` line reference, keep it under 20
  lines. (CLAUDE.md: don't reference the current task, fix, or callers
  — those rot.)

New regression guards
- case_c_same_type_with_beat_zero_still_redeclare: locks match-arm
  ordering so a future reorder can't promote beat==0 above same-type.
- case_f_empty_incoming_type_routes_to_unknown: empty incoming type
  must route to UnknownType, not silently no-op.
- case_f_unknown_type_with_existing_encounter_preserves_state: Case F
  with a pre-existing encounter must leave the encounter untouched.
- case_a_populates_both_player_and_npc_actors: exercises the player
  actor loop in build_encounter that every other test left untested.

Gate code hygiene
- Make the Case E match arm's `beat > 0` guard explicit so the code
  matches the module doc table. Add an unreachable! catch-all covering
  the compiler-required fallback (guarded arms aren't exhaustive).
- Document `apply_confrontation_gate`'s always-emits-one-event contract
  and the `narrator_npcs` parameter semantics on the function doc.

Call site
- Bind the gate outcome at dispatch/mod.rs with `let _gate_outcome =`
  and a comment documenting the intentional discard and the future
  hook point for surfacing RejectedMidEncounter to the session layer.

Tests: 57 lib (up from 53) + 412 integration. Clippy clean, fmt clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Addresses the three HIGH and two MEDIUM findings from Colonel Potter's
pass-2 review. All are documentation or test-coverage fixes; no logic
changes to the gate itself.

HIGH: #[non_exhaustive] lying docstring
- Rewrote the doc comment on ConfrontationGateOutcome to be honest
  about intra-crate behavior. The attribute has no compiler bite for
  pub(crate) consumers inside the defining crate; downstream wildcard
  arms are a convention, not a compiler guarantee. The new doc says
  so explicitly.

HIGH: Case B missing assert_source_is_narrator
- Added the assertion to case_b_resolved_current_encounter_creates_new
  so every canonical event test verifies source=narrator_confrontation.

HIGH: _gate_outcome aspirational comment rot
- Dropped the "A future story can match on _gate_outcome to surface
  RejectedMidEncounter to the session layer" sentence from dispatch/
  mod.rs. Replaced with a terse mention that the binding is a named
  placeholder — same information, no future-tense aspiration, no
  unowned TODO.

MEDIUM: Case F existing-encounter missing actor preservation
- Extended case_f_unknown_type_with_existing_encounter_preserves_state
  to assert resolved flag, actors.len(), first actor name, and
  per_actor_state are preserved — plus assert_source_is_narrator on
  the captured event.

MEDIUM: Wiring test AND-logic gap
- Split the negative-side check from `!(a && b)` to two independent
  `!a` / `!b` assertions, closing the hole where a partial regression
  reintroducing just one of the two tokens would slip past.

Tests: 57 lib + 412 integration still green. Clippy clean. Fmt clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@slabgorb slabgorb merged commit 2d40dc5 into develop Apr 14, 2026
1 check failed
@slabgorb slabgorb deleted the feat/37-13-encounter-gate-telemetry branch April 14, 2026 13:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant