hostbat 0.9.0

Generic deterministic module host: mount content-identified modules over a syncbat Core.
//! Event payload binding registration, identity, and composition witnesses.

use batpak::event::EventKind;
use syncbat::{Ctx, HandlerResult, OperationDescriptor};

use crate::error::HostError;
use crate::module::{HostModule, HostModuleBuilder};
use crate::schema::{GoldenVector, SchemaDescriptor, SchemaId, SchemaRole, SchemaVersion};
use crate::{EventPayloadBinding, HostBuilder};

const KIND_A: EventKind = EventKind::custom(0xF, 1);

fn canonical_bytes(value: &str) -> Vec<u8> {
    batpak::canonical::to_bytes(&value).expect("canonical fixture encodes")
}

fn op(name: &'static str) -> OperationDescriptor {
    syncbat::OperationDescriptor::new(
        name,
        syncbat::EffectClass::Inspect,
        "schema.in.v1",
        "schema.out.v1",
        "receipt.v1",
    )
}

fn echo(input: &[u8], _cx: &mut Ctx<'_>) -> HandlerResult {
    Ok(input.to_vec())
}

fn schema_with_role(id: &str, role: SchemaRole, bytes: &[u8]) -> SchemaDescriptor {
    SchemaDescriptor::new(
        SchemaId::new(id).expect("id"),
        SchemaVersion(1),
        role,
        vec![GoldenVector::new("c", bytes.to_vec())],
    )
    .expect("descriptor")
}

fn with_default_operation_schemas(builder: HostModuleBuilder) -> HostModuleBuilder {
    builder
        .schema(schema_with_role(
            "schema.in.v1",
            SchemaRole::OperationInput,
            &canonical_bytes("default-in"),
        ))
        .expect("input schema")
        .schema(schema_with_role(
            "schema.out.v1",
            SchemaRole::OperationOutput,
            &canonical_bytes("default-out"),
        ))
        .expect("output schema")
        .schema(schema_with_role(
            "receipt.v1",
            SchemaRole::ReceiptPayload,
            &canonical_bytes("default-receipt"),
        ))
        .expect("receipt schema")
}

fn event_payload_schema(id: &str, bytes: &[u8]) -> SchemaDescriptor {
    schema_with_role(id, SchemaRole::EventPayload, bytes)
}

fn module_builder_with_op(id: &'static str, op_name: &'static str) -> HostModuleBuilder {
    with_default_operation_schemas(
        HostModule::builder(id, 1)
            .operation(op(op_name), echo)
            .expect("op"),
    )
}

#[test]
fn binding_rejects_empty_schema_reference() -> Result<(), Box<dyn std::error::Error>> {
    let err = match EventPayloadBinding::new(KIND_A, "") {
        Ok(_) => {
            return Err(std::io::Error::other(
                "PROPERTY: empty payload schema reference must be rejected",
            )
            .into())
        }
        Err(error) => error,
    };
    assert!(matches!(err, HostError::EventPayloadBindingInvalid { .. }));
    Ok(())
}

#[test]
fn module_rejects_duplicate_binding_within_module() -> Result<(), Box<dyn std::error::Error>> {
    let err = match module_builder_with_op("mod.a", "mod.a.echo")
        .bind_event_payload(KIND_A, "event.payload.v1")
        .expect("first binding")
        .bind_event_payload(KIND_A, "event.payload.v2")
    {
        Ok(_) => {
            return Err(std::io::Error::other(
                "PROPERTY: duplicate event kind within one module must be rejected",
            )
            .into())
        }
        Err(error) => error,
    };
    assert!(matches!(
        err,
        HostError::EventPayloadBindingDuplicateWithinModule { .. }
    ));
    Ok(())
}

#[test]
fn module_digest_changes_when_event_payload_binding_is_added() {
    let base = module_builder_with_op("mod.a", "mod.a.echo")
        .build()
        .expect("base module");
    let bound = module_builder_with_op("mod.a", "mod.a.echo")
        .bind_event_payload(KIND_A, "event.payload.v1")
        .expect("binding")
        .build()
        .expect("bound module");
    assert_ne!(
        base.manifest().digest(),
        bound.manifest().digest(),
        "event payload bindings fold into H_module",
    );
    assert!(bound.manifest().verify_hash().expect("verify"));
}

#[test]
fn composition_rejects_duplicate_event_kind_across_modules(
) -> Result<(), Box<dyn std::error::Error>> {
    let left = module_builder_with_op("mod.a", "mod.a.echo")
        .bind_event_payload(KIND_A, "event.payload.v1")
        .expect("left binding")
        .build()
        .expect("left module");
    let right = module_builder_with_op("mod.b", "mod.b.echo")
        .bind_event_payload(KIND_A, "event.payload.v1")
        .expect("right binding")
        .build()
        .expect("right module");
    let err = match HostBuilder::new()
        .mount(left)
        .expect("mount left")
        .mount(right)
    {
        Ok(_) => {
            return Err(std::io::Error::other(
                "PROPERTY: duplicate event kind across modules must be rejected",
            )
            .into())
        }
        Err(error) => error,
    };
    assert!(matches!(
        err,
        HostError::DuplicateEventPayloadBinding { .. }
    ));
    Ok(())
}

#[test]
fn composition_rejects_conflicting_bindings_for_same_kind() -> Result<(), Box<dyn std::error::Error>>
{
    let left = module_builder_with_op("mod.a", "mod.a.echo")
        .bind_event_payload(KIND_A, "event.payload.v1")
        .expect("left binding")
        .build()
        .expect("left module");
    let right = module_builder_with_op("mod.b", "mod.b.echo")
        .bind_event_payload(KIND_A, "event.payload.v2")
        .expect("right binding")
        .build()
        .expect("right module");
    let err = match HostBuilder::new()
        .mount(left)
        .expect("mount left")
        .mount(right)
    {
        Ok(_) => {
            return Err(std::io::Error::other(
                "PROPERTY: conflicting payload schema refs for one kind must be rejected",
            )
            .into())
        }
        Err(error) => error,
    };
    assert!(matches!(err, HostError::EventPayloadBindingConflict { .. }));
    Ok(())
}

#[test]
fn host_build_rejects_binding_to_missing_schema() -> Result<(), Box<dyn std::error::Error>> {
    let module = module_builder_with_op("mod.a", "mod.a.echo")
        .bind_event_payload(KIND_A, "event.payload.missing")
        .expect("binding")
        .build()
        .expect("module");
    let err = match HostBuilder::new().mount(module).expect("mount").build() {
        Ok(_) => {
            return Err(std::io::Error::other(
                "PROPERTY: binding to missing schema must fail host build",
            )
            .into())
        }
        Err(error) => error,
    };
    assert!(matches!(
        err,
        HostError::EventPayloadBindingSchemaMissing { .. }
    ));
    Ok(())
}

#[test]
fn interface_fingerprint_changes_when_event_payload_binding_is_added() {
    let base = HostBuilder::new()
        .mount(
            module_builder_with_op("mod.a", "mod.a.echo")
                .schema(event_payload_schema(
                    "event.payload.v1",
                    &canonical_bytes("event-a"),
                ))
                .expect("event schema")
                .build()
                .expect("module"),
        )
        .expect("mount")
        .build()
        .expect("build")
        .interface_fingerprint();
    let bound = HostBuilder::new()
        .mount(
            module_builder_with_op("mod.a", "mod.a.echo")
                .schema(event_payload_schema(
                    "event.payload.v1",
                    &canonical_bytes("event-a"),
                ))
                .expect("event schema")
                .bind_event_payload(KIND_A, "event.payload.v1")
                .expect("binding")
                .build()
                .expect("module"),
        )
        .expect("mount")
        .build()
        .expect("build")
        .interface_fingerprint();
    assert_ne!(
        base, bound,
        "event payload bindings fold into H_interface v4",
    );
}