Skip to content

feat(rskim-contract): add OutcomeReason + token fields to DecisionRecord (#342)#349

Open
dean0x wants to merge 6 commits into
mainfrom
ticket/342-decision-reason
Open

feat(rskim-contract): add OutcomeReason + token fields to DecisionRecord (#342)#349
dean0x wants to merge 6 commits into
mainfrom
ticket/342-decision-reason

Conversation

@dean0x

@dean0x dean0x commented Jun 21, 2026

Copy link
Copy Markdown
Owner

Summary

Changes

crates/rskim-contract/src/log.rs:

crates/rskim-contract/src/lib.rs:

Breaking Changes

None. Additive only — all existing call sites compile unchanged. The Decision wire enum remains 2-variant (Modified/Passthrough); reason is an additional JSON field.

Reviewer Focus Areas

  • OutcomeReason enum variants and their 5→3 mapping documentation (log.rs:198–260) — these must match the binding table in 304-plan.md §3
  • passthrough_with_reason / modified_with_reason debug assertions: passthrough-family variants must not be passed to modified_with_reason and vice versa
  • tokens_in/tokens_out are accounting-only: they must never appear in any accept/reject gate (AC15 in the 304 plan). The with_tokens() builder is the only way to set them, and it is called AFTER the byte gate, never before
  • Backward-compat tests existing_passthrough_ctor_reason_is_passthrough and existing_modified_ctor_reason_is_full pin the default-reason behavior so a future refactor can't silently change the defaults

Test Plan

  • cargo nextest run -p rskim-contract --features harness -j 4 --no-fail-fast → 164/164 PASS
  • cargo test -p rskim-contract --doc → 14/14 PASS
  • cargo clippy -p rskim-contract --all-targets -- -D warnings → 0 warnings
  • cargo fmt -p rskim-contract -- --check → clean
  • Snyk code scan → 0 issues

Closes #342

dean0x and others added 6 commits June 22, 2026 01:52
…onRecord (#342)

Adds the shared schema coordination step required by ADR-004 between #301
(this crate, schema owner) and #305 (persistence), unblocking #304's full
5→3 reason mapping in BlockRouter.

Changes:
- New OutcomeReason enum (Full/Degraded/Passthrough/FailedOpen/PolicyPassthrough)
  with #[non_exhaustive] + serde snake_case, cited to #342 + #305 in rustdoc
- DecisionRecord gains `reason: OutcomeReason`, `tokens_in: Option<u64>`,
  `tokens_out: Option<u64>` (token fields skip_serializing_if=None)
- Existing constructors stay backward-compatible: passthrough()→Passthrough,
  modified()→Full; no call-site changes required anywhere
- New constructors: passthrough_with_reason(), modified_with_reason(),
  with_tokens() (builder-style, #[must_use])
- OutcomeReason re-exported from crate root alongside DecisionRecord
- All 164 harness+proptest tests pass; clippy clean; fmt clean; Snyk clean

Co-Authored-By: Claude <noreply@anthropic.com>
…solidate duplicates

- outcome_reason_variants_are_distinct: replace 10 pairwise assert_ne! calls with
  compile-time array construction — a deleted variant causes a compile error, making
  the runtime checks redundant language-behavior tests.
- token_fields_json_round_trip: drop the without-tokens half; that case is already
  asserted in decision_record_to_json_round_trips.
- Remove existing_passthrough_ctor_reason_is_passthrough and
  existing_modified_ctor_reason_is_full; their assertions are exact duplicates of
  what decision_record_passthrough_fields and decision_record_modified_fields already
  assert. The additive-guarantee rationale is preserved as a comment in the base tests.
- Remove restatement-only #342: prefixed comments inside assertion blocks.

All 131 tests pass, no behavior changed.
Add discriminating #[should_panic] tests for the OutcomeReason family
guards in DecisionRecord::passthrough_with_reason / modified_with_reason
(#342).

The debug_assert! family guards prevent a caller from pairing a wire
Decision with a mismatched OutcomeReason (e.g. Decision::Modified +
FailedOpen), which would silently violate the binding 5->3 mapping
(the PF-006 silent-wrong-path class). They previously had no test:
deleting either guard would still compile and pass the suite (PF-007
vacuous-coverage gap). The two new tests fail if either guard is removed.

cargo nextest -p rskim-contract --all-features: green
cargo clippy -p rskim-contract --all-targets --all-features -D warnings: clean
cargo fmt -p rskim-contract --check: clean
…rt!, usize tokens, AC10, typed round-trips

Five confirmed review issues closed (ADR-001 / PF-002: surface and fix now):

1. Backward-/forward-compatible deserialization [medium x2]:
   - Add `Default` impl for `OutcomeReason` (→ `Passthrough`), matching the
     passthrough-default semantics documented in the rustdoc.
   - Add `#[serde(default)]` on `DecisionRecord::reason` so pre-#342 records
     (no `reason` key) deserialize successfully rather than failing with
     `missing field "reason"`. Mirrors the already-resilient `tokens_in`/`tokens_out`
     pattern. Asymmetry with sibling optional fields is now closed.

2. Family-consistency invariants in release builds [low]:
   - Promote `debug_assert!` → `assert!` in `passthrough_with_reason` and
     `modified_with_reason`. These are public module-boundary constructors; a
     mismatched (Decision, OutcomeReason) pair produces a silently-inconsistent
     record violating the 5→3 wire-vocabulary mapping (PF-006 silent-wrong-path
     class). `assert!` fires in both debug and release builds (rust.md: assert!
     at module boundaries).

3. PF-007 typed round-trip tests [medium]:
   - Add five new discriminating tests: `legacy_record_deserializes_without_reason_or_tokens`
     (the `#[serde(default)]` guard — deleting the attribute makes this fail),
     `passthrough_record_typed_round_trip`, `modified_with_reason_degraded_typed_round_trip`,
     `with_tokens_typed_round_trip`, and `outcome_reason_typed_deserialize_all_variants`.
   - Use `Box::leak` / `static` literals to satisfy `Deserialize<'static>` (imposed
     by `component: &'static str`). The `from_value` path was attempted first but
     also requires `DeserializeOwned` + `'static` for the same reason.

4. Token-count type drift [medium]:
   - Change `tokens_in`/`tokens_out` from `Option<u64>` to `Option<usize>` and
     `with_tokens(u64, u64)` → `with_tokens(usize, usize)` to match
     `rskim_tokens::Counter::count(&str) -> usize` and the `token_counter` closure
     type documented in 304-plan.md:108,118. Eliminates the avoidable `as u64`
     cast at every #304 call site; keeps consistency with sibling `bytes_in`/`bytes_out`
     (also `usize`).

5. Rustdoc AC citation [low]:
   - Change three "AC15" citations on token-field docs to "AC10" (the correct
     AC mandating token counter exclusion from the gate, per 304-plan.md:288).
     AC15 is the unrelated live-zone passthrough byte-identity criterion.

138/138 nextest + 14/14 doctests pass. clippy -p rskim-contract --all-targets -D warnings clean.
…y + doc correction

Two review issues from #342:

1. [critical] is_sensitive_key (log.rs:128) used string slicing `key[key.len() -
   s.len()..]` for suffix matching. When `key` contains a multibyte UTF-8 code
   point and the byte offset lands inside that code point, Rust panics with
   'byte index N is not a char boundary'. Fix: compare on byte slices via
   `key.as_bytes()` / `s.as_bytes()` — all suffix constants are pure ASCII so
   `eq_ignore_ascii_case` on byte slices is semantically identical but
   panic-free. Adds discriminating regression test `is_sensitive_key_multibyte_utf8_no_panic`.

2. [low] Rustdoc on `DecisionRecord::reason` attributed deserialization
   absence-tolerance to `skip_serializing_if`, which only governs the serialize
   direction. Corrected to explain that serde's built-in `Option` defaulting
   handles the deserialize direction for `tokens_in`/`tokens_out`, while
   `#[serde(default)]` explicitly handles it for the non-Option `reason` field.

142/142 tests pass; clippy -D warnings clean.

Co-Authored-By: Claude <noreply@anthropic.com>
…unctions

`cargo fmt -p rskim-contract -- --check` failed on Box::leak chains
in passthrough_record_typed_round_trip, modified_with_reason_degraded_typed_round_trip,
and with_tokens_typed_round_trip. rustfmt breaks the chained .expect()
and .into_boxed_str() calls onto separate indented lines. Auto-applied
by `cargo fmt -p rskim-contract`. 139/139 tests pass; clippy -D warnings
clean.

Refs #342

Co-Authored-By: Claude <noreply@anthropic.com>
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.

L3: add reason + token fields to rskim_contract::DecisionRecord (schema coordination #301/#305)

1 participant