tandem-server 0.6.0

HTTP server for Tandem engine APIs
#[tokio::test]
async fn memory_promote_blocks_rejected_source_outcome() {
    let state = test_state().await;
    let app = app_router(state.clone());
    let mut rx = state.event_bus.subscribe();

    let capability = json!({
        "run_id": "run-3-rejected",
        "subject": "reviewer-user",
        "org_id": "org-1",
        "workspace_id": "ws-1",
        "project_id": "proj-1",
        "memory": {
            "read_tiers": ["session", "project"],
            "write_tiers": ["session"],
            "promote_targets": ["project"],
            "require_review_for_promote": false,
            "allow_auto_use_tiers": ["curated"]
        },
        "expires_at": 9999999999999u64
    });

    let put_req = Request::builder()
        .method("POST")
        .uri("/memory/put")
        .header("content-type", "application/json")
        .body(Body::from(
            json!({
                "run_id": "run-3-rejected",
                "partition": {
                    "org_id": "org-1",
                    "workspace_id": "ws-1",
                    "project_id": "proj-1",
                    "tier": "session"
                },
                "kind": "fact",
                "content": "rejected outcome must not become reusable memory",
                "artifact_refs": ["artifact://run-3-rejected/output.json"],
                "classification": "internal",
                "metadata": {
                    "source_outcome": {
                        "status": "rejected",
                        "approved": false,
                        "source_run_id": "run-3-rejected",
                        "approval_id": "appr-denied"
                    }
                },
                "capability": capability
            })
            .to_string(),
        ))
        .expect("put request");
    let put_resp = app.clone().oneshot(put_req).await.expect("put response");
    assert_eq!(put_resp.status(), StatusCode::OK);
    let put_body = to_bytes(put_resp.into_body(), usize::MAX)
        .await
        .expect("put body");
    let put_payload: Value = serde_json::from_slice(&put_body).expect("put json");
    let memory_id = put_payload
        .get("id")
        .and_then(Value::as_str)
        .expect("memory id")
        .to_string();
    let _ = next_event_of_type(&mut rx, "memory.put").await;
    let _ = next_event_of_type(&mut rx, "memory.updated").await;

    let promote_req = Request::builder()
        .method("POST")
        .uri("/memory/promote")
        .header("content-type", "application/json")
        .body(Body::from(
            json!({
                "run_id": "run-3-rejected",
                "source_memory_id": memory_id,
                "from_tier": "session",
                "to_tier": "project",
                "partition": {
                    "org_id": "org-1",
                    "workspace_id": "ws-1",
                    "project_id": "proj-1",
                    "tier": "session"
                },
                "reason": "try rejected source",
                "review": {
                    "required": false
                },
                "source_outcome": {
                    "status": "approved",
                    "approved": true
                },
                "capability": capability
            })
            .to_string(),
        ))
        .expect("promote request");
    let promote_resp = app
        .clone()
        .oneshot(promote_req)
        .await
        .expect("promote response");
    assert_eq!(promote_resp.status(), StatusCode::OK);
    let promote_body = to_bytes(promote_resp.into_body(), usize::MAX)
        .await
        .expect("promote body");
    let promote_payload: Value = serde_json::from_slice(&promote_body).expect("promote json");
    assert_eq!(
        promote_payload.get("promoted").and_then(Value::as_bool),
        Some(false)
    );
    let promote_policy_decision_id = promote_payload
        .get("policy_decision_id")
        .and_then(Value::as_str)
        .expect("blocked promote policy decision id")
        .to_string();
    let policy_record = state
        .get_policy_decision(&promote_policy_decision_id)
        .await
        .expect("blocked promote policy decision");
    assert_eq!(
        policy_record.decision,
        tandem_types::PolicyDecisionEffect::Deny
    );
    assert_eq!(policy_record.reason_code, "source_outcome_not_approved");

    let promote_event = next_event_of_type(&mut rx, "memory.promote").await;
    assert_eq!(
        promote_event.properties.get("status").and_then(Value::as_str),
        Some("blocked")
    );
    assert_eq!(
        promote_event
            .properties
            .get("policyDecisionID")
            .and_then(Value::as_str),
        Some(promote_policy_decision_id.as_str())
    );
    assert!(promote_event
        .properties
        .get("detail")
        .and_then(Value::as_str)
        .is_some_and(|detail| detail.contains("source outcome not approved")));

    let search_req = Request::builder()
        .method("POST")
        .uri("/memory/search")
        .header("content-type", "application/json")
        .body(Body::from(
            json!({
                "run_id": "run-3-rejected",
                "query": "rejected outcome reusable memory",
                "read_scopes": ["project"],
                "partition": {
                    "org_id": "org-1",
                    "workspace_id": "ws-1",
                    "project_id": "proj-1",
                    "tier": "project"
                },
                "capability": capability,
                "limit": 5
            })
            .to_string(),
        ))
        .expect("search request");
    let search_resp = app
        .clone()
        .oneshot(search_req)
        .await
        .expect("search response");
    assert_eq!(search_resp.status(), StatusCode::OK);
    let search_body = to_bytes(search_resp.into_body(), usize::MAX)
        .await
        .expect("search body");
    let search_payload: Value = serde_json::from_slice(&search_body).expect("search json");
    assert_eq!(
        search_payload
            .get("results")
            .and_then(Value::as_array)
            .map(Vec::len),
        Some(0)
    );
}