Skip to main content

entelix_core/
thread_key.rs

1//! `ThreadKey` — the canonical `(tenant_id, thread_id)` addressing
2//! tuple for every persistence and session operation in entelix.
3//!
4//! Encodes Invariant 11 (multi-tenant isolation) at the type level so
5//! impls — `Checkpointer<S>`, `SessionLog`, future per-tenant state
6//! handlers — cannot accidentally drop the tenant scope. A backend
7//! receiving a `&ThreadKey` parameter cannot run a query missing the
8//! `WHERE tenant_id = …` clause, because the tenant component is
9//! syntactically required to read the thread component.
10//!
11//! Lives in `entelix-core` (rather than `entelix-graph`) so all
12//! tenant-scoped subsystems — checkpointer, session log, future memory
13//! companion crates — can address through one type without taking an
14//! upward dependency on `entelix-graph`.
15
16use serde::{Deserialize, Serialize};
17
18use crate::context::ExecutionContext;
19use crate::error::{Error, Result};
20use crate::tenant_id::TenantId;
21
22/// Canonical addressing tuple for every tenant-scoped persistence
23/// operation — `(tenant_id, thread_id)`. Encodes Invariant 11
24/// (multi-tenant isolation) at the type level so impls cannot
25/// accidentally drop the tenant scope. The `tenant_id` carries the
26/// validating [`TenantId`] newtype, so a `ThreadKey` whose serde
27/// payload arrived with an empty tenant is rejected at deserialize
28/// time rather than constructed and then silently mis-routed.
29#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
30pub struct ThreadKey {
31    tenant_id: TenantId,
32    thread_id: String,
33}
34
35impl ThreadKey {
36    /// Build a key from a [`TenantId`] (already validated by its
37    /// constructor) and a `thread_id` literal.
38    ///
39    /// # Panics
40    ///
41    /// Panics when `thread_id` is empty — empty would silently
42    /// produce ambiguous keys (`"tenant:"`) that collide across
43    /// logically distinct callers, defeating the Invariant 11
44    /// isolation this type exists to enforce. Use [`Self::from_ctx`]
45    /// in production paths; `new` is intended for tests and
46    /// migration tooling that have already validated the inputs.
47    #[must_use]
48    pub fn new(tenant_id: TenantId, thread_id: impl Into<String>) -> Self {
49        let thread_id = thread_id.into();
50        assert!(
51            !thread_id.is_empty(),
52            "ThreadKey::new: thread_id must be non-empty"
53        );
54        Self {
55            tenant_id,
56            thread_id,
57        }
58    }
59
60    /// Derive a key from an [`ExecutionContext`]. Returns
61    /// [`Error::Config`] when the context carries no `thread_id` or
62    /// the `thread_id` is empty — every persistence call requires
63    /// both components be non-empty so two rows under different
64    /// intent cannot share a key. The `tenant_id` is taken from the
65    /// context's [`TenantId`] (already validated).
66    pub fn from_ctx(ctx: &ExecutionContext) -> Result<Self> {
67        let thread_id = ctx.thread_id().ok_or_else(|| {
68            Error::config(
69                "ThreadKey::from_ctx requires ExecutionContext::thread_id; \
70                 set it via ExecutionContext::with_thread_id(...)",
71            )
72        })?;
73        if thread_id.is_empty() {
74            return Err(Error::config(
75                "ThreadKey::from_ctx: thread_id must be non-empty",
76            ));
77        }
78        Ok(Self {
79            tenant_id: ctx.tenant_id().clone(),
80            thread_id: thread_id.to_owned(),
81        })
82    }
83
84    /// Borrow the tenant scope.
85    #[must_use]
86    pub const fn tenant_id(&self) -> &TenantId {
87        &self.tenant_id
88    }
89
90    /// Borrow the conversation thread identifier.
91    #[must_use]
92    pub fn thread_id(&self) -> &str {
93        &self.thread_id
94    }
95}