tea-session 0.1.0

Append-only session log, replay, and storage contracts for tea-rs
Documentation
use crate::common;

use common::{APPROVAL_ID, SESSION_ID, TOOL_CALL_ID, approval_flow, envelope};
use serde_json::{Value, json};
use std::str::FromStr;
use tea_policy::{ActorId, ApprovalRequest, ApprovalResolution, PolicyGrant};
use tea_protocol::{RecordEnvelope, SessionId, SessionSequence};
use tea_session::{
    AppendTransaction, ApprovalArtifactEntry, GrantJournalEntry, InMemorySessionStore,
    SessionStore, SessionStoreErrorCode,
};

const GRANT_ID: &str = "0195a0b1-5e69-70ac-807e-0aa7aa000047";
const CREATED_AT: &str = "2026-07-23T09:30:12.005Z";
const DECIDED_AT: &str = "2026-07-23T09:30:12.006Z";
const EXPIRES_AT: &str = "2026-07-23T09:35:13.010Z";

fn request() -> ApprovalRequest {
    serde_json::from_value(json!({
        "approvalId": APPROVAL_ID,
        "toolCallId": TOOL_CALL_ID,
        "actorId": "user:alice",
        "profileId": "minimal-assistant",
        "sessionId": SESSION_ID,
        "workspaceId": "workspace/main",
        "environment": {"surface":"test","target":"native","metadata":{}},
        "toolName": "write_text_file",
        "toolVersion": "1.0.0",
        "effects": ["fs.write"],
        "resources": [{
            "scheme":"file",
            "locator":"/workspace/notes.txt",
            "access":"write"
        }],
        "createdAt": CREATED_AT,
        "expiresAt": EXPIRES_AT,
        "presentation": {
            "reason":"workspace mutation requires approval",
            "arguments":{"path":"/workspace/notes.txt"},
            "resources":["file:/workspace/notes.txt"]
        }
    }))
    .unwrap()
}

fn grant() -> PolicyGrant {
    serde_json::from_value(json!({
        "id":GRANT_ID,
        "actorId":"user:alice",
        "profileId":"minimal-assistant",
        "toolName":"write_text_file",
        "toolVersion":"1.0.0",
        "effects":["fs.write"],
        "resources":[{"scheme":"file","locatorPrefix":"/workspace/","access":"write"}],
        "scope":{"type":"session_resource","session_id":SESSION_ID},
        "issuedAt":DECIDED_AT
    }))
    .unwrap()
}

fn resolution() -> ApprovalResolution {
    ApprovalResolution::new(
        &request(),
        tea_protocol::ApprovalDecision::AllowSession,
        DECIDED_AT.parse().unwrap(),
        Some(grant()),
    )
    .unwrap()
}

fn approval_request_record() -> RecordEnvelope {
    let mut value = serde_json::to_value(envelope(
        5,
        "approval_requested",
        json!({"approvalId":APPROVAL_ID,"toolCallId":TOOL_CALL_ID,"expiresAt":EXPIRES_AT}),
    ))
    .unwrap();
    value["timestamp"] = json!(CREATED_AT);
    serde_json::from_value(value).unwrap()
}

fn initial_records() -> Vec<RecordEnvelope> {
    let mut records = approval_flow();
    records[5] = approval_request_record();
    records
}

fn resolution_record() -> RecordEnvelope {
    let mut value = serde_json::to_value(envelope(
        6,
        "approval_resolved",
        json!({"approvalId":APPROVAL_ID,"decision":{"type":"allow_session"}}),
    ))
    .unwrap();
    value["timestamp"] = json!(DECIDED_AT);
    serde_json::from_value(value).unwrap()
}

#[tokio::test(flavor = "current_thread")]
async fn request_resolution_grant_and_revocation_are_atomic() {
    let store = InMemorySessionStore::new();
    let session_id = SessionId::from_str(SESSION_ID).unwrap();
    let request_record = approval_request_record();
    let created = store
        .append(
            AppendTransaction::new(session_id, None, initial_records())
                .with_expected_journal_revision(0)
                .with_approval_artifacts([ApprovalArtifactEntry::Requested {
                    record_id: request_record.record_id(),
                    request: request(),
                }]),
        )
        .await
        .unwrap();
    assert_eq!(created.journal_revision(), 1);

    let resolved_record = resolution_record();
    let resolution = resolution();
    let issued = store
        .append(
            AppendTransaction::new(
                session_id,
                Some(SessionSequence::new(5)),
                vec![resolved_record.clone()],
            )
            .with_expected_journal_revision(1)
            .with_approval_artifacts([ApprovalArtifactEntry::Resolved {
                record_id: resolved_record.record_id(),
                resolution: resolution.clone(),
            }])
            .with_grant_entries([GrantJournalEntry::Issued {
                approval_record_id: resolved_record.record_id(),
                grant: grant(),
            }]),
        )
        .await
        .unwrap();
    assert_eq!(issued.journal_revision(), 3);
    assert_eq!(
        store.load(session_id).await.unwrap().active_grants().len(),
        1
    );
    assert_eq!(
        store
            .active_grants_for_actor(ActorId::from_str("user:alice").unwrap())
            .await
            .unwrap(),
        vec![grant()]
    );

    let revoked = grant()
        .revoke("2026-07-23T10:00:00.000Z".parse().unwrap())
        .unwrap();
    let revoked_outcome = store
        .append(
            AppendTransaction::new(session_id, Some(SessionSequence::new(6)), vec![])
                .with_expected_journal_revision(3)
                .with_grant_entries([GrantJournalEntry::Revoked { grant: revoked }]),
        )
        .await
        .unwrap();
    assert_eq!(revoked_outcome.current_sequence(), SessionSequence::new(6));
    assert_eq!(revoked_outcome.journal_revision(), 4);
    assert!(
        store
            .load(session_id)
            .await
            .unwrap()
            .active_grants()
            .is_empty()
    );
}

#[tokio::test(flavor = "current_thread")]
async fn mismatched_artifact_and_stale_journal_roll_back() {
    let store = InMemorySessionStore::new();
    let session_id = SessionId::from_str(SESSION_ID).unwrap();
    let request_record = approval_request_record();
    let mut value = serde_json::to_value(request()).unwrap();
    value["toolCallId"] = json!("0195a0b1-5e70-7c8d-9e2f-0aa7aa000048");
    let wrong_request: ApprovalRequest = serde_json::from_value(value).unwrap();
    let error = store
        .append(
            AppendTransaction::new(session_id, None, initial_records())
                .with_expected_journal_revision(0)
                .with_approval_artifacts([ApprovalArtifactEntry::Requested {
                    record_id: request_record.record_id(),
                    request: wrong_request,
                }]),
        )
        .await
        .unwrap_err();
    assert_eq!(error.code(), SessionStoreErrorCode::InvalidRecord);
    assert_eq!(
        store.load(session_id).await.unwrap_err().code(),
        SessionStoreErrorCode::SessionNotFound
    );

    store
        .append(
            AppendTransaction::new(session_id, None, initial_records())
                .with_expected_journal_revision(0)
                .with_approval_artifacts([ApprovalArtifactEntry::Requested {
                    record_id: request_record.record_id(),
                    request: request(),
                }]),
        )
        .await
        .unwrap();
    let error = store
        .append(
            AppendTransaction::new(session_id, Some(SessionSequence::new(5)), vec![])
                .with_expected_journal_revision(0)
                .with_grant_entries([GrantJournalEntry::Revoked { grant: grant() }]),
        )
        .await
        .unwrap_err();
    assert_eq!(error.code(), SessionStoreErrorCode::SequenceConflict);
    assert_eq!(
        store.load(session_id).await.unwrap().grant_journal().len(),
        0
    );
}

#[test]
fn artifact_values_round_trip() {
    let entries = vec![
        ApprovalArtifactEntry::Requested {
            record_id: approval_request_record().record_id(),
            request: request(),
        },
        ApprovalArtifactEntry::Resolved {
            record_id: resolution_record().record_id(),
            resolution: resolution(),
        },
    ];
    let value: Value = serde_json::to_value(&entries).unwrap();
    assert_eq!(
        serde_json::from_value::<Vec<ApprovalArtifactEntry>>(value).unwrap(),
        entries
    );
}