|
|
|
|
@@ -0,0 +1,416 @@
|
|
|
|
|
// file: kb_materializer_api/src/contracts.rs
|
|
|
|
|
// version: 7
|
|
|
|
|
|
|
|
|
|
//! Backend-neutral decoded observation materialization contracts.
|
|
|
|
|
|
|
|
|
|
/// Stable identity of one decoded event materializer.
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
|
|
|
|
pub struct MaterializerIdentity {
|
|
|
|
|
/// Stable lower snake case processor name.
|
|
|
|
|
pub name: std::string::String,
|
|
|
|
|
/// Semantic or deterministic implementation version.
|
|
|
|
|
pub version: std::string::String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl MaterializerIdentity {
|
|
|
|
|
/// Builds a validated materializer identity.
|
|
|
|
|
pub fn new(
|
|
|
|
|
name: impl std::convert::Into<std::string::String>,
|
|
|
|
|
version: impl std::convert::Into<std::string::String>,
|
|
|
|
|
) -> kb_core::Result<Self> {
|
|
|
|
|
let value = Self {
|
|
|
|
|
name: name.into(),
|
|
|
|
|
version: version.into(),
|
|
|
|
|
};
|
|
|
|
|
if value.name.trim().is_empty() || value.version.trim().is_empty() {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::invalid_state(
|
|
|
|
|
"materializer identity fields must not be empty",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
return std::result::Result::Ok(value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Explicit policy applied to successful and failed source transactions.
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
|
|
|
|
pub enum MaterializationTransactionPolicy {
|
|
|
|
|
/// Accept committed observations from successful transactions only.
|
|
|
|
|
SuccessfulCommittedOnly,
|
|
|
|
|
/// Accept successful observations and failed audit-only observations.
|
|
|
|
|
SuccessfulOrFailedAudit,
|
|
|
|
|
/// Accept failed observations for a specifically declared non-mutating family.
|
|
|
|
|
ExplicitFailedObservation,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Terminal materializer result status.
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
|
|
|
|
pub enum MaterializerOutcomeStatus {
|
|
|
|
|
/// One or more new stable outputs were inserted.
|
|
|
|
|
Inserted,
|
|
|
|
|
/// One or more processor-owned outputs were deterministically replaced.
|
|
|
|
|
Replaced,
|
|
|
|
|
/// The decoded observation was intentionally ignored.
|
|
|
|
|
Ignored,
|
|
|
|
|
/// The decoded observation was rejected by the declared policy.
|
|
|
|
|
Refused,
|
|
|
|
|
/// Materialization failed and diagnostics were produced.
|
|
|
|
|
Failed,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Structured materializer diagnostic without generic error wrappers.
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
|
|
|
|
pub struct MaterializerDiagnostic {
|
|
|
|
|
/// Stable machine-readable diagnostic code.
|
|
|
|
|
pub code: std::string::String,
|
|
|
|
|
/// Human-readable diagnostic message.
|
|
|
|
|
pub message: std::string::String,
|
|
|
|
|
/// Whether retrying the same version and input may succeed.
|
|
|
|
|
pub retriable: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl MaterializerDiagnostic {
|
|
|
|
|
/// Builds a validated materializer diagnostic.
|
|
|
|
|
pub fn new(
|
|
|
|
|
code: impl std::convert::Into<std::string::String>,
|
|
|
|
|
message: impl std::convert::Into<std::string::String>,
|
|
|
|
|
retriable: bool,
|
|
|
|
|
) -> kb_core::Result<Self> {
|
|
|
|
|
let value = Self {
|
|
|
|
|
code: code.into(),
|
|
|
|
|
message: message.into(),
|
|
|
|
|
retriable,
|
|
|
|
|
};
|
|
|
|
|
if value.code.trim().is_empty() || value.message.trim().is_empty() {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::invalid_state(
|
|
|
|
|
"materializer diagnostic code and message must not be empty",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
return std::result::Result::Ok(value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Stable processor-owned business output derived from one decoded observation.
|
|
|
|
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
|
|
|
|
pub struct MaterializedOutput {
|
|
|
|
|
/// Stable processor-owned output key within one decoded observation.
|
|
|
|
|
pub output_key: std::string::String,
|
|
|
|
|
/// Business output family.
|
|
|
|
|
pub family: kb_model::MaterializedEventFamily,
|
|
|
|
|
/// Typed business payload serialized as deterministic JSON.
|
|
|
|
|
pub payload_json: serde_json::Value,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl MaterializedOutput {
|
|
|
|
|
/// Validates stable output identity.
|
|
|
|
|
pub fn validate(&self) -> kb_core::Result<()> {
|
|
|
|
|
if self.output_key.trim().is_empty() {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::invalid_state(
|
|
|
|
|
"materialized output key must not be empty",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
return std::result::Result::Ok(());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Complete explicit result produced by one materializer invocation.
|
|
|
|
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
|
|
|
|
pub struct MaterializerExecutionResult {
|
|
|
|
|
/// Terminal materializer status.
|
|
|
|
|
pub status: MaterializerOutcomeStatus,
|
|
|
|
|
/// Stable business outputs.
|
|
|
|
|
pub outputs: std::vec::Vec<MaterializedOutput>,
|
|
|
|
|
/// Structured diagnostics.
|
|
|
|
|
pub diagnostics: std::vec::Vec<MaterializerDiagnostic>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl MaterializerExecutionResult {
|
|
|
|
|
/// Builds a policy refusal result.
|
|
|
|
|
pub fn refused(code: &str, message: &str) -> Self {
|
|
|
|
|
return Self {
|
|
|
|
|
status: MaterializerOutcomeStatus::Refused,
|
|
|
|
|
outputs: std::vec::Vec::new(),
|
|
|
|
|
diagnostics: std::vec![MaterializerDiagnostic {
|
|
|
|
|
code: code.to_string(),
|
|
|
|
|
message: message.to_string(),
|
|
|
|
|
retriable: false,
|
|
|
|
|
}],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Builds an ignored result.
|
|
|
|
|
pub fn ignored() -> Self {
|
|
|
|
|
return Self {
|
|
|
|
|
status: MaterializerOutcomeStatus::Ignored,
|
|
|
|
|
outputs: std::vec::Vec::new(),
|
|
|
|
|
diagnostics: std::vec::Vec::new(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Validates status and output consistency.
|
|
|
|
|
pub fn validate(&self) -> kb_core::Result<()> {
|
|
|
|
|
let output_status = self.status == MaterializerOutcomeStatus::Inserted
|
|
|
|
|
|| self.status == MaterializerOutcomeStatus::Replaced;
|
|
|
|
|
if output_status && self.outputs.is_empty() {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::invalid_state(
|
|
|
|
|
"inserted or replaced materializer status requires outputs",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if !output_status && !self.outputs.is_empty() {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::invalid_state(
|
|
|
|
|
"ignored, refused or failed materializer status must not contain outputs",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if self.status == MaterializerOutcomeStatus::Failed && self.diagnostics.is_empty() {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::invalid_state(
|
|
|
|
|
"failed materializer status requires at least one diagnostic",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
for output in &self.outputs {
|
|
|
|
|
let validation_result = output.validate();
|
|
|
|
|
if let std::result::Result::Err(error) = validation_result {
|
|
|
|
|
return std::result::Result::Err(error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return std::result::Result::Ok(());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Stable decoded observation materializer contract used by the common pipeline.
|
|
|
|
|
pub trait EventMaterializer: std::marker::Send + std::marker::Sync {
|
|
|
|
|
/// Returns the stable materializer identity.
|
|
|
|
|
fn identity(&self) -> MaterializerIdentity;
|
|
|
|
|
/// Returns every decoded event family explicitly accepted by this materializer.
|
|
|
|
|
fn accepted_families(&self) -> &'static [kb_model::EventFamily];
|
|
|
|
|
/// Returns whether this materializer accepts one exact decoded observation.
|
|
|
|
|
fn accepts_observation(&self, observation: &kb_decoder_api::DecodedObservation) -> bool {
|
|
|
|
|
let family = observation.event.event_family;
|
|
|
|
|
return self.accepted_families().iter().any(|accepted| return *accepted == family);
|
|
|
|
|
}
|
|
|
|
|
/// Returns the failed/successful transaction policy for one accepted family.
|
|
|
|
|
fn transaction_policy(&self, family: kb_model::EventFamily)
|
|
|
|
|
-> MaterializationTransactionPolicy;
|
|
|
|
|
/// Materializes one validated decoded observation.
|
|
|
|
|
fn materialize(
|
|
|
|
|
&self,
|
|
|
|
|
observation: &kb_decoder_api::DecodedObservation,
|
|
|
|
|
) -> MaterializerExecutionResult;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns true when a materializer explicitly accepts one decoded family.
|
|
|
|
|
pub fn materializer_accepts_family(
|
|
|
|
|
materializer: &dyn EventMaterializer,
|
|
|
|
|
family: kb_model::EventFamily,
|
|
|
|
|
) -> bool {
|
|
|
|
|
return materializer
|
|
|
|
|
.accepted_families()
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|accepted| return *accepted == family);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns true when a materializer accepts one exact decoded observation.
|
|
|
|
|
pub fn materializer_accepts_observation(
|
|
|
|
|
materializer: &dyn EventMaterializer,
|
|
|
|
|
observation: &kb_decoder_api::DecodedObservation,
|
|
|
|
|
) -> bool {
|
|
|
|
|
return materializer.accepts_observation(observation);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Applies the mandatory source transaction policy before materialization.
|
|
|
|
|
pub fn validate_materialization_policy(
|
|
|
|
|
materializer: &dyn EventMaterializer,
|
|
|
|
|
observation: &kb_decoder_api::DecodedObservation,
|
|
|
|
|
) -> std::result::Result<(), MaterializerExecutionResult> {
|
|
|
|
|
let family = observation.event.event_family;
|
|
|
|
|
if !crate::materializer_accepts_observation(materializer, observation) {
|
|
|
|
|
return std::result::Result::Err(MaterializerExecutionResult::refused(
|
|
|
|
|
"unsupported_decoded_observation",
|
|
|
|
|
"materializer does not accept the decoded observation surface and entry",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let policy = materializer.transaction_policy(family);
|
|
|
|
|
if observation.transaction_failed || !observation.observation_committed {
|
|
|
|
|
if family == kb_model::EventFamily::Trade
|
|
|
|
|
|| family == kb_model::EventFamily::Liquidity
|
|
|
|
|
|| family == kb_model::EventFamily::Lifecycle
|
|
|
|
|
{
|
|
|
|
|
return std::result::Result::Err(MaterializerExecutionResult::refused(
|
|
|
|
|
"failed_transaction_mutation_refused",
|
|
|
|
|
"failed or uncommitted observations cannot create successful mutable business outputs",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if policy == MaterializationTransactionPolicy::SuccessfulCommittedOnly {
|
|
|
|
|
return std::result::Result::Err(MaterializerExecutionResult::refused(
|
|
|
|
|
"failed_transaction_policy_refused",
|
|
|
|
|
"materializer accepts committed observations from successful transactions only",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if policy == MaterializationTransactionPolicy::SuccessfulOrFailedAudit
|
|
|
|
|
&& family != kb_model::EventFamily::Audit
|
|
|
|
|
&& family != kb_model::EventFamily::ComplianceAudit
|
|
|
|
|
&& family != kb_model::EventFamily::Risk
|
|
|
|
|
{
|
|
|
|
|
return std::result::Result::Err(MaterializerExecutionResult::refused(
|
|
|
|
|
"failed_transaction_non_audit_refused",
|
|
|
|
|
"failed transaction materialization is limited to declared audit or risk families",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return std::result::Result::Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Rejects successful mutable business outputs derived from failed or uncommitted observations.
|
|
|
|
|
pub fn validate_materialized_output_policy(
|
|
|
|
|
observation: &kb_decoder_api::DecodedObservation,
|
|
|
|
|
result: &MaterializerExecutionResult,
|
|
|
|
|
) -> std::result::Result<(), MaterializerExecutionResult> {
|
|
|
|
|
if !observation.transaction_failed && observation.observation_committed {
|
|
|
|
|
return std::result::Result::Ok(());
|
|
|
|
|
}
|
|
|
|
|
let forbidden_output = result.outputs.iter().any(|output| {
|
|
|
|
|
return output.family != kb_model::MaterializedEventFamily::ComplianceAudit
|
|
|
|
|
&& output.family != kb_model::MaterializedEventFamily::TokenMetadataRisk
|
|
|
|
|
&& output.family != kb_model::MaterializedEventFamily::Risk;
|
|
|
|
|
});
|
|
|
|
|
if forbidden_output {
|
|
|
|
|
return std::result::Result::Err(MaterializerExecutionResult::refused(
|
|
|
|
|
"failed_transaction_output_refused",
|
|
|
|
|
"failed or uncommitted observations may only produce audit or risk outputs",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
return std::result::Result::Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
struct TradeMaterializer;
|
|
|
|
|
|
|
|
|
|
impl crate::EventMaterializer for TradeMaterializer {
|
|
|
|
|
fn identity(&self) -> crate::MaterializerIdentity {
|
|
|
|
|
return crate::MaterializerIdentity {
|
|
|
|
|
name: "trade_materializer".to_string(),
|
|
|
|
|
version: "1".to_string(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn accepted_families(&self) -> &'static [kb_model::EventFamily] {
|
|
|
|
|
return &[kb_model::EventFamily::Trade];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn transaction_policy(
|
|
|
|
|
&self,
|
|
|
|
|
_family: kb_model::EventFamily,
|
|
|
|
|
) -> crate::MaterializationTransactionPolicy {
|
|
|
|
|
return crate::MaterializationTransactionPolicy::SuccessfulCommittedOnly;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn materialize(
|
|
|
|
|
&self,
|
|
|
|
|
_observation: &kb_decoder_api::DecodedObservation,
|
|
|
|
|
) -> crate::MaterializerExecutionResult {
|
|
|
|
|
return crate::MaterializerExecutionResult::ignored();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn failed_trade_observation() -> kb_decoder_api::DecodedObservation {
|
|
|
|
|
return kb_decoder_api::DecodedObservation {
|
|
|
|
|
event_key: "trade".to_string(),
|
|
|
|
|
event: kb_model::DecodedProtocolEvent {
|
|
|
|
|
signature: kb_model::Signature("signature".to_string()),
|
|
|
|
|
slot: kb_model::Slot(42),
|
|
|
|
|
instruction_path: kb_model::InstructionPath("0".to_string()),
|
|
|
|
|
program_id: kb_model::ProgramId("program".to_string()),
|
|
|
|
|
protocol_code: kb_model::ProtocolCode("protocol".to_string()),
|
|
|
|
|
surface_code: kb_model::SurfaceCode("surface".to_string()),
|
|
|
|
|
event_code: kb_model::EventCode("trade".to_string()),
|
|
|
|
|
event_name: kb_model::EventName("trade".to_string()),
|
|
|
|
|
event_family: kb_model::EventFamily::Trade,
|
|
|
|
|
source_kind: kb_model::EventSourceKind::Instruction,
|
|
|
|
|
confidence: kb_model::DecoderConfidence::Exact,
|
|
|
|
|
},
|
|
|
|
|
payload_json: serde_json::json!({}),
|
|
|
|
|
transaction_failed: true,
|
|
|
|
|
transaction_error: std::option::Option::Some(
|
|
|
|
|
serde_json::json!({"InstructionError": [0, "Custom"]}),
|
|
|
|
|
),
|
|
|
|
|
observation_committed: false,
|
|
|
|
|
proof: kb_decoder_api::DecoderProof {
|
|
|
|
|
kind: kb_decoder_api::DecoderProofKind::ExactLayout,
|
|
|
|
|
confidence: kb_model::DecoderConfidence::Exact,
|
|
|
|
|
evidence: std::vec!["layout".to_string()],
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn failed_trade_is_refused_before_materialization() {
|
|
|
|
|
let result =
|
|
|
|
|
crate::validate_materialization_policy(&TradeMaterializer, &failed_trade_observation());
|
|
|
|
|
assert!(result.is_err());
|
|
|
|
|
let refusal = match result {
|
|
|
|
|
std::result::Result::Ok(()) => panic!("failed trade unexpectedly accepted"),
|
|
|
|
|
std::result::Result::Err(value) => value,
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(refusal.status, crate::MaterializerOutcomeStatus::Refused);
|
|
|
|
|
assert_eq!(refusal.diagnostics[0].code, "failed_transaction_mutation_refused");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct FailedAuditToTradeMaterializer;
|
|
|
|
|
|
|
|
|
|
impl crate::EventMaterializer for FailedAuditToTradeMaterializer {
|
|
|
|
|
fn identity(&self) -> crate::MaterializerIdentity {
|
|
|
|
|
return crate::MaterializerIdentity {
|
|
|
|
|
name: "failed_audit_to_trade".to_string(),
|
|
|
|
|
version: "1".to_string(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn accepted_families(&self) -> &'static [kb_model::EventFamily] {
|
|
|
|
|
return &[kb_model::EventFamily::Audit];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn transaction_policy(
|
|
|
|
|
&self,
|
|
|
|
|
_family: kb_model::EventFamily,
|
|
|
|
|
) -> crate::MaterializationTransactionPolicy {
|
|
|
|
|
return crate::MaterializationTransactionPolicy::SuccessfulOrFailedAudit;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn materialize(
|
|
|
|
|
&self,
|
|
|
|
|
_observation: &kb_decoder_api::DecodedObservation,
|
|
|
|
|
) -> crate::MaterializerExecutionResult {
|
|
|
|
|
return crate::MaterializerExecutionResult {
|
|
|
|
|
status: crate::MaterializerOutcomeStatus::Inserted,
|
|
|
|
|
outputs: std::vec![crate::MaterializedOutput {
|
|
|
|
|
output_key: "trade".to_string(),
|
|
|
|
|
family: kb_model::MaterializedEventFamily::Trade,
|
|
|
|
|
payload_json: serde_json::json!({}),
|
|
|
|
|
}],
|
|
|
|
|
diagnostics: std::vec::Vec::new(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn failed_audit_observation() -> kb_decoder_api::DecodedObservation {
|
|
|
|
|
let mut observation = failed_trade_observation();
|
|
|
|
|
observation.event.event_family = kb_model::EventFamily::Audit;
|
|
|
|
|
return observation;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn failed_audit_cannot_emit_successful_trade_output() {
|
|
|
|
|
let materializer = FailedAuditToTradeMaterializer;
|
|
|
|
|
let observation = failed_audit_observation();
|
|
|
|
|
let policy_result = crate::validate_materialization_policy(&materializer, &observation);
|
|
|
|
|
assert!(policy_result.is_ok());
|
|
|
|
|
let materialized = crate::EventMaterializer::materialize(&materializer, &observation);
|
|
|
|
|
let output_result = crate::validate_materialized_output_policy(&observation, &materialized);
|
|
|
|
|
assert!(output_result.is_err());
|
|
|
|
|
let refusal = match output_result {
|
|
|
|
|
std::result::Result::Ok(()) => panic!("failed audit unexpectedly emitted a trade"),
|
|
|
|
|
std::result::Result::Err(value) => value,
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(refusal.status, crate::MaterializerOutcomeStatus::Refused);
|
|
|
|
|
assert_eq!(refusal.diagnostics[0].code, "failed_transaction_output_refused");
|
|
|
|
|
}
|
|
|
|
|
}
|