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}