//! Behavior tests for receipt emission, idempotency, and sequence
//! scoping (issue #14).
//!
//! Uses the in-process pattern from `tests/fake_client.rs`: build a
//! plan, run negotiation, hand the negotiated plan plus a
//! `CallbackResponse` to the emitter, assert the receipt shape.

use std::collections::BTreeMap;

use lifeloop::router::{
    AdapterRegistry, AdapterResolution, CapabilityKind, CapabilityRequest, CapabilityRequirement,
    LifeloopReceiptEmitter, ReceiptContext, ReceiptError, RoutingPlan, negotiate, route,
};
use lifeloop::{
    AcceptablePlacement, AdapterManifest, AdapterRole, CallbackRequest, CallbackResponse,
    ConformanceLevel, FailureClass, FrameContext, IntegrationMode, LifecycleEventKind,
    ManifestContextPressure, ManifestPlacementClass, ManifestPlacementSupport, ManifestReceipts,
    NegotiationOutcome, PayloadEnvelope, PayloadRef, PlacementClass, ReceiptStatus,
    RegisteredAdapter, RequirementLevel, RetryClass, SCHEMA_VERSION, SupportState, Warning,
};

const FAKE_ID: &str = "fake-adapter";
const FAKE_VERSION: &str = "0.0.1";
const CLIENT_ID: &str = "fake-client";

fn manifest() -> AdapterManifest {
    let mut placement = BTreeMap::new();
    placement.insert(
        ManifestPlacementClass::PreFrameTrailing,
        ManifestPlacementSupport {
            support: SupportState::Native,
            max_bytes: None,
        },
    );
    AdapterManifest {
        contract_version: SCHEMA_VERSION.to_string(),
        adapter_id: FAKE_ID.into(),
        adapter_version: FAKE_VERSION.into(),
        display_name: "Fake".into(),
        role: AdapterRole::PrimaryWorker,
        integration_modes: vec![IntegrationMode::NativeHook],
        lifecycle_events: BTreeMap::new(),
        placement,
        context_pressure: ManifestContextPressure {
            support: SupportState::Native,
            evidence: None,
        },
        receipts: ManifestReceipts {
            native: false,
            lifeloop_synthesized: true,
            receipt_ledger: SupportState::Unavailable,
        },
        session_identity: None,
        session_rename: None,
        renewal: None,
        approval_surface: None,
        failure_modes: Vec::new(),
        telemetry_sources: Vec::new(),
        known_degradations: Vec::new(),
    }
}

struct Fixture(AdapterManifest);

impl AdapterRegistry for Fixture {
    fn resolve(&self, id: &str, version: &str) -> AdapterResolution {
        if id != self.0.adapter_id {
            return AdapterResolution::UnknownId;
        }
        if version != self.0.adapter_version {
            return AdapterResolution::VersionMismatch {
                registered_version: self.0.adapter_version.clone(),
            };
        }
        AdapterResolution::Found(RegisteredAdapter {
            manifest: self.0.clone(),
            conformance: ConformanceLevel::PreConformance,
        })
    }
}

fn frame_request_with(idem: Option<&str>, run_id: Option<&str>, event_id: &str) -> CallbackRequest {
    CallbackRequest {
        schema_version: SCHEMA_VERSION.to_string(),
        event: LifecycleEventKind::FrameOpening,
        event_id: event_id.into(),
        adapter_id: FAKE_ID.into(),
        adapter_version: FAKE_VERSION.into(),
        integration_mode: IntegrationMode::NativeHook,
        invocation_id: format!("inv-{event_id}"),
        harness_session_id: Some("sess-1".into()),
        harness_run_id: run_id.map(String::from),
        harness_task_id: None,
        frame_context: Some(FrameContext::top_level("frm-1")),
        capability_snapshot_ref: None,
        payload_refs: vec![PayloadRef {
            payload_id: "pay-1".into(),
            payload_kind: "instruction_frame".into(),
            content_digest: None,
            byte_size: Some(11),
        }],
        sequence: None,
        idempotency_key: idem.map(String::from),
        metadata: serde_json::Map::new(),
    }
}

fn build_plan(idem: Option<&str>, run_id: Option<&str>, event_id: &str) -> RoutingPlan {
    let fx = Fixture(manifest());
    route(&frame_request_with(idem, run_id, event_id), &fx).expect("plan builds")
}

fn opaque_payload(body: &str) -> PayloadEnvelope {
    PayloadEnvelope {
        schema_version: SCHEMA_VERSION.to_string(),
        payload_id: "pay-1".into(),
        client_id: CLIENT_ID.into(),
        payload_kind: "instruction_frame".into(),
        format: "client-defined".into(),
        content_encoding: "utf8".into(),
        body: Some(body.into()),
        body_ref: None,
        byte_size: body.len() as u64,
        content_digest: None,
        acceptable_placements: vec![AcceptablePlacement {
            placement: PlacementClass::PrePromptFrame,
            requirement: RequirementLevel::Preferred,
        }],
        idempotency_key: None,
        expires_at_epoch_s: None,
        redaction: None,
        metadata: serde_json::Map::new(),
    }
}

fn satisfied_request() -> CapabilityRequest {
    CapabilityRequest::new().with(CapabilityRequirement::preferred(
        CapabilityKind::ContextPressure,
        SupportState::Native,
    ))
}

fn unsupported_request() -> CapabilityRequest {
    // ReceiptLedger is Unavailable in the manifest -> Unsupported when Required.
    CapabilityRequest::new().with(CapabilityRequirement::required(
        CapabilityKind::ReceiptLedger,
        SupportState::Native,
    ))
}

fn requires_operator_request() -> CapabilityRequest {
    // ReceiptLedger is Unavailable, but if we say Required+Manual the
    // outcome is Unsupported, not RequiresOperator. To trigger
    // RequiresOperator we need a manifest claim that is exactly
    // Manual against a Required non-Manual request; fall back to
    // building the NegotiatedPlan via a manifest tweak.
    // (Tested via direct construction in `requires_operator_failed_status` below.)
    CapabilityRequest::new()
}

fn ctx(receipt_id: &str, run_id: Option<&str>) -> ReceiptContext {
    ReceiptContext {
        client_id: CLIENT_ID.into(),
        receipt_id: receipt_id.into(),
        parent_receipt_id: None,
        at_epoch_s: 1_700_000_000,
        harness_session_id: Some("sess-1".into()),
        harness_run_id: run_id.map(String::from),
        harness_task_id: None,
    }
}

// ===========================================================================
// Status mapping: covers all 5 ReceiptStatus variants
// ===========================================================================

#[test]
fn delivered_status_when_satisfied_and_client_delivered() {
    let plan = build_plan(Some("idem-1"), Some("run-A"), "evt-1");
    let payloads = vec![opaque_payload("hello")];
    let neg = negotiate(&plan, &satisfied_request(), &payloads);
    let response = CallbackResponse::ok(ReceiptStatus::Delivered);

    let emitter = LifeloopReceiptEmitter::in_memory();
    let receipt = emitter
        .synthesize_and_emit(&neg, &response, &ctx("rec-1", Some("run-A")))
        .expect("emit ok");
    assert_eq!(receipt.status, ReceiptStatus::Delivered);
    assert_eq!(receipt.failure_class, None);
    assert_eq!(receipt.retry_class, None);
}

#[test]
fn degraded_status_when_negotiation_degraded() {
    // Preferred capability with Native desired; manifest's
    // SessionRename is unset (Unavailable) → demoted to degraded.
    let req = CapabilityRequest::new().with(CapabilityRequirement::preferred(
        CapabilityKind::SessionRename,
        SupportState::Native,
    ));
    let plan = build_plan(Some("idem-2"), Some("run-A"), "evt-2");
    let payloads = vec![opaque_payload("hello")];
    let neg = negotiate(&plan, &req, &payloads);

    let response = CallbackResponse::ok(ReceiptStatus::Delivered);
    let emitter = LifeloopReceiptEmitter::in_memory();
    let receipt = emitter
        .synthesize_and_emit(&neg, &response, &ctx("rec-2", Some("run-A")))
        .expect("emit ok");
    assert_eq!(receipt.status, ReceiptStatus::Degraded);
    assert!(!receipt.capability_degradations.is_empty());
}

#[test]
fn skipped_status_when_client_skipped() {
    let plan = build_plan(Some("idem-3"), Some("run-A"), "evt-3");
    let payloads = vec![opaque_payload("hello")];
    let neg = negotiate(&plan, &satisfied_request(), &payloads);
    let response = CallbackResponse::ok(ReceiptStatus::Skipped);

    let emitter = LifeloopReceiptEmitter::in_memory();
    let receipt = emitter
        .synthesize_and_emit(&neg, &response, &ctx("rec-3", Some("run-A")))
        .expect("emit ok");
    assert_eq!(receipt.status, ReceiptStatus::Skipped);
}

#[test]
fn observed_status_when_client_observed() {
    let plan = build_plan(Some("idem-4"), Some("run-A"), "evt-4");
    let payloads = vec![opaque_payload("hello")];
    let neg = negotiate(&plan, &satisfied_request(), &payloads);
    let response = CallbackResponse::ok(ReceiptStatus::Observed);

    let emitter = LifeloopReceiptEmitter::in_memory();
    let receipt = emitter
        .synthesize_and_emit(&neg, &response, &ctx("rec-4", Some("run-A")))
        .expect("emit ok");
    assert_eq!(receipt.status, ReceiptStatus::Observed);
}

#[test]
fn failed_status_when_negotiation_unsupported() {
    let plan = build_plan(Some("idem-5"), Some("run-A"), "evt-5");
    let payloads = vec![opaque_payload("hello")];
    let neg = negotiate(&plan, &unsupported_request(), &payloads);
    // Even if the client says Delivered, an unsupported negotiation
    // outcome blocks dispatch and produces a Failed receipt.
    let response = CallbackResponse::ok(ReceiptStatus::Delivered);

    let emitter = LifeloopReceiptEmitter::in_memory();
    let receipt = emitter
        .synthesize_and_emit(&neg, &response, &ctx("rec-5", Some("run-A")))
        .expect("emit ok");
    assert_eq!(receipt.status, ReceiptStatus::Failed);
    assert_eq!(
        receipt.failure_class,
        Some(FailureClass::CapabilityUnsupported)
    );
    assert!(receipt.retry_class.is_some());
}

#[test]
fn renewal_payload_delivery_requirement_fails_when_manifest_omits_renewal() {
    let plan = build_plan(
        Some("idem-renewal-unsupported"),
        Some("run-A"),
        "evt-renewal-unsupported",
    );
    let req = CapabilityRequest::new().with(CapabilityRequirement::required(
        CapabilityKind::RenewalContinuationPayloadDelivery,
        SupportState::Partial,
    ));
    let neg = negotiate(&plan, &req, &[opaque_payload("renewal continuation")]);
    assert_eq!(neg.outcome, NegotiationOutcome::Unsupported);

    let response = CallbackResponse::ok(ReceiptStatus::Delivered);
    let emitter = LifeloopReceiptEmitter::in_memory();
    let receipt = emitter
        .synthesize_and_emit(
            &neg,
            &response,
            &ctx("rec-renewal-unsupported", Some("run-A")),
        )
        .expect("emit ok");

    assert_eq!(receipt.status, ReceiptStatus::Failed);
    assert_eq!(
        receipt.failure_class,
        Some(FailureClass::CapabilityUnsupported)
    );
    assert_eq!(receipt.retry_class, Some(RetryClass::DoNotRetry));
    assert_eq!(
        receipt.warnings[0].capability.as_deref(),
        Some("renewal.continuation.payload_delivery")
    );
}

#[test]
fn failed_status_when_client_returns_failure() {
    let plan = build_plan(Some("idem-6"), Some("run-A"), "evt-6");
    let payloads = vec![opaque_payload("hello")];
    let neg = negotiate(&plan, &satisfied_request(), &payloads);
    let response = CallbackResponse::failed(FailureClass::TransportError);

    let emitter = LifeloopReceiptEmitter::in_memory();
    let receipt = emitter
        .synthesize_and_emit(&neg, &response, &ctx("rec-6", Some("run-A")))
        .expect("emit ok");
    assert_eq!(receipt.status, ReceiptStatus::Failed);
    assert_eq!(receipt.failure_class, Some(FailureClass::TransportError));
}

#[test]
fn requires_operator_failed_status_with_operator_required_failure_class() {
    // RequiresOperator from negotiation: tweak the manifest so a
    // Required Native capability is only Manual in the manifest.
    let mut m = manifest();
    m.session_identity = Some(lifeloop::ManifestSessionIdentity {
        harness_session_id: SupportState::Manual,
        harness_run_id: SupportState::Native,
        harness_task_id: SupportState::Native,
    });
    let plan = {
        let fx = Fixture(m);
        route(
            &frame_request_with(Some("idem-7"), Some("run-A"), "evt-7"),
            &fx,
        )
        .expect("plan builds")
    };
    let req = CapabilityRequest::new().with(CapabilityRequirement::required(
        CapabilityKind::HarnessSessionId,
        SupportState::Native,
    ));
    let payloads = vec![opaque_payload("hello")];
    let neg = negotiate(&plan, &req, &payloads);
    assert_eq!(
        neg.outcome,
        lifeloop::NegotiationOutcome::RequiresOperator,
        "precondition: negotiation produces RequiresOperator"
    );

    let response = CallbackResponse::ok(ReceiptStatus::Delivered);
    let emitter = LifeloopReceiptEmitter::in_memory();
    let receipt = emitter
        .synthesize_and_emit(&neg, &response, &ctx("rec-7", Some("run-A")))
        .expect("emit ok");
    assert_eq!(receipt.status, ReceiptStatus::Failed);
    assert_eq!(receipt.failure_class, Some(FailureClass::OperatorRequired));
}

#[test]
fn failed_placement_decision_defensively_fails_receipt_status() {
    let plan = build_plan(Some("idem-place-fail"), Some("run-A"), "evt-place-fail");
    let mut payload = opaque_payload("hello");
    payload.acceptable_placements[0].placement = PlacementClass::DeveloperEquivalentFrame;
    let mut neg = negotiate(&plan, &satisfied_request(), std::slice::from_ref(&payload));
    assert_eq!(
        neg.failure_class,
        Some(FailureClass::PlacementUnavailable),
        "precondition: negotiation recorded the placement failure"
    );

    neg.outcome = NegotiationOutcome::Satisfied;
    neg.failure_class = None;
    let response = CallbackResponse::ok(ReceiptStatus::Delivered);
    let emitter = LifeloopReceiptEmitter::in_memory();
    let receipt = emitter
        .synthesize_and_emit(&neg, &response, &ctx("rec-place-fail", Some("run-A")))
        .expect("emit ok");

    assert_eq!(receipt.status, ReceiptStatus::Failed);
    assert_eq!(
        receipt.failure_class,
        Some(FailureClass::PlacementUnavailable)
    );
}

#[test]
fn receipt_merges_negotiation_and_response_warnings() {
    let req = CapabilityRequest::new().with(CapabilityRequirement::preferred(
        CapabilityKind::SessionRename,
        SupportState::Native,
    ));
    let plan = build_plan(Some("idem-warn"), Some("run-A"), "evt-warn");
    let payloads = vec![opaque_payload("hello")];
    let neg = negotiate(&plan, &req, &payloads);
    assert_eq!(neg.warnings.len(), 1);

    let mut response = CallbackResponse::ok(ReceiptStatus::Delivered);
    response.warnings.push(Warning {
        code: "client_warning".into(),
        message: "client surfaced a warning".into(),
        capability: None,
    });
    let emitter = LifeloopReceiptEmitter::in_memory();
    let receipt = emitter
        .synthesize_and_emit(&neg, &response, &ctx("rec-warn", Some("run-A")))
        .expect("emit ok");
    let warning_codes: Vec<_> = receipt.warnings.iter().map(|w| w.code.as_str()).collect();

    assert_eq!(warning_codes, vec!["capability_degraded", "client_warning"]);
}

// ===========================================================================
// Idempotency
// ===========================================================================

#[test]
fn idempotent_replay_returns_prior_receipt_unchanged() {
    let plan = build_plan(Some("idem-replay"), Some("run-A"), "evt-r1");
    let payloads = vec![opaque_payload("hello")];
    let neg = negotiate(&plan, &satisfied_request(), &payloads);
    let response = CallbackResponse::ok(ReceiptStatus::Delivered);

    let emitter = LifeloopReceiptEmitter::in_memory();
    let first = emitter
        .synthesize_and_emit(&neg, &response, &ctx("rec-replay", Some("run-A")))
        .expect("first emit");
    let second = emitter
        .synthesize_and_emit(&neg, &response, &ctx("rec-replay", Some("run-A")))
        .expect("replay should be no-op");
    assert_eq!(first, second, "replay must return the prior receipt");
}

#[test]
fn idempotency_conflict_on_different_content() {
    let plan = build_plan(Some("idem-conflict"), Some("run-A"), "evt-c1");
    let payloads = vec![opaque_payload("hello")];
    let neg = negotiate(&plan, &satisfied_request(), &payloads);
    let response_ok = CallbackResponse::ok(ReceiptStatus::Delivered);
    let response_fail = CallbackResponse::failed(FailureClass::TransportError);

    let emitter = LifeloopReceiptEmitter::in_memory();
    emitter
        .synthesize_and_emit(&neg, &response_ok, &ctx("rec-c1", Some("run-A")))
        .expect("first emit");
    let err = emitter
        .synthesize_and_emit(&neg, &response_fail, &ctx("rec-c2", Some("run-A")))
        .expect_err("conflicting content must be rejected");
    assert!(
        matches!(err, ReceiptError::Conflict { .. }),
        "expected ReceiptError::Conflict, got {err:?}"
    );
}

#[test]
fn sequencer_peek_and_store_size_track_emitted_receipts() {
    let emitter = LifeloopReceiptEmitter::in_memory();
    assert_eq!(emitter.sequencer().peek("run-A"), 0);
    assert_eq!(emitter.store().len(), 0);
    assert!(emitter.store().is_empty());

    let plan = build_plan(Some("idem-store"), Some("run-A"), "evt-store");
    let payloads = vec![opaque_payload("hello")];
    let neg = negotiate(&plan, &satisfied_request(), &payloads);
    let response = CallbackResponse::ok(ReceiptStatus::Delivered);
    let receipt = emitter
        .synthesize_and_emit(&neg, &response, &ctx("rec-store", Some("run-A")))
        .expect("emit ok");

    assert_eq!(receipt.sequence, Some(1));
    assert_eq!(emitter.sequencer().peek("run-A"), 1);
    assert_eq!(emitter.store().len(), 1);
    assert!(!emitter.store().is_empty());
}

// ===========================================================================
// Sequence scoping by lifeloop_run_id
// ===========================================================================

#[test]
fn sequence_scopes_per_lifeloop_run_id() {
    // A=1, B=1, A=2 — independent counters per run.
    let emitter = LifeloopReceiptEmitter::in_memory();
    let response = CallbackResponse::ok(ReceiptStatus::Delivered);
    let payloads = vec![opaque_payload("hello")];

    let plan_a1 = build_plan(Some("idem-A1"), Some("run-A"), "evt-A1");
    let neg_a1 = negotiate(&plan_a1, &satisfied_request(), &payloads);
    let r_a1 = emitter
        .synthesize_and_emit(&neg_a1, &response, &ctx("rec-A1", Some("run-A")))
        .expect("emit A1");

    let plan_b1 = build_plan(Some("idem-B1"), Some("run-B"), "evt-B1");
    let neg_b1 = negotiate(&plan_b1, &satisfied_request(), &payloads);
    let r_b1 = emitter
        .synthesize_and_emit(&neg_b1, &response, &ctx("rec-B1", Some("run-B")))
        .expect("emit B1");

    let plan_a2 = build_plan(Some("idem-A2"), Some("run-A"), "evt-A2");
    let neg_a2 = negotiate(&plan_a2, &satisfied_request(), &payloads);
    let r_a2 = emitter
        .synthesize_and_emit(&neg_a2, &response, &ctx("rec-A2", Some("run-A")))
        .expect("emit A2");

    assert_eq!(r_a1.sequence, Some(1));
    assert_eq!(r_b1.sequence, Some(1));
    assert_eq!(r_a2.sequence, Some(2));
}

#[test]
fn sequence_is_none_without_run_id() {
    let plan = build_plan(Some("idem-norun"), None, "evt-nr");
    let payloads = vec![opaque_payload("hello")];
    let neg = negotiate(&plan, &satisfied_request(), &payloads);
    let response = CallbackResponse::ok(ReceiptStatus::Delivered);

    let emitter = LifeloopReceiptEmitter::in_memory();
    let receipt = emitter
        .synthesize_and_emit(&neg, &response, &ctx("rec-nr", None))
        .expect("emit ok");
    assert_eq!(receipt.sequence, None);
}

// ===========================================================================
// receipt.emitted is a notification; no receipt produced
// ===========================================================================

#[test]
fn receipt_emitted_event_is_rejected_at_emit_time() {
    let mut req = frame_request_with(Some("idem-rej"), Some("run-A"), "evt-rej");
    req.event = LifecycleEventKind::ReceiptEmitted;
    req.frame_context = None; // ReceiptEmitted is not frame-scoped
    let fx = Fixture(manifest());
    let plan = match route(&req, &fx) {
        Ok(p) => p,
        // If validation already rejects ReceiptEmitted at the route
        // boundary, that's fine — #15 will tighten this; here we
        // only need to prove the emitter cannot record one.
        Err(_) => return,
    };
    let payloads = vec![opaque_payload("hello")];
    let neg = negotiate(&plan, &satisfied_request(), &payloads);
    let response = CallbackResponse::ok(ReceiptStatus::Delivered);

    let emitter = LifeloopReceiptEmitter::in_memory();
    let err = emitter
        .synthesize_and_emit(&neg, &response, &ctx("rec-rej", Some("run-A")))
        .expect_err("receipt.emitted must not produce a receipt");
    assert!(
        matches!(err, ReceiptError::ReceiptEmittedNotEmittable),
        "expected ReceiptEmittedNotEmittable, got {err:?}"
    );
}

// ===========================================================================
// Payload body opacity
// ===========================================================================

#[test]
fn payload_body_bytes_pass_through_emission_unchanged() {
    let plan = build_plan(Some("idem-opa"), Some("run-A"), "evt-opa");
    let original = "opaque-payload-bytes-do-not-mutate";
    let payload = opaque_payload(original);
    let payload_clone = payload.clone();
    let neg = negotiate(
        &plan,
        &satisfied_request(),
        std::slice::from_ref(&payload_clone),
    );
    let response = CallbackResponse::ok(ReceiptStatus::Delivered);

    let emitter = LifeloopReceiptEmitter::in_memory();
    let _receipt = emitter
        .synthesize_and_emit(&neg, &response, &ctx("rec-opa", Some("run-A")))
        .expect("emit ok");
    assert_eq!(payload.body.as_deref(), Some(original));
    assert_eq!(payload_clone.body.as_deref(), Some(original));
}

// ===========================================================================
// Validation: required fields
// ===========================================================================

#[test]
fn empty_receipt_id_is_rejected_by_validation() {
    let plan = build_plan(Some("idem-val"), Some("run-A"), "evt-val");
    let payloads = vec![opaque_payload("hello")];
    let neg = negotiate(&plan, &satisfied_request(), &payloads);
    let response = CallbackResponse::ok(ReceiptStatus::Delivered);
    let mut bad_ctx = ctx("rec-val", Some("run-A"));
    bad_ctx.receipt_id = String::new();

    let emitter = LifeloopReceiptEmitter::in_memory();
    let err = emitter
        .synthesize_and_emit(&neg, &response, &bad_ctx)
        .expect_err("empty receipt_id must be rejected");
    assert!(
        matches!(err, ReceiptError::Invalid(_)),
        "expected Invalid(_), got {err:?}"
    );
}

#[test]
fn receipt_carries_required_contract_fields() {
    let plan = build_plan(Some("idem-fields"), Some("run-A"), "evt-fields");
    let payloads = vec![opaque_payload("hello")];
    let neg = negotiate(&plan, &satisfied_request(), &payloads);
    let response = CallbackResponse::ok(ReceiptStatus::Delivered);

    let emitter = LifeloopReceiptEmitter::in_memory();
    let r = emitter
        .synthesize_and_emit(&neg, &response, &ctx("rec-fields", Some("run-A")))
        .expect("emit ok");
    assert_eq!(r.schema_version, SCHEMA_VERSION);
    assert_eq!(r.adapter_id, FAKE_ID);
    assert_eq!(r.event, LifecycleEventKind::FrameOpening);
    assert_eq!(r.event_id, "evt-fields");
    assert_eq!(r.client_id, CLIENT_ID);
    assert_eq!(r.receipt_id, "rec-fields");
    assert_eq!(r.invocation_id, "inv-evt-fields");
    assert_eq!(r.harness_run_id.as_deref(), Some("run-A"));
    assert_eq!(r.integration_mode, IntegrationMode::NativeHook);
    assert_eq!(r.idempotency_key.as_deref(), Some("idem-fields"));
    assert!(r.sequence.is_some());
    assert_eq!(r.payload_receipts.len(), 1);
    assert_eq!(r.payload_receipts[0].payload_id, "pay-1");
    assert_eq!(r.payload_receipts[0].payload_kind, "instruction_frame");
    assert_eq!(r.payload_receipts[0].byte_size, 5);
    assert_eq!(r.payload_receipts[0].content_digest, None);
}

#[test]
fn payload_receipt_provenance_comes_from_negotiated_payload() {
    let plan = build_plan(Some("idem-prov"), Some("run-A"), "evt-prov");
    let mut payload = opaque_payload("hello");
    payload.content_digest = Some("sha256:source".into());
    let neg = negotiate(&plan, &satisfied_request(), std::slice::from_ref(&payload));

    let mut response = CallbackResponse::ok(ReceiptStatus::Delivered);
    let mut echoed = payload.clone();
    echoed.payload_kind = "client.output".into();
    echoed.content_digest = Some("sha256:response".into());
    response.client_payloads.push(echoed);

    let emitter = LifeloopReceiptEmitter::in_memory();
    let r = emitter
        .synthesize_and_emit(&neg, &response, &ctx("rec-prov", Some("run-A")))
        .expect("emit ok");
    assert_eq!(r.payload_receipts.len(), 1);
    assert_eq!(r.payload_receipts[0].payload_id, "pay-1");
    assert_eq!(r.payload_receipts[0].payload_kind, "instruction_frame");
    assert_eq!(r.payload_receipts[0].byte_size, 5);
    assert_eq!(
        r.payload_receipts[0].content_digest.as_deref(),
        Some("sha256:source")
    );
}

// silence warnings about a helper kept for documentation parallels
#[allow(dead_code)]
fn _docs_only_helper(_: CapabilityRequest) -> CapabilityRequest {
    requires_operator_request()
}
