Files
buzz/crates/buzz-conformance/src/transitions.rs
T
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

331 lines
14 KiB
Rust

//! Independent translation of `docs/spec/MultiTenantRelay.tla`'s `Next`
//! transition relation into Rust.
//!
//! This module is the heart of the conformance gate. It is deliberately
//! **independent** of the production reducer: it reads only the trace
//! schema in [`crate`] and the spec text in `docs/spec/MultiTenantRelay.tla`.
//! It does not import `buzz-relay`, `buzz-db`, `buzz-auth`, or any other
//! production crate that could share a normalization bug with the emitter.
//!
//! ## What an "abstract state" means here
//!
//! The TLA+ spec models the relay as a multi-worker system whose state is
//! the set of accepted rows, projection rows, observations, etc. A runtime
//! trace covers ONE worker handling ONE request — so the model state we
//! carry is much smaller:
//!
//! - `resolved_community` — the server-resolved `TenantContext::community()`
//! for this request. `Inv_NonInterference` requires every row label
//! observed in this request be a subset of `{resolved_community}`.
//! - `bound_host` — the host label `TenantContext::host()` was bound from.
//! `AuthCheck` / channel-less reads require `HostCommunity[host]` agree
//! with the resolved community.
//!
//! The checker rebuilds this state independently from the FIRST trace step
//! it sees and then validates every subsequent step against it.
//!
//! ## Per-action obligations
//!
//! Each action has a triple of obligations distilled from the spec:
//!
//! 1. **State match.** `step.state_after.resolved_community` and
//! `bound_host` agree with the checker's running model (no mid-request
//! tenant flip).
//! 2. **Row-label confinement** (`Inv_NonInterference` line ~983,
//! `Inv_ReadConfinement` line ~1003). Every `row_communities` entry,
//! every accept label, must equal `resolved_community`. A single foreign
//! label fails the trace.
//! 3. **Action-specific guards.** AuthCheck `Allow` requires host/channel
//! agreement; channel-less reads require `HostCommunity[host] = c`;
//! `WriteInsert` claim-vs-resolved is recorded but a mismatch is
//! allowed at the abstract level — the spec ignores it ("host wins"),
//! so the gate that bites mismatches is the row-label confinement on
//! the *next* read.
use crate::{
AbstractState, ChannelLabel, CommunityLabel, SanitizedReason, TraceAction, TraceStep, Verdict,
};
/// A judgment about a single trace step. The checker walks the trace and
/// returns the first failure verdict (fail-fast); per the skill's "fail
/// closed on the first violation" guidance.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Verdict_ {
/// Reserved — internal placeholder.
Ok,
}
/// Failure reasons returned by [`check_step`]. The string payload is
/// human-readable; mechanical consumers should match on the variant.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum TransitionError {
/// The traced action is not allowed from the checker's current model
/// state — e.g. an `AuthCheck { verdict: Allow }` with `claimed != real`.
#[error("illegal transition at step {step_index}: {detail}")]
IllegalTransition {
/// 0-based index of the offending step.
step_index: usize,
/// Human-readable detail.
detail: String,
},
/// The trace's `state_after` does not match the model state the checker
/// computed independently. Indicates the relay either reassigned the
/// tenant context mid-request, or emitted a step from a context other
/// than `TenantContext`.
#[error("state mismatch at step {step_index}: {detail}")]
StateMismatch {
/// 0-based index of the offending step.
step_index: usize,
/// Human-readable detail.
detail: String,
},
/// Row labels include a community other than the resolved tenant —
/// the master `Inv_NonInterference` failure.
#[error("non-interference breach at step {step_index}: {detail}")]
NonInterference {
/// 0-based index of the offending step.
step_index: usize,
/// Human-readable detail.
detail: String,
},
/// A coverage breach: ImplBug action, or a fixture-declared
/// `required_critical_actions` entry never appeared.
#[error("coverage breach: {detail}")]
CoverageBreach {
/// Human-readable detail naming the missing or broken coverage rule.
detail: String,
},
}
/// The model state the checker carries between steps.
#[derive(Debug, Clone)]
pub struct ModelState {
/// The community the FIRST step's `state_after` told us was resolved.
/// Subsequent steps must agree.
pub resolved_community: CommunityLabel,
/// The host the FIRST step's `state_after` told us was bound. Channel-
/// bearing AuthCheck and channel-less reads enforce
/// `host_community(host) == resolved_community`. The checker does NOT
/// know `HostCommunity[_]` at large; it only knows the spec guarantees
/// `HostCommunity[bound_host] = resolved_community` whenever the relay
/// took the success branch.
pub bound_host: crate::HostLabel,
/// The actor for this request — opaque, equality-checked only.
pub actor: crate::ActorLabel,
}
impl ModelState {
/// Bootstrap the model from the very first step. Subsequent calls to
/// [`check_step`] return a `StateMismatch` if `state_after` disagrees.
pub fn bootstrap(first: &AbstractState) -> Self {
Self {
resolved_community: first.resolved_community,
bound_host: first.bound_host.clone(),
actor: first.actor.clone(),
}
}
}
/// Validate one step against the model. Updates nothing (the model is
/// immutable for the lifetime of a single trace); a violation returns the
/// matching [`TransitionError`].
///
/// Spec line numbers below refer to `docs/spec/MultiTenantRelay.tla` at the
/// snapshot pinned in this PR's `docs/spec/`.
pub fn check_step(
step_index: usize,
model: &ModelState,
step: &TraceStep,
) -> Result<(), TransitionError> {
// Universal obligation 1: state_after agrees with the bootstrapped model.
if step.state_after.resolved_community != model.resolved_community {
return Err(TransitionError::StateMismatch {
step_index,
detail: format!(
"resolved_community changed mid-request: bootstrap={:?}, step={:?}",
model.resolved_community, step.state_after.resolved_community
),
});
}
if step.state_after.bound_host != model.bound_host {
return Err(TransitionError::StateMismatch {
step_index,
detail: format!(
"bound_host changed mid-request: bootstrap={:?}, step={:?}",
model.bound_host, step.state_after.bound_host
),
});
}
if step.state_after.actor != model.actor {
return Err(TransitionError::StateMismatch {
step_index,
detail: format!(
"actor changed mid-request: bootstrap={:?}, step={:?}",
model.actor, step.state_after.actor
),
});
}
// Action-specific obligations.
match &step.action {
// --- Spec WriteInsert (lines 514-550) ---
// Resolution: real == ChannelCommunity(ch).
// Success branch requires HostCommunity[host] = real, which the
// emitter guarantees by emitting from inside the success path with
// state_after.resolved_community = real and state_after.bound_host
// = the bound host. The trace records claimed_community separately
// so M2/M8 (host/channel disagreement, claim≠resolved) surface here
// as a state mismatch on the *resolved* side.
//
// What we check at this step: nothing beyond the universal state
// match. The spec ignores claimed_community ("host wins"), so a
// mismatch is allowed at this exact action — the gate that bites
// it is the next read's row labels.
TraceAction::WriteInsert { .. } => Ok(()),
// --- Spec WriteInsertGlobal (lines 559-595) ---
// resolved == HostCommunity[host]. Same shape as WriteInsert.
TraceAction::WriteInsertGlobal { .. } => Ok(()),
// --- Spec WriteDuplicate (lines 606-637) ---
// Carries the same host-axis obligation as WriteInsert: an A-host
// presenting a B-channel id must not learn whether the id exists.
// Same observable: state_after.resolved_community must be the
// real ChannelCommunity(ch), enforced by the universal check.
TraceAction::WriteDuplicate { .. } => Ok(()),
// --- Spec SanitizedError (line 778) ---
// Closed-alphabet reason; labels = {}; carries no row data. The
// emitter must collapse every reject path into one of the three
// SanitizedReason variants. The schema-level type system already
// enforces that — we just check the variant is among the spec's
// closed set (trivially true by construction).
TraceAction::SanitizedError { reason } => match reason {
SanitizedReason::Restricted
| SanitizedReason::Invalid
| SanitizedReason::ServerError => Ok(()),
},
// --- Spec AuthCheck (lines 794-810) ---
// real == ChannelCommunity(ch).
// hostAgrees == real ∈ Communities ∧ HostCommunity[host] = real.
// allowed == hostAgrees ∧ ch ∈ ScopedAccessible(real, a).
// verdict == IF allowed THEN Allow ELSE Deny.
//
// The runtime checker cannot recompute ScopedAccessible (that's
// production state). What it CAN check: when verdict = Allow, the
// claimed_community MUST equal resolved_community. This is the
// M2 bite ("auth verdict driven by claimed instead of resolved")
// and the M8 bite ("A-host driving a B-channel verdict") — both
// collapse to "Allow with a foreign label leak".
//
// We deliberately do NOT bite Deny on claim mismatch (Deny with
// any claim is in-spec — the spec models Deny as the catch-all
// for hostAgrees=false or accessibility=false).
TraceAction::AuthCheck {
channel: _,
claimed_community,
verdict,
} => match (verdict, claimed_community) {
(Verdict::Allow, Some(c)) if c != &model.resolved_community => {
Err(TransitionError::IllegalTransition {
step_index,
detail: format!(
"AuthCheck verdict=Allow with claimed_community={:?} != resolved={:?} \
— M2/M8 (claim or host driving verdict) bite",
c, model.resolved_community
),
})
}
_ => Ok(()),
},
// --- Spec ReadMessageRows (line 643) / ReadByIdRows (line 681) ---
// The action emits rows; `RowLabels(rows)` is the observation's
// labels and Inv_NonInterference requires labels ⊆ {community}.
// For channel-less (ch = NoChannel) the spec ADDS:
// HostCommunity[host] = c ∧ IsAdmitted(c, a).
// The host-agreement piece is enforced at the universal check
// (state_after.resolved_community is host-derived); IsAdmitted is
// production state we cannot recompute, so it lives in fixture
// assertions rather than this generic checker.
//
// What this checker bites: every row label must equal the
// resolved community. ONE foreign label fails NI.
TraceAction::ReadMessageRows {
channel: _,
row_communities,
}
| TraceAction::ReadByIdRows {
channel: _,
row_communities,
} => check_row_labels(step_index, model, row_communities),
// --- Spec ReadHostFeedRows (line ~720) ---
// Community is host-derived; same row-label confinement.
TraceAction::ReadHostFeedRows { row_communities } => {
check_row_labels(step_index, model, row_communities)
}
// --- Coverage breach via the Drop guard ---
// The seam exited without emitting any recognized action.
// Per the skill: this is the load-bearing coverage mode — without
// it, trace conformance is decorative logging.
TraceAction::ImplBug { kind } => Err(TransitionError::CoverageBreach {
detail: format!("ImplBug action emitted by Drop guard: kind={kind:?}"),
}),
}
}
/// Row-label confinement check shared by all three read actions.
///
/// `Inv_NonInterference` (spec line ~983):
/// `\A o \in observations : o.labels \subseteq {o.community}`.
///
/// Translated: every `row_communities` entry must equal `model
/// .resolved_community`. The check is on a `Vec`, not a `Set`, deliberately
/// — if a buggy relay returned the same foreign row twice the checker still
/// bites, and if a buggy emitter de-duped foreign labels to one occurrence
/// the checker still bites. Foreign-label count is unimportant; foreign-
/// label presence is the entire bar.
fn check_row_labels(
step_index: usize,
model: &ModelState,
row_communities: &[CommunityLabel],
) -> Result<(), TransitionError> {
if let Some(foreign) = row_communities
.iter()
.find(|c| **c != model.resolved_community)
{
return Err(TransitionError::NonInterference {
step_index,
detail: format!(
"row labeled {:?} returned in observation scoped to {:?} \
— Inv_NonInterference breach (foreign row leaked through tenant fence)",
foreign, model.resolved_community
),
});
}
Ok(())
}
/// Helper: which channel (if any) does the action target? Used by the
/// checker to bind cross-step claims to a stable channel — and by fixtures
/// asserting that a particular channel surfaced at this seam.
pub fn action_channel(action: &TraceAction) -> Option<&ChannelLabel> {
match action {
TraceAction::WriteInsert { channel, .. } => Some(channel),
TraceAction::WriteDuplicate { channel, .. } => Some(channel),
TraceAction::AuthCheck { channel, .. } => Some(channel),
TraceAction::ReadMessageRows { channel, .. } => channel.as_ref(),
TraceAction::ReadByIdRows { channel, .. } => channel.as_ref(),
TraceAction::WriteInsertGlobal { .. }
| TraceAction::ReadHostFeedRows { .. }
| TraceAction::SanitizedError { .. }
| TraceAction::ImplBug { .. } => None,
}
}