Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ec6dca7
docs(adr): ADR-0153 declared composite-key index — negative-existence…
rita-aga Jun 24, 2026
d2aac86
feat(store-postgres): tag projection-index-fields metric by entity_ty…
rita-aga Jun 24, 2026
769a9ef
feat(stores): add entity_key_index table — declared composite-key ind…
rita-aga Jun 24, 2026
4aa75ec
feat(spec): [[key]] unique-key declaration in IOA spec (ADR-0153, ARN…
rita-aga Jun 24, 2026
da5af69
feat(server): canonical type-tagged key hash for entity_key_index (AD…
rita-aga Jun 24, 2026
e03fea1
feat(runtime): EventStore::append_with_keys co-commit contract (ADR-0…
rita-aga Jun 24, 2026
cd2d0f7
feat(server): lookup_by_key read contract + store forwarding (ADR-015…
rita-aga Jun 24, 2026
c1b846e
feat: entity_key_index co-commit + keyed read — DST present/absent gr…
rita-aga Jun 24, 2026
7823470
test(dst): co-commit atomicity on uniqueness reject — validate key be…
rita-aga Jun 24, 2026
befd870
feat(store-postgres): append_with_keys + lookup_by_key — entity_key_i…
rita-aga Jun 24, 2026
e550630
test(store-postgres): live entity_key_index present/absent + atomicit…
rita-aga Jun 24, 2026
bc0d86a
feat(server): read-plane fast path — resolve declared key via lookup_…
rita-aga Jun 24, 2026
c0f5ade
feat(store-turso): lookup_by_key — keyed entity_key_index probe (ADR-…
rita-aga Jun 24, 2026
d728b1c
feat(query-plane): $filter-equals-declared-key fast path — bounded ke…
rita-aga Jun 24, 2026
b513f72
test(query-plane): keyed $filter resolves to bounded candidate on rea…
rita-aga Jun 24, 2026
e2b1028
test(query-plane): keyed fast-path boundary — declines non-key shapes…
rita-aga Jun 24, 2026
c5b7ab8
test(query-plane): amplification bound — sync co-commit writes K=1 ke…
rita-aga Jun 24, 2026
c7b4924
fix(key-index): case-tolerant field lookup so write/read hashes agree…
rita-aga Jun 24, 2026
9f20a0c
feat: entity_key_index backfill for pre-existing entities (ADR-0153, …
rita-aga Jun 24, 2026
af3e6f7
test(dst): bring the read/projection plane under deterministic simula…
rita-aga Jun 24, 2026
11cd432
style: rustfmt + split temper-store-sim tests into a module (CI: lint…
rita-aga Jun 26, 2026
ecad3a2
fix(clippy): collapse the ADR-0153 keyed fast-path nested if-let into…
rita-aga Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/temper-jit/src/shadow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ mod tests {
entity_name: "Order".into(),
states: vec!["Draft".into(), "Submitted".into(), "Cancelled".into()],
initial_state: "Draft".into(),
keys: vec![],
rules: vec![
TransitionRule {
name: "SubmitOrder".into(),
Expand Down
1 change: 1 addition & 0 deletions crates/temper-jit/src/swap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ mod tests {
entity_name: name.to_string(),
states: vec!["A".into(), "B".into()],
initial_state: "A".into(),
keys: vec![],
rules: vec![TransitionRule {
name: "GoB".into(),
from_states: vec!["A".into()],
Expand Down
8 changes: 8 additions & 0 deletions crates/temper-jit/src/table/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,14 @@ impl TransitionTable {
states: automaton.automaton.states.clone(),
initial_state: automaton.automaton.initial.clone(),
rules,
keys: automaton
.keys
.iter()
.map(|k| super::types::DeclaredKey {
name: k.name.clone(),
properties: k.properties.clone(),
})
.collect(),
state_var_metadata,
composite_actions,
rule_index,
Expand Down
16 changes: 16 additions & 0 deletions crates/temper-jit/src/table/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ use super::guard::{Guard, GuardFailure};
// Core types
// ---------------------------------------------------------------------------

/// A declared unique/alternate key carried on the table (ADR-0153). `name`
/// identifies it; `properties` is the unique property set in canonical order.
/// The actor hashes these on write to maintain the negative-existence access path.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeclaredKey {
pub name: String,
pub properties: Vec<String>,
}

/// A transition table: state machine transitions as DATA, not code.
/// Can be hot-swapped per-actor without restart.
#[derive(Debug, Clone, Serialize)]
Expand All @@ -27,6 +36,9 @@ pub struct TransitionTable {
pub initial_state: String,
/// Ordered list of transition rules.
pub rules: Vec<TransitionRule>,
/// ADR-0153: declared unique/alternate keys the kernel indexes for
/// negative-existence reads. Empty when the spec declared no `[[key]]`.
pub keys: Vec<DeclaredKey>,
/// Per-state-variable metadata for platform primitives (ADR-0045, ADR-0047).
/// Keyed by state-variable name. Empty map when the IOA spec did not
/// declare any per-field overrides.
Expand Down Expand Up @@ -129,6 +141,8 @@ impl<'de> Deserialize<'de> for TransitionTable {
state_var_metadata: BTreeMap<String, StateVarMetadata>,
#[serde(default)]
composite_actions: BTreeMap<String, CompositeActionMetadata>,
#[serde(default)]
keys: Vec<DeclaredKey>,
}

let raw = TransitionTableRaw::deserialize(deserializer)?;
Expand All @@ -137,6 +151,7 @@ impl<'de> Deserialize<'de> for TransitionTable {
states: raw.states,
initial_state: raw.initial_state,
rules: raw.rules,
keys: raw.keys,
state_var_metadata: raw.state_var_metadata,
composite_actions: raw.composite_actions,
rule_index: BTreeMap::new(),
Expand Down Expand Up @@ -250,6 +265,7 @@ mod tests {
entity_name: "TestEntity".to_string(),
states: vec!["Draft".to_string(), "Active".to_string()],
initial_state: "Draft".to_string(),
keys: vec![],
rules: vec![
TransitionRule {
name: "Submit".to_string(),
Expand Down
61 changes: 61 additions & 0 deletions crates/temper-runtime/src/persistence/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ pub trait PersistentActor: Send + 'static {
}
}

/// A declared-key row to co-commit with an append (ADR-0153). The entity claims
/// `key_hash` for `key_name`; the store writes it into `entity_key_index` in the
/// same transaction as the journal append, giving the read plane an `O(log n)`
/// present/absent probe (the negative-existence access path, ARN-68).
#[derive(Debug, Clone)]
pub struct EntityKeyRow {
/// The declared key's identifier (the `[[key]]` block's `name`).
pub key_name: String,
/// The canonical, type-tagged hash of the key's values.
pub key_hash: String,
}

/// Trait for the event store backend (implemented by temper-store-postgres).
/// Uses desugared async-in-trait to enforce Send bounds on futures.
pub trait EventStore: Send + Sync + 'static {
Expand All @@ -94,6 +106,55 @@ pub trait EventStore: Send + Sync + 'static {
events: &[PersistenceEnvelope],
) -> impl std::future::Future<Output = Result<u64, PersistenceError>> + Send;

/// Append events and co-commit declared key-index rows (ADR-0153) in the
/// **same transaction** as the journal append. The default ignores
/// `key_rows` and delegates to [`EventStore::append`] — only stores with a
/// query plane (postgres, turso) maintain `entity_key_index`. The sequence
/// and atomicity contract is identical to `append`.
fn append_with_keys(
&self,
persistence_id: &str,
expected_sequence: u64,
events: &[PersistenceEnvelope],
key_rows: &[EntityKeyRow],
) -> impl std::future::Future<Output = Result<u64, PersistenceError>> + Send {
let _ = key_rows;
self.append(persistence_id, expected_sequence, events)
}

/// Backfill declared key-index rows for an **existing** entity (ADR-0153),
/// without appending a journal event. Idempotent: re-running yields the same
/// rows. Used to populate `entity_key_index` for entities written before the
/// declared key existed, so a keyed read can authoritatively prove absence
/// (the per-tenant backfill watermark gates #324's retirement). The default
/// is a no-op (non-indexing backends); query-plane stores upsert the rows.
fn backfill_entity_keys(
&self,
tenant: &str,
entity_type: &str,
entity_id: &str,
key_rows: &[EntityKeyRow],
) -> impl std::future::Future<Output = Result<(), PersistenceError>> + Send {
let _ = (tenant, entity_type, entity_id, key_rows);
async { Ok(()) }
}

/// Resolve an entity by a declared key (ADR-0153): the `entity_id` currently
/// holding `(key_name, key_hash)`, or `None` if absent. This is the
/// negative-existence access path — present *and* absent in one `O(log n)`
/// probe, no scan. Default returns `None` (non-indexing backends); the
/// query-plane stores override it against `entity_key_index`.
fn lookup_by_key(
&self,
tenant: &str,
entity_type: &str,
key_name: &str,
key_hash: &str,
) -> impl std::future::Future<Output = Result<Option<String>, PersistenceError>> + Send {
let _ = (tenant, entity_type, key_name, key_hash);
async { Ok(None) }
}

/// Atomically append events to multiple journals.
///
/// Backends must either commit every append in `appends`, or commit none.
Expand Down
58 changes: 37 additions & 21 deletions crates/temper-server/src/entity_actor/actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ impl EntityActor {

/// Persist an event to the configured event store.
async fn persist_event(
&self,
store: &BoxedEventStore,
backend: BackendLabel,
persistence_id: &str,
Expand All @@ -350,9 +351,28 @@ impl EntityActor {

// W2 / temper#146: measure append wait — the hypothesis is that
// writer-lock / fsync serialization is a cold-start bottleneck.
// ADR-0153: derive the declared key rows from the new state and co-commit
// them with the journal append, so a keyed read is correct without a scan.
let key_rows = {
let table = self.table.read().expect("table lock poisoned");
let mut rows = Vec::new();
if let Some(field_map) = state.fields.as_object() {
for key in &table.keys {
if let Some(hash) =
crate::key_index::canonical_key_hash(&key.name, &key.properties, field_map)
{
rows.push(temper_runtime::persistence::EntityKeyRow {
key_name: key.name.clone(),
key_hash: hash,
});
}
}
}
rows
};
let append_start = Instant::now();
let result = store
.append(persistence_id, state.sequence_nr, &[envelope])
.append_with_keys(persistence_id, state.sequence_nr, &[envelope], &key_rows)
.await;
crate::runtime_metrics::record_event_store_append_wait(
backend.as_str(),
Expand Down Expand Up @@ -729,7 +749,7 @@ impl Actor for EntityActor {

if let (Some(store), Some(backend)) = (self.event_journal.as_ref(), self.event_backend)
{
Self::persist_event(store, backend, &self.persistence_id(), &mut state, &created)
self.persist_event(store, backend, &self.persistence_id(), &mut state, &created)
.await
.map_err(|e| {
ActorError::custom(format!(
Expand Down Expand Up @@ -924,14 +944,9 @@ impl Actor for EntityActor {
if let (Some(store), Some(backend)) =
(self.event_journal.as_ref(), self.event_backend)
{
let first_persist = Self::persist_event(
store,
backend,
&self.persistence_id(),
state,
&event,
)
.await;
let first_persist = self
.persist_event(store, backend, &self.persistence_id(), state, &event)
.await;

match first_persist {
Ok(_) => {
Expand Down Expand Up @@ -1072,14 +1087,15 @@ impl Actor for EntityActor {
))
.await; // determinism-ok: rare retry backoff (ADR-0046)

match Self::persist_event(
store,
backend,
&self.persistence_id(),
state,
&retry_event,
)
.await
match self
.persist_event(
store,
backend,
&self.persistence_id(),
state,
&retry_event,
)
.await
{
Ok(_) => {
// Commit re-evaluated event + result into
Expand Down Expand Up @@ -1380,9 +1396,9 @@ impl Actor for EntityActor {

if let (Some(store), Some(backend)) =
(self.event_journal.as_ref(), self.event_backend)
&& let Err(e) =
Self::persist_event(store, backend, &self.persistence_id(), state, &deleted)
.await
&& let Err(e) = self
.persist_event(store, backend, &self.persistence_id(), state, &deleted)
.await
{
ctx.reply(EntityResponse {
success: false,
Expand Down
Loading
Loading