Skip to main content

khive_runtime/
streams.rs

1//! Ordered note streams. Embedding preparation precedes one SQL-only writer
2//! transaction containing the sequence predicates, note/index writes and ledger
3//! rows. A batch is that transaction over several members (atomic mode) or one
4//! such transaction per member, in list order (per-member mode).
5use std::any::Any;
6use std::collections::{HashMap, HashSet};
7use std::sync::{
8    atomic::{AtomicBool, Ordering},
9    Arc,
10};
11
12use khive_storage::{
13    AtomicUnitOp, Note, SqlAccess, SqlStatement, SqlValue, SqlWriter, StorageCapability,
14    StorageError, WriterTaskRequestState,
15};
16use khive_types::{Details, KhiveError};
17use serde::Deserialize;
18use serde_json::{json, Value};
19use uuid::Uuid;
20
21use crate::atomic_message::{
22    prepare_atomic_note_requests, AtomicNoteOptions, AtomicNoteRequest, AtomicNoteSpec,
23};
24use crate::atomic_plan::{PlanStatement, PostCommitEffect};
25use crate::atomic_runner::{
26    apply_plan, run_prepared_atomic_unit, AtomicOpFailure, AtomicOpPlan,
27    CommittedPostCommitEffects, PreparedAtomicError, PreparedAtomicOp, PreparedAtomicOutcome,
28};
29use crate::note_write::{
30    NoteFence, NoteFences, NoteWriteConflict, NoteWriteGuard, NoteWriteOptions,
31};
32use crate::{
33    micros_to_iso, DomainDisposition, KhiveRuntime, NamespaceToken, RuntimeError, RuntimeResult,
34    VerbRegistry,
35};
36
37#[cfg(test)]
38mod append_failure_tests;
39
40fn validate_stream(stream: &str) -> RuntimeResult<()> {
41    crate::note_validation::validate_key(
42        stream,
43        "stream must be at most 512 UTF-8 bytes and contain no U+0000",
44    )
45}
46
47fn write_failure(message: &str) -> StorageError {
48    StorageError::Conflict {
49        capability: StorageCapability::Notes,
50        operation: "stream.append".into(),
51        message: message.into(),
52    }
53}
54
55/// What the append path can prove about its own failed append, independently
56/// of an enclosing dispatch or a nested error's wire disposition.
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub enum StreamAppendDisposition {
59    /// This append either never reached a write or its transaction rolled back.
60    NotCommitted,
61    /// The append may have committed; no absence of a record is asserted.
62    Unknown,
63}
64
65/// An unchanged source error together with evidence local to one append.
66#[derive(Debug, thiserror::Error)]
67#[error("{source}")]
68pub struct StreamAppendFailure {
69    #[source]
70    source: RuntimeError,
71    disposition: StreamAppendDisposition,
72}
73
74impl StreamAppendFailure {
75    fn not_committed(source: RuntimeError) -> Self {
76        Self {
77            source,
78            disposition: StreamAppendDisposition::NotCommitted,
79        }
80    }
81
82    /// Retain an error when no append-local committedness proof is available.
83    /// This constructor cannot assert that an append was dropped.
84    pub fn unknown(source: RuntimeError) -> Self {
85        Self {
86            source,
87            disposition: StreamAppendDisposition::Unknown,
88        }
89    }
90
91    fn after_submission(source: RuntimeError, refused_before_write: bool) -> Self {
92        let disposition = if let Some(context) = source.writer_task_failure_context() {
93            match context.request_state {
94                WriterTaskRequestState::NotStarted
95                | WriterTaskRequestState::TransactionRolledBack => {
96                    StreamAppendDisposition::NotCommitted
97                }
98                WriterTaskRequestState::SideEffectsUnknown => StreamAppendDisposition::Unknown,
99            }
100        } else if refused_before_write
101            || source.admission_failure_context().is_some()
102            || matches!(
103                &source,
104                RuntimeError::Storage(error) if matches!(error.without_sqlite_write_stage(), StorageError::WriterTaskBusy { .. })
105            )
106        {
107            StreamAppendDisposition::NotCommitted
108        } else {
109            StreamAppendDisposition::Unknown
110        };
111        Self {
112            source,
113            disposition,
114        }
115    }
116
117    /// Borrow the original error without rewriting its fields or provenance.
118    pub fn source(&self) -> &RuntimeError {
119        &self.source
120    }
121
122    /// Evidence about this append, not the enclosing handler's disposition.
123    pub fn disposition(&self) -> StreamAppendDisposition {
124        self.disposition
125    }
126
127    /// Separate the unchanged source from append-local committedness evidence.
128    pub fn into_parts(self) -> (RuntimeError, StreamAppendDisposition) {
129        (self.source, self.disposition)
130    }
131
132    /// Preserve the compatibility error path without an additional wrapper.
133    pub fn into_source(self) -> RuntimeError {
134        self.source
135    }
136}
137
138/// One append, shape-validated by the verb layer; the runtime validates the
139/// stream name and the record's serialization before any write.
140pub struct StreamAppendSpec {
141    pub stream: String,
142    pub record: Value,
143    pub expected_seq: Option<i64>,
144    pub embed: Option<bool>,
145    pub embedding_model: Option<String>,
146    pub note_kind: String,
147    pub tags: Option<Vec<String>>,
148    pub fence: Option<NoteFences>,
149}
150
151impl StreamAppendSpec {
152    fn validate_embedding(&self, member: Option<usize>) -> RuntimeResult<()> {
153        if self.embedding_model.is_some() && self.embed != Some(true) {
154            let mut error = KhiveError::invalid_input("embedding_model requires embed=true");
155            if let Some(member) = member {
156                error = error.with_details(Details::new_owned([("member", member.to_string())]));
157            }
158            return Err(error.into());
159        }
160        Ok(())
161    }
162
163    fn note_options(&self) -> AtomicNoteOptions<'_> {
164        AtomicNoteOptions {
165            embed: Some(self.embed.unwrap_or(false)),
166            embedding_model: self.embedding_model.as_deref(),
167            ..Default::default()
168        }
169    }
170}
171
172/// A keyed document write. Kinds are canonical note-kind names. A missing
173/// expected version creates only; a positive version updates only.
174#[derive(Clone, Debug)]
175pub struct StreamWriteSpec {
176    pub key: String,
177    pub kind: String,
178    pub doc: Value,
179    pub tags: Option<Vec<String>>,
180    pub embed: Option<bool>,
181    pub expected_version: Option<i64>,
182}
183
184/// An exact live-key observation; a null version asserts that the key is unheld.
185/// An optional identity pins which live note must hold the key at that version.
186#[derive(Clone, Debug)]
187pub struct StreamObservation {
188    pub key: String,
189    pub kind: String,
190    pub version: Option<i64>,
191    pub id: Option<Uuid>,
192    /// Dotted document path whose RFC 3339 value must exceed the writer clock.
193    pub live_until: Option<String>,
194}
195
196/// A batch member or its already established refusal. The mode places it.
197pub enum StreamBatchMember {
198    Append(StreamAppendSpec),
199    Write(StreamWriteSpec),
200    Refused(KhiveError),
201}
202
203// Like the per-list fence and observation bounds, this limits precondition
204// reads while holding the writer; member count alone does not bound their sum.
205const MAX_BATCH_FENCE_ENTRIES: usize = 100;
206
207fn stream_batch_fence_count(members: &[StreamBatchMember], fence: Option<&NoteFence>) -> usize {
208    usize::from(fence.is_some())
209        + members
210            .iter()
211            .filter_map(|member| match member {
212                StreamBatchMember::Append(spec) => spec.fence.as_ref(),
213                StreamBatchMember::Write(_) | StreamBatchMember::Refused(_) => None,
214            })
215            .map(|fences| fences.entries().len())
216            .sum::<usize>()
217}
218
219fn validate_stream_batch_fence_count(count: usize) -> RuntimeResult<()> {
220    crate::note_validation::validate_fence_count(count, MAX_BATCH_FENCE_ENTRIES, || {
221        format!(
222            "stream.batch admits at most {MAX_BATCH_FENCE_ENTRIES} total fence entries; this call sent {count}: each fence is a read taken while holding the writer"
223        )
224    })
225}
226
227/// The member refusal that stopped an atomic batch; nothing was written.
228#[derive(Debug)]
229pub struct StreamBatchRefusal {
230    pub member: usize,
231    pub error: KhiveError,
232}
233
234/// A member refusal as a value: the error object a refused op carries, plus
235/// the disposition the consumer rule reads without a special case.
236pub fn refusal_value(error: &KhiveError) -> RuntimeResult<Value> {
237    let mut value = serde_json::to_value(error)
238        .map_err(|e| RuntimeError::Internal(format!("stream refusal serialization: {e}")))?;
239    value["domain_disposition"] = json!(DomainDisposition::NotCommitted.as_str());
240    Ok(value)
241}
242
243fn seq_conflict(stream: &str, expected: i64, next: i64, member: Option<usize>) -> KhiveError {
244    let mut pairs = vec![
245        ("reason", "seq_conflict".to_string()),
246        ("stream", stream.to_string()),
247        ("expected_seq", expected.to_string()),
248        ("next_seq", next.to_string()),
249    ];
250    if let Some(member) = member {
251        pairs.push(("member", member.to_string()));
252    }
253    KhiveError::conflict("stream sequence precondition failed")
254        .with_details(Details::new_owned(pairs))
255}
256
257/// A prepared append: the note, its planned statements and the ledger key.
258struct PreparedAppend {
259    stream: String,
260    expected_seq: Option<i64>,
261    note: Note,
262    statements: Vec<PlanStatement>,
263    guard: NoteWriteGuard,
264}
265
266fn append_result(prepared: &PreparedAppend, seq: i64) -> Value {
267    json!({"seq": seq, "id": prepared.note.id, "created_at": micros_to_iso(prepared.note.created_at)})
268}
269
270enum BatchOutcome {
271    Appended(Vec<i64>),
272    Conflict { next: i64 },
273    FenceConflict { conflict: NoteWriteConflict },
274}
275
276struct PreparedBatchMember {
277    index: usize,
278    action: PreparedBatchAction,
279}
280
281enum PreparedBatchAction {
282    Append {
283        stream: String,
284        expected_seq: Option<i64>,
285        note: Box<Note>,
286        plan: AtomicOpPlan,
287        fence: Option<NoteFences>,
288    },
289    Write {
290        key: String,
291        kind: String,
292        id: Uuid,
293        plan: AtomicOpPlan,
294        after_create: Option<Value>,
295    },
296    Refused(KhiveError),
297}
298
299#[derive(Deserialize)]
300struct StreamCreateFields {
301    content: String,
302    name: Option<String>,
303    properties: Option<Value>,
304    tags: Option<Vec<String>>,
305    salience: Option<f64>,
306    decay_factor: Option<f64>,
307    embed: Option<bool>,
308}
309
310fn normalize_stream_create_fields(fields: &mut StreamCreateFields) -> RuntimeResult<()> {
311    let tags = fields.tags.take();
312    if let crate::EffectiveCreateTags::TopLevel(tags) =
313        crate::effective_create_tags(tags.as_deref(), fields.properties.as_ref())
314    {
315        let tags = json!(tags);
316        let mut properties = match fields.properties.take() {
317            None => serde_json::Map::new(),
318            Some(Value::Object(properties)) => properties,
319            Some(_) => {
320                return Err(RuntimeError::InvalidInput(
321                    "note tags require object properties".into(),
322                ))
323            }
324        };
325        properties.insert("tags".into(), tags);
326        fields.properties = Some(Value::Object(properties));
327    }
328    Ok(())
329}
330
331struct PreparedStreamCreate {
332    key: String,
333    kind: String,
334    fields: StreamCreateFields,
335    args: Value,
336}
337
338enum StreamBatchPreparation {
339    Ready(Box<PreparedBatchAction>),
340    Append(Box<(StreamAppendSpec, String)>),
341    Create(Box<PreparedStreamCreate>),
342}
343
344fn batch_create_hooks(members: &[PreparedBatchMember]) -> Vec<(Uuid, String, Value)> {
345    members
346        .iter()
347        .filter_map(|member| match &member.action {
348            PreparedBatchAction::Write {
349                id,
350                kind,
351                after_create: Some(args),
352                ..
353            } => Some((*id, kind.clone(), args.clone())),
354            _ => None,
355        })
356        .collect()
357}
358
359fn place_member(error: KhiveError, member: Option<usize>) -> RuntimeResult<KhiveError> {
360    let pairs = member
361        .into_iter()
362        .map(|member| ("member".to_owned(), member.to_string()))
363        .chain(
364            error
365                .details()
366                .into_iter()
367                .flat_map(Details::iter)
368                .filter(|(key, _)| *key != "member")
369                .map(|(key, value)| (key.to_owned(), value.to_owned())),
370        );
371    let details = Details::deserialize(serde::de::value::MapDeserializer::<
372        _,
373        serde::de::value::Error,
374    >::new(pairs))
375    .map_err(|error| RuntimeError::Internal(format!("stream member details: {error}")))?;
376    Ok(error.with_details(details))
377}
378
379fn missing_write(key: &str) -> KhiveError {
380    KhiveError::not_found("note key", key).with_details(Details::new_owned([
381        ("reason", "stream_write_not_found".into()),
382        ("key", key.into()),
383    ]))
384}
385
386async fn check_observed(
387    writer: &mut dyn SqlWriter,
388    namespace: &str,
389    observed: &[StreamObservation],
390    now: Option<i64>,
391) -> Result<Option<KhiveError>, StorageError> {
392    for (index, entry) in observed.iter().enumerate() {
393        let current = crate::fence_identity::read_holder(
394            writer,
395            namespace,
396            &entry.kind,
397            &entry.key,
398            "stream-batch-observed",
399        )
400        .await?;
401        // No live holder is the existing version conflict. Identity conflict
402        // specifically names a replacement, so current_id is always present.
403        if let (Some(asserted), Some(holder)) = (entry.id, current.as_ref()) {
404            if asserted != holder.id {
405                let version = entry.version.ok_or_else(|| {
406                    StorageError::Internal("identity observation missing version".into())
407                })?;
408                let mut details = vec![
409                    ("reason", "identity_conflict".into()),
410                    ("key", entry.key.clone()),
411                    ("kind", entry.kind.clone()),
412                    ("version", version.to_string()),
413                ];
414                details.extend(crate::fence_identity::identity_evidence(
415                    asserted, holder.id,
416                ));
417                details.push(("index", index.to_string()));
418                return Ok(Some(
419                    KhiveError::conflict("stream observation identity precondition failed")
420                        .with_details(Details::new_owned(details)),
421                ));
422            }
423        }
424        let current = current.map(|holder| holder.version);
425        if current != entry.version {
426            let mut details = vec![
427                ("reason", "version_conflict".into()),
428                ("key", entry.key.clone()),
429                ("index", index.to_string()),
430            ];
431            if let Some(expected) = entry.version {
432                details.push(("expected_version", expected.to_string()));
433            }
434            if let Some(current) = current {
435                details.push(("current_version", current.to_string()));
436            }
437            return Ok(Some(
438                KhiveError::conflict("stream observation precondition failed")
439                    .with_details(Details::new_owned(details)),
440            ));
441        }
442        if let Some(field) = &entry.live_until {
443            let now = now
444                .ok_or_else(|| StorageError::Internal("missing stream observation clock".into()))?;
445            if let Some(refusal) = crate::live_until::evaluate(
446                writer,
447                namespace,
448                &entry.kind,
449                &entry.key,
450                field,
451                now,
452                "stream-batch-live-until",
453            )
454            .await?
455            {
456                let mut details = vec![
457                    ("reason", refusal.reason().into()),
458                    ("key", entry.key.clone()),
459                    ("kind", entry.kind.clone()),
460                    ("version", entry.version.unwrap().to_string()),
461                    ("field", field.clone()),
462                    ("index", index.to_string()),
463                ];
464                details.extend(refusal.details());
465                return Ok(Some(
466                    KhiveError::conflict("stream observation time precondition failed")
467                        .with_details(Details::new_owned(details)),
468                ));
469            }
470        }
471    }
472    Ok(None)
473}
474
475async fn observation_clock(writer: &mut dyn SqlWriter) -> Result<i64, StorageError> {
476    crate::live_until::writer_clock(writer, "stream-batch-clock").await
477}
478
479struct BatchFailure {
480    member: Option<usize>,
481    error: RuntimeError,
482}
483
484async fn run_prepared_stream_batch(
485    access: &dyn SqlAccess,
486    namespace: String,
487    members: Vec<PreparedBatchMember>,
488    fence: Option<NoteFence>,
489    observed: Vec<StreamObservation>,
490) -> RuntimeResult<Result<(Vec<Value>, CommittedPostCommitEffects), StreamBatchRefusal>> {
491    let op: PreparedAtomicOp<Vec<Value>, BatchFailure> = Box::new(move |writer| {
492        Box::pin(async move {
493            // One SQL clock read after writer admission, shared by the list.
494            let now = if observed.iter().any(|entry| entry.live_until.is_some()) {
495                Some(observation_clock(writer).await?)
496            } else {
497                None
498            };
499            let guard = NoteWriteGuard {
500                namespace: namespace.clone(),
501                target_id: Uuid::nil(),
502                expected_version: None,
503                fence: fence.map(Into::into),
504                create_key: None,
505            };
506            let predicate_error = if let Some(conflict) = guard.check_fence(writer).await? {
507                Some(conflict.into_error())
508            } else {
509                check_observed(writer, &namespace, &observed, now).await?
510            };
511            if let Some(error) = predicate_error {
512                return Err(PreparedAtomicError::Refused {
513                    failure: BatchFailure {
514                        member: None,
515                        error: error.into(),
516                    },
517                    message: "stream batch predicate refused".into(),
518                });
519            }
520            let mut results = Vec::with_capacity(members.len());
521            let mut effects = Vec::new();
522            let mut heads = HashMap::new();
523            // Member fences observe the transaction's initial state, even when
524            // an earlier keyed write changes a fenced note in this batch.
525            for member in &members {
526                if let PreparedBatchAction::Append { fence, .. } = &member.action {
527                    let guard = NoteWriteGuard {
528                        namespace: namespace.clone(),
529                        target_id: Uuid::nil(),
530                        expected_version: None,
531                        fence: fence.clone(),
532                        create_key: None,
533                    };
534                    if let Some(conflict) = guard.check_fence(writer).await? {
535                        return Err(PreparedAtomicError::Refused {
536                            failure: BatchFailure {
537                                member: Some(member.index),
538                                error: conflict.into_error().into(),
539                            },
540                            message: "stream append fence refused".into(),
541                        });
542                    }
543                }
544            }
545            for member in members {
546                let result =
547                    apply_stream_member(writer, &namespace, &mut heads, member.action).await;
548                match result {
549                    Ok((value, effect)) => {
550                        results.push(value);
551                        if let Some(effect) = effect {
552                            effects.push(effect);
553                        }
554                    }
555                    Err(error) => {
556                        return Err(PreparedAtomicError::Refused {
557                            failure: BatchFailure {
558                                member: Some(member.index),
559                                error,
560                            },
561                            message: "stream batch member refused".into(),
562                        });
563                    }
564                }
565            }
566            Ok((results, effects))
567        })
568    });
569    match run_prepared_atomic_unit(access, op).await? {
570        PreparedAtomicOutcome::Committed { value, post_commit } => Ok(Ok((value, post_commit))),
571        PreparedAtomicOutcome::RolledBack(BatchFailure {
572            member: Some(member),
573            error: RuntimeError::Khive(error),
574        }) => Ok(Err(StreamBatchRefusal {
575            member,
576            error: place_member(error, Some(member))?,
577        })),
578        PreparedAtomicOutcome::RolledBack(BatchFailure { error, .. }) => Err(error),
579    }
580}
581
582enum SequenceRefusal {
583    Exhausted,
584    Conflict { expected: i64, next: i64 },
585}
586
587fn allocate_sequence(head: &mut i64, expected: Option<i64>) -> Result<i64, SequenceRefusal> {
588    let next = head.checked_add(1).ok_or(SequenceRefusal::Exhausted)?;
589    if let Some(expected) = expected.filter(|expected| *expected != next) {
590        return Err(SequenceRefusal::Conflict { expected, next });
591    }
592    *head = next;
593    Ok(next)
594}
595
596async fn insert_stream_entry(
597    writer: &mut dyn SqlWriter,
598    namespace: &str,
599    stream: String,
600    seq: i64,
601    note_id: String,
602) -> Result<(), StorageError> {
603    writer
604        .execute(
605            SqlStatement::new(
606                "INSERT INTO note_streams(namespace,stream,seq,note_id) VALUES (?1,?2,?3,?4)",
607                vec![
608                    SqlValue::Text(namespace.into()),
609                    SqlValue::Text(stream),
610                    SqlValue::Integer(seq),
611                    SqlValue::Text(note_id),
612                ],
613            )
614            .labelled("stream"),
615        )
616        .await?;
617    Ok(())
618}
619
620async fn apply_stream_member(
621    writer: &mut dyn SqlWriter,
622    namespace: &str,
623    heads: &mut HashMap<String, i64>,
624    action: PreparedBatchAction,
625) -> RuntimeResult<(Value, Option<PostCommitEffect>)> {
626    match action {
627        PreparedBatchAction::Refused(error) => Err(error.into()),
628        PreparedBatchAction::Append {
629            stream,
630            expected_seq,
631            note,
632            plan,
633            ..
634        } => {
635            let head = match heads.entry(stream.clone()) {
636                std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut(),
637                std::collections::hash_map::Entry::Vacant(entry) => {
638                    let head = writer.query_scalar(SqlStatement::new(
639                        "SELECT COALESCE(MAX(seq), 0) FROM note_streams WHERE namespace=?1 AND stream=?2",
640                        vec![SqlValue::Text(namespace.into()), SqlValue::Text(stream.clone())],
641                    ).labelled("stream")).await?;
642                    let Some(SqlValue::Integer(head)) = head else {
643                        return Err(RuntimeError::Internal("invalid stream head".into()));
644                    };
645                    entry.insert(head)
646                }
647            };
648            let next = allocate_sequence(head, expected_seq).map_err(|refusal| match refusal {
649                SequenceRefusal::Exhausted => {
650                    RuntimeError::InvalidInput("stream sequence exhausted".into())
651                }
652                SequenceRefusal::Conflict { expected, next } => {
653                    seq_conflict(&stream, expected, next, None).into()
654                }
655            })?;
656            let applied = apply_plan(writer, &plan, false).await.map_err(|error| {
657                RuntimeError::Internal(format!("stream append plan failed: {error:?}"))
658            })?;
659            insert_stream_entry(writer, namespace, stream, next, note.id.to_string()).await?;
660            Ok((
661                json!({"seq": next, "id": note.id, "created_at": micros_to_iso(note.created_at)}),
662                applied.effect,
663            ))
664        }
665        PreparedBatchAction::Write {
666            key,
667            kind,
668            id,
669            plan,
670            ..
671        } => match apply_plan(writer, &plan, false).await {
672            Ok(applied) => {
673                // Read the row under this writer, before commit or any later
674                // writer. Preserve its existing timestamp/CAS semantics.
675                let stored = writer
676                    .query_row(SqlStatement {
677                        sql: "SELECT version, updated_at FROM notes WHERE namespace=?1 AND id=?2"
678                            .into(),
679                        params: vec![
680                            SqlValue::Text(namespace.into()),
681                            SqlValue::Text(id.to_string()),
682                        ],
683                        label: Some("stream-batch-write-time".into()),
684                    })
685                    .await?;
686                let Some(stored) = stored else {
687                    return Err(RuntimeError::Internal(
688                        "missing stream write timestamp".into(),
689                    ));
690                };
691                Ok((
692                    json!({"id": id, "version": stored.i64("version").map_err(|_| RuntimeError::Internal("stream query missing integer version".to_owned()))?, "updated_at": micros_to_iso(stored.i64("updated_at").map_err(|_| RuntimeError::Internal("stream query missing integer updated_at".to_owned()))?)}),
693                    applied.effect,
694                ))
695            }
696            Err(AtomicOpFailure::NoteConflict(conflict)) => Err(conflict.into_error().into()),
697            Err(AtomicOpFailure::GuardFailed { .. }) => {
698                let holder = writer
699                    .query_row(
700                        khive_db::statement_lists::live_holder_read(namespace, &kind, &key)
701                            .labelled("stream"),
702                    )
703                    .await?;
704                let Some(holder) = holder else {
705                    return Err(missing_write(&key).into());
706                };
707                // Versions are local to a note identity. A replacement can be
708                // at the expected version without being the prepared target.
709                if holder.text("id").map_err(|_| {
710                    RuntimeError::Internal("stream query missing text id".to_owned())
711                })? != id.to_string()
712                {
713                    if let AtomicOpPlan::Update(update) = &plan {
714                        if let Some(expected) = update
715                            .note_guard
716                            .as_ref()
717                            .and_then(|guard| guard.expected_version)
718                        {
719                            return Err(NoteWriteConflict::Version {
720                                expected,
721                                current: holder.i64("version").map_err(|_| {
722                                    RuntimeError::Internal(
723                                        "stream query missing integer version".to_owned(),
724                                    )
725                                })?,
726                            }
727                            .into_error()
728                            .into());
729                        }
730                    }
731                }
732                Err(crate::curation::stale_note_snapshot_error(id))
733            }
734            Err(error) => Err(RuntimeError::Internal(format!(
735                "stream write plan failed: {error:?}"
736            ))),
737        },
738    }
739}
740
741impl KhiveRuntime {
742    /// Validate every spec and prepare every note before any write. All
743    /// allocations for embedding, note normalization and SQL plans complete
744    /// here, so only bounded statement driving occurs while the writer is held.
745    async fn prepare_stream_appends(
746        &self,
747        token: &NamespaceToken,
748        specs: &[&StreamAppendSpec],
749        registry: &VerbRegistry,
750    ) -> RuntimeResult<Vec<PreparedAppend>> {
751        let mut fields = Vec::with_capacity(specs.len());
752        for spec in specs {
753            spec.validate_embedding(None)?;
754            validate_stream(&spec.stream)?;
755            if let Some(fences) = &spec.fence {
756                fences.validate()?;
757                for fence in fences.entries() {
758                    self.validate_note_kind(&fence.kind)?;
759                }
760            }
761            let content = serde_json::to_string(&spec.record)
762                .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
763            let hook = registry.find_kind_hook(&spec.note_kind);
764            // A hooked kind may use its ordinary create fields from an object record (for
765            // example, `task` reads `title`), while an unhooked kind keeps the append record as
766            // opaque JSON content exactly as before.
767            let mut args = if hook.is_some() {
768                match &spec.record {
769                    Value::Object(record) => Value::Object(record.clone()),
770                    _ => json!({}),
771                }
772            } else {
773                json!({})
774            };
775            args["kind"] = json!("note");
776            args["note_kind"] = json!(&spec.note_kind);
777            args["content"] = json!(content);
778            args["namespace"] = json!(token.namespace().as_str());
779            if let Some(tags) = &spec.tags {
780                args["tags"] = json!(tags);
781            }
782            if let Some(embed) = spec.embed {
783                args["embed"] = json!(embed);
784            }
785            if let Some(hook) = hook {
786                hook.prepare_create(self, &mut args).await?;
787            }
788            let mut note_fields: StreamCreateFields =
789                serde_json::from_value(args).map_err(|error| {
790                    RuntimeError::InvalidInput(format!("stream append fields: {error}"))
791                })?;
792            normalize_stream_create_fields(&mut note_fields)?;
793            fields.push(note_fields);
794        }
795        let atomic_specs = specs
796            .iter()
797            .zip(&fields)
798            .map(|(spec, fields)| AtomicNoteRequest {
799                spec: AtomicNoteSpec {
800                    token,
801                    id: None,
802                    kind: &spec.note_kind,
803                    name: fields.name.as_deref(),
804                    content: &fields.content,
805                    properties: fields.properties.clone(),
806                },
807                options: AtomicNoteOptions {
808                    salience: fields.salience,
809                    decay_factor: fields.decay_factor,
810                    embed: Some(fields.embed.or(spec.embed).unwrap_or(false)),
811                    embedding_model: spec.embedding_model.as_deref(),
812                    ..Default::default()
813                },
814            })
815            .collect();
816        let prepared = prepare_atomic_note_requests(self, atomic_specs).await?;
817        let mut out = Vec::with_capacity(specs.len());
818        for ((spec, note), plan) in specs.iter().zip(prepared.notes).zip(prepared.plans) {
819            let AtomicOpPlan::AddNote(plan) = plan else {
820                return Err(RuntimeError::Internal(
821                    "stream preparation did not produce a note".into(),
822                ));
823            };
824            let plan = *plan;
825            let mut guard = plan.note_guard.ok_or_else(|| {
826                RuntimeError::Internal("stream preparation did not produce a note guard".into())
827            })?;
828            guard.fence = spec.fence.clone();
829            out.push(PreparedAppend {
830                stream: spec.stream.clone(),
831                expected_seq: spec.expected_seq,
832                note,
833                statements: plan.statements,
834                guard,
835            });
836        }
837        Ok(out)
838    }
839
840    /// Run prepared appends as one writer transaction. Every stream head is
841    /// read, every number assigned in list order and every `expected_seq`
842    /// checked before the first write; then notes and ledger rows land in
843    /// list order, so appends to one stream take consecutive numbers.
844    async fn run_stream_appends(
845        access: &dyn SqlAccess,
846        token: &NamespaceToken,
847        appends: &[PreparedAppend],
848    ) -> Result<BatchOutcome, StreamAppendFailure> {
849        let ns = token.namespace().as_str().to_string();
850        let entries: Vec<_> = appends
851            .iter()
852            .map(|a| {
853                (
854                    a.stream.clone(),
855                    a.expected_seq,
856                    a.note.id.to_string(),
857                    a.statements.clone(),
858                    a.guard.clone(),
859                )
860            })
861            .collect();
862        // A false flag is never proof: the backend may have accepted a closure
863        // that has not run yet. Only a completed pre-write error sets it true.
864        let refused_before_write = Arc::new(AtomicBool::new(false));
865        let refusal_proof = Arc::clone(&refused_before_write);
866        let op: AtomicUnitOp = Box::new(move |writer| {
867            Box::pin(async move {
868                let mut writes_started = false;
869                let result = async {
870                let mut heads: Vec<(String, i64)> = Vec::new();
871                for (stream, _, _, _, _) in &entries {
872                    if heads.iter().any(|(known, _)| known == stream) {
873                        continue;
874                    }
875                    let head = writer
876                        .query_scalar(SqlStatement::new(
877                            "SELECT COALESCE(MAX(seq), 0) FROM note_streams WHERE namespace=?1 AND stream=?2",
878                            vec![SqlValue::Text(ns.clone()), SqlValue::Text(stream.clone())],
879                        ).labelled("stream"))
880                        .await?;
881                    let Some(SqlValue::Integer(head)) = head else {
882                        return Err(write_failure("invalid stream head"));
883                    };
884                    heads.push((stream.clone(), head));
885                }
886                let mut assigned = Vec::with_capacity(entries.len());
887                for (stream, expected_seq, _, _, guard) in &entries {
888                    if let Some(conflict) = guard.check_fence(writer).await? {
889                        return Ok(Box::new(BatchOutcome::FenceConflict { conflict })
890                            as Box<dyn Any + Send>);
891                    }
892                    let head = heads
893                        .iter_mut()
894                        .find(|(known, _)| known == stream)
895                        .map(|(_, head)| head)
896                        .ok_or_else(|| write_failure("stream head missing"))?;
897                    let next = match allocate_sequence(head, *expected_seq) {
898                        Ok(next) => next,
899                        Err(SequenceRefusal::Exhausted) => {
900                            return Err(write_failure("stream sequence exhausted"));
901                        }
902                        Err(SequenceRefusal::Conflict { next, .. }) => {
903                            return Ok(
904                                Box::new(BatchOutcome::Conflict { next }) as Box<dyn Any + Send>
905                            );
906                        }
907                    };
908                    assigned.push(next);
909                }
910                for ((stream, _, note_id, statements, _), seq) in
911                    entries.into_iter().zip(assigned.iter().copied())
912                {
913                    // Set before invoking the first write, including a write
914                    // whose driver returns an error with ambiguous effects.
915                    writes_started = true;
916                    for planned in statements {
917                        let affected = writer.execute(planned.statement).await?;
918                        if planned
919                            .guard
920                            .is_some_and(|guard| !guard.holds_for(affected))
921                        {
922                            return Err(write_failure("prepared note write guard failed"));
923                        }
924                    }
925                    insert_stream_entry(writer, &ns, stream, seq, note_id).await?;
926                }
927                Ok(Box::new(BatchOutcome::Appended(assigned)) as Box<dyn Any + Send>)
928                }.await;
929                if result.is_err() && !writes_started {
930                    refusal_proof.store(true, Ordering::Release);
931                }
932                result
933            })
934        });
935        let outcome = access
936            .atomic_unit(op)
937            .await
938            .map_err(|source| {
939                StreamAppendFailure::after_submission(
940                    source.into(),
941                    refused_before_write.load(Ordering::Acquire),
942                )
943            })?
944            .downcast::<BatchOutcome>()
945            .map_err(|_| {
946                StreamAppendFailure::unknown(RuntimeError::Internal(
947                    "invalid stream append outcome".into(),
948                ))
949            })?;
950        Ok(*outcome)
951    }
952
953    /// Append a JSON value as an immutable note. The sequence precondition and
954    /// every note/index/ledger statement share the same writer transaction.
955    #[allow(clippy::too_many_arguments)]
956    pub async fn stream_append(
957        &self,
958        token: &NamespaceToken,
959        stream: &str,
960        record: &Value,
961        expected_seq: Option<i64>,
962        note_kind: &str,
963        tags: Option<Vec<String>>,
964        fence: Option<NoteFences>,
965        embed: Option<bool>,
966        embedding_model: Option<String>,
967        registry: &VerbRegistry,
968    ) -> RuntimeResult<Value> {
969        self.stream_append_with_outcome(
970            token,
971            stream,
972            record,
973            expected_seq,
974            note_kind,
975            tags,
976            fence,
977            embed,
978            embedding_model,
979            registry,
980        )
981        .await
982        .map_err(StreamAppendFailure::into_source)
983    }
984
985    /// Append once while retaining evidence about the failed append's own
986    /// committedness. The original error remains available for stop semantics
987    /// or the shared structured error projection; this method never retries.
988    #[allow(clippy::too_many_arguments)]
989    pub async fn stream_append_with_outcome(
990        &self,
991        token: &NamespaceToken,
992        stream: &str,
993        record: &Value,
994        expected_seq: Option<i64>,
995        note_kind: &str,
996        tags: Option<Vec<String>>,
997        fence: Option<NoteFences>,
998        embed: Option<bool>,
999        embedding_model: Option<String>,
1000        registry: &VerbRegistry,
1001    ) -> Result<Value, StreamAppendFailure> {
1002        let spec = StreamAppendSpec {
1003            stream: stream.to_string(),
1004            record: record.clone(),
1005            expected_seq,
1006            note_kind: note_kind.to_string(),
1007            tags,
1008            fence,
1009            embed,
1010            embedding_model,
1011        };
1012        let prepared = self
1013            .prepare_stream_appends(token, &[&spec], registry)
1014            .await
1015            .map_err(StreamAppendFailure::not_committed)?;
1016        let access = self.sql();
1017        match Self::run_stream_appends(access.as_ref(), token, &prepared).await? {
1018            BatchOutcome::Appended(seqs) => Ok(append_result(&prepared[0], seqs[0])),
1019            BatchOutcome::FenceConflict { conflict } => Err(StreamAppendFailure::not_committed(
1020                conflict.into_error().into(),
1021            )),
1022            BatchOutcome::Conflict { next } => Err(StreamAppendFailure::not_committed(
1023                seq_conflict(
1024                    stream,
1025                    expected_seq.expect("only conditional appends conflict"),
1026                    next,
1027                    None,
1028                )
1029                .into(),
1030            )),
1031        }
1032    }
1033
1034    fn validate_stream_batch(
1035        &self,
1036        members: &[StreamBatchMember],
1037        atomic: bool,
1038    ) -> RuntimeResult<()> {
1039        if members.is_empty() {
1040            return Err(RuntimeError::InvalidInput(
1041                "stream.batch requires at least one member".into(),
1042            ));
1043        }
1044        let mut keys = HashSet::new();
1045        for (index, member) in members.iter().enumerate() {
1046            match member {
1047                StreamBatchMember::Append(spec) => {
1048                    spec.validate_embedding(atomic.then_some(index))?;
1049                    validate_stream(&spec.stream)?;
1050                    self.validate_note_kind(&spec.note_kind)?;
1051                    if let Some(fences) = &spec.fence {
1052                        fences.validate()?;
1053                        for fence in fences.entries() {
1054                            self.validate_note_kind(&fence.kind)?;
1055                        }
1056                    }
1057                    crate::secret_gate::check_at(
1058                        &serde_json::to_string(&spec.record)
1059                            .map_err(|error| RuntimeError::InvalidInput(error.to_string()))?,
1060                        &format!("member[{index}]"),
1061                        "record",
1062                    )?;
1063                }
1064                StreamBatchMember::Write(spec) => {
1065                    self.validate_note_kind(&spec.kind)?;
1066                    NoteWriteOptions {
1067                        key: Some(spec.key.clone()),
1068                        expected_version: spec.expected_version,
1069                        embed: spec.embed,
1070                        fence: None,
1071                    }
1072                    .validate()?;
1073                    if spec.kind == "scheduled_event" {
1074                        return Err(RuntimeError::InvalidInput(
1075                            "scheduled_event notes are not writable through stream.batch; use schedule verbs".into(),
1076                        ));
1077                    }
1078                    if !keys.insert((&spec.kind, &spec.key)) {
1079                        return Err(RuntimeError::InvalidInput(format!(
1080                            "stream.batch repeats write key {:?} for kind {:?}",
1081                            spec.key, spec.kind,
1082                        )));
1083                    }
1084                    crate::secret_gate::check_at(
1085                        &serde_json::to_string(&spec.doc)
1086                            .map_err(|error| RuntimeError::InvalidInput(error.to_string()))?,
1087                        &format!("member[{index}]"),
1088                        "doc",
1089                    )?;
1090                    if let Some(tags) = &spec.tags {
1091                        crate::secret_gate::check_json_at(
1092                            &json!({"tags": tags}),
1093                            &format!("member[{index}]"),
1094                            "tags",
1095                        )?;
1096                    }
1097                }
1098                StreamBatchMember::Refused(_) => {}
1099            }
1100        }
1101        Ok(())
1102    }
1103
1104    async fn prepare_stream_write(
1105        &self,
1106        token: &NamespaceToken,
1107        spec: StreamWriteSpec,
1108        registry: &VerbRegistry,
1109    ) -> RuntimeResult<StreamBatchPreparation> {
1110        let content = serde_json::to_string(&spec.doc)
1111            .map_err(|error| RuntimeError::InvalidInput(error.to_string()))?;
1112        if let Some(expected) = spec.expected_version {
1113            let snapshot = match self
1114                .get_note_by_key(token, &spec.key, Some(&spec.kind), false)
1115                .await
1116            {
1117                Ok(note) => note,
1118                Err(RuntimeError::Khive(error))
1119                    if error.kind() == khive_types::ErrorKind::NotFound =>
1120                {
1121                    return Ok(StreamBatchPreparation::Ready(Box::new(
1122                        PreparedBatchAction::Refused(missing_write(&spec.key)),
1123                    )));
1124                }
1125                Err(error) => return Err(error),
1126            };
1127            let id = snapshot.id;
1128            snapshot
1129                .version
1130                .checked_add(1)
1131                .ok_or_else(|| RuntimeError::InvalidInput("note version exhausted".into()))?;
1132            let mut args = json!({
1133                "id": id, "kind": "note", "note_kind": spec.kind,
1134                "content": content, "expected_version": expected,
1135                "namespace": token.namespace().as_str(),
1136            });
1137            if let Some(tags) = spec.tags {
1138                args["properties"] = json!({"tags": tags});
1139            }
1140            if let Some(embed) = spec.embed {
1141                args["embed"] = json!(embed);
1142            }
1143            let update_policy = registry
1144                .prepare_note_update_policy(self, token, &snapshot, &mut args)
1145                .await?;
1146            let (_, plan) = crate::atomic_prepare::prepare_update_from_note_snapshot(
1147                self,
1148                token,
1149                &args,
1150                None,
1151                snapshot,
1152                update_policy,
1153                registry,
1154            )
1155            .await?;
1156            return Ok(StreamBatchPreparation::Ready(Box::new(
1157                PreparedBatchAction::Write {
1158                    key: spec.key,
1159                    kind: spec.kind,
1160                    id,
1161                    plan,
1162                    after_create: None,
1163                },
1164            )));
1165        }
1166        let mut args = json!({
1167            "kind": "note", "note_kind": spec.kind, "key": spec.key,
1168            "content": content, "namespace": token.namespace().as_str(),
1169        });
1170        if let Some(tags) = spec.tags {
1171            args["tags"] = json!(tags);
1172        }
1173        if let Some(embed) = spec.embed {
1174            args["embed"] = json!(embed);
1175        }
1176        if let Some(hook) = registry.find_kind_hook(&spec.kind) {
1177            hook.prepare_create(self, &mut args).await?;
1178        }
1179        let mut fields: StreamCreateFields = serde_json::from_value(args.clone())
1180            .map_err(|error| RuntimeError::InvalidInput(format!("stream write fields: {error}")))?;
1181        normalize_stream_create_fields(&mut fields)?;
1182        let mut candidate = Note::new(token.namespace().as_str(), &spec.kind, &fields.content);
1183        candidate.name = fields.name.clone();
1184        candidate.properties = fields.properties.clone();
1185        crate::note_write::validate_head(&candidate)?;
1186        Ok(StreamBatchPreparation::Create(Box::new(
1187            PreparedStreamCreate {
1188                key: spec.key,
1189                kind: spec.kind,
1190                fields,
1191                args,
1192            },
1193        )))
1194    }
1195
1196    async fn prepare_stream_batch(
1197        &self,
1198        token: &NamespaceToken,
1199        members: Vec<StreamBatchMember>,
1200        registry: &VerbRegistry,
1201    ) -> RuntimeResult<Vec<PreparedBatchMember>> {
1202        let mut pending = Vec::with_capacity(members.len());
1203        for member in members {
1204            let action = match member {
1205                StreamBatchMember::Refused(error) => {
1206                    StreamBatchPreparation::Ready(Box::new(PreparedBatchAction::Refused(error)))
1207                }
1208                StreamBatchMember::Write(spec) => {
1209                    match self.prepare_stream_write(token, spec, registry).await {
1210                        Ok(action) => action,
1211                        Err(RuntimeError::Khive(error))
1212                            if error.kind() == khive_types::ErrorKind::Conflict =>
1213                        {
1214                            StreamBatchPreparation::Ready(Box::new(PreparedBatchAction::Refused(
1215                                error,
1216                            )))
1217                        }
1218                        Err(error) => return Err(error),
1219                    }
1220                }
1221                StreamBatchMember::Append(spec) => {
1222                    let content = serde_json::to_string(&spec.record)
1223                        .map_err(|error| RuntimeError::InvalidInput(error.to_string()))?;
1224                    StreamBatchPreparation::Append(Box::new((spec, content)))
1225                }
1226            };
1227            pending.push(action);
1228        }
1229        let requests = pending
1230            .iter()
1231            .filter_map(|action| match action {
1232                StreamBatchPreparation::Ready(_) => None,
1233                StreamBatchPreparation::Append(append) => {
1234                    let (spec, content) = append.as_ref();
1235                    Some(AtomicNoteRequest {
1236                        spec: AtomicNoteSpec {
1237                            token,
1238                            id: None,
1239                            kind: &spec.note_kind,
1240                            name: None,
1241                            content,
1242                            properties: spec.tags.clone().map(|tags| json!({"tags": tags})),
1243                        },
1244                        options: spec.note_options(),
1245                    })
1246                }
1247                StreamBatchPreparation::Create(create) => Some(AtomicNoteRequest {
1248                    spec: AtomicNoteSpec {
1249                        token,
1250                        id: None,
1251                        kind: &create.kind,
1252                        name: create.fields.name.as_deref(),
1253                        content: &create.fields.content,
1254                        properties: create.fields.properties.clone(),
1255                    },
1256                    options: AtomicNoteOptions {
1257                        salience: create.fields.salience,
1258                        decay_factor: create.fields.decay_factor,
1259                        key: Some(&create.key),
1260                        embed: Some(create.fields.embed.unwrap_or(create.kind != "head")),
1261                        ..Default::default()
1262                    },
1263                }),
1264            })
1265            .collect();
1266        let notes = prepare_atomic_note_requests(self, requests).await?;
1267        let mut note_plans = notes.notes.into_iter().zip(notes.plans);
1268        let mut prepared = Vec::with_capacity(pending.len());
1269        for (index, pending) in pending.into_iter().enumerate() {
1270            let action = match pending {
1271                StreamBatchPreparation::Ready(action) => *action,
1272                StreamBatchPreparation::Append(append) => {
1273                    let (spec, _) = *append;
1274                    let (note, plan) = note_plans.next().expect("one plan per new note");
1275                    PreparedBatchAction::Append {
1276                        stream: spec.stream,
1277                        expected_seq: spec.expected_seq,
1278                        note: Box::new(note),
1279                        plan,
1280                        fence: spec.fence,
1281                    }
1282                }
1283                StreamBatchPreparation::Create(create) => {
1284                    let (note, mut plan) = note_plans.next().expect("one plan per new note");
1285                    let AtomicOpPlan::AddNote(add) = &mut plan else {
1286                        return Err(RuntimeError::Internal(
1287                            "stream keyed create did not prepare a note".into(),
1288                        ));
1289                    };
1290                    add.post_commit = PostCommitEffect::NoteChanged {
1291                        note_id: note.id,
1292                        kind: note.kind.clone(),
1293                    };
1294                    PreparedBatchAction::Write {
1295                        key: create.key,
1296                        kind: create.kind,
1297                        id: note.id,
1298                        plan,
1299                        after_create: Some(create.args),
1300                    }
1301                }
1302            };
1303            prepared.push(PreparedBatchMember { index, action });
1304        }
1305        Ok(prepared)
1306    }
1307
1308    async fn stream_batch_after_create(
1309        &self,
1310        registry: &VerbRegistry,
1311        hooks: Vec<(Uuid, String, Value)>,
1312    ) {
1313        for (id, kind, args) in hooks {
1314            if let Some(hook) = registry.find_kind_hook(&kind) {
1315                if let Err(error) = hook.after_create(self, id, &args).await {
1316                    tracing::warn!(%id, %kind, %error, "stream batch after_create failed after commit");
1317                }
1318            }
1319        }
1320    }
1321
1322    /// All members share one transaction. Batch predicates precede member DML;
1323    /// effects become executable only after the complete unit commits.
1324    pub async fn stream_batch_atomic(
1325        &self,
1326        token: &NamespaceToken,
1327        members: Vec<StreamBatchMember>,
1328        fence: Option<NoteFence>,
1329        observed: Vec<StreamObservation>,
1330        registry: &VerbRegistry,
1331    ) -> RuntimeResult<Result<Vec<Value>, StreamBatchRefusal>> {
1332        self.validate_stream_batch(&members, true)?;
1333        if let Some(fence) = &fence {
1334            fence.validate()?;
1335            self.validate_note_kind(&fence.kind)?;
1336        }
1337        for entry in &observed {
1338            if entry.id.is_some() && entry.version.is_none() {
1339                return Err(RuntimeError::InvalidInput(
1340                    "observed id requires a positive version".into(),
1341                ));
1342            }
1343            if entry.live_until.is_some() && entry.version.is_none() {
1344                return Err(RuntimeError::InvalidInput(
1345                    "observed live_until requires a positive version".into(),
1346                ));
1347            }
1348            crate::keyed_memory::validate_memory_key(&entry.key)?;
1349            self.validate_note_kind(&entry.kind)?;
1350            if entry.version.is_some_and(|version| version < 1) {
1351                return Err(RuntimeError::InvalidInput(
1352                    "observed version must be positive or null".into(),
1353                ));
1354            }
1355        }
1356        validate_stream_batch_fence_count(stream_batch_fence_count(&members, fence.as_ref()))?;
1357        for (member, item) in members.iter().enumerate() {
1358            if let StreamBatchMember::Refused(error) = item {
1359                return Ok(Err(StreamBatchRefusal {
1360                    member,
1361                    error: place_member(error.clone(), Some(member))?,
1362                }));
1363            }
1364        }
1365        let prepared = self.prepare_stream_batch(token, members, registry).await?;
1366        let hooks = batch_create_hooks(&prepared);
1367        match run_prepared_stream_batch(
1368            self.sql().as_ref(),
1369            token.namespace().as_str().into(),
1370            prepared,
1371            fence,
1372            observed,
1373        )
1374        .await?
1375        {
1376            Ok((results, effects)) => {
1377                crate::atomic_prepare::apply_post_commit_effects_with_report(self, token, effects)
1378                    .await?;
1379                self.stream_batch_after_create(registry, hooks).await;
1380                Ok(Ok(results))
1381            }
1382            Err(refusal) => Ok(Err(refusal)),
1383        }
1384    }
1385
1386    /// One writer transaction per member, in list order. Every member is
1387    /// prepared before the first write; a member's refusal is returned as its
1388    /// value and its siblings stand, so numbers on one stream increase with
1389    /// list position but another writer's append may fall between them.
1390    pub async fn stream_batch_per_member(
1391        &self,
1392        token: &NamespaceToken,
1393        members: Vec<StreamBatchMember>,
1394        registry: &VerbRegistry,
1395    ) -> RuntimeResult<Vec<Value>> {
1396        self.validate_stream_batch(&members, false)?;
1397        validate_stream_batch_fence_count(stream_batch_fence_count(&members, None))?;
1398        let mut results = Vec::with_capacity(members.len());
1399        let prepared = self.prepare_stream_batch(token, members, registry).await?;
1400        for member in prepared {
1401            if let PreparedBatchAction::Refused(error) = &member.action {
1402                results.push(refusal_value(&place_member(error.clone(), None)?)?);
1403                continue;
1404            }
1405            let hooks = batch_create_hooks(std::slice::from_ref(&member));
1406            match run_prepared_stream_batch(
1407                self.sql().as_ref(),
1408                token.namespace().as_str().into(),
1409                vec![member],
1410                None,
1411                vec![],
1412            )
1413            .await?
1414            {
1415                Ok((mut values, effects)) => {
1416                    crate::atomic_prepare::apply_post_commit_effects_with_report(
1417                        self, token, effects,
1418                    )
1419                    .await?;
1420                    self.stream_batch_after_create(registry, hooks).await;
1421                    results.append(&mut values);
1422                }
1423                Err(refusal) => results.push(refusal_value(&place_member(refusal.error, None)?)?),
1424            }
1425        }
1426        Ok(results)
1427    }
1428
1429    /// Read one ordered page and its head from one SQL snapshot.
1430    pub async fn stream_read(
1431        &self,
1432        token: &NamespaceToken,
1433        stream: &str,
1434        after: i64,
1435        limit: i64,
1436    ) -> RuntimeResult<Value> {
1437        validate_stream(stream)?;
1438        if after < 0 || limit < 1 {
1439            return Err(RuntimeError::InvalidInput(
1440                "stream.read requires after >= 0 and limit >= 1".into(),
1441            ));
1442        }
1443        let mut reader = self.sql().reader().await?;
1444        let rows = reader.query_all(SqlStatement::new(
1445            "WITH head AS (SELECT COALESCE(MAX(seq),0) AS head_seq FROM note_streams WHERE namespace=?1 AND stream=?2), \
1446             page AS (SELECT s.seq,n.id,n.content,n.created_at FROM note_streams s JOIN notes n ON n.id=s.note_id \
1447                      WHERE s.namespace=?1 AND s.stream=?2 AND s.seq>?3 ORDER BY s.seq LIMIT ?4) \
1448             SELECT head.head_seq,page.seq,page.id,page.content,page.created_at FROM head LEFT JOIN page ON 1=1 ORDER BY page.seq",
1449            vec![SqlValue::Text(token.namespace().as_str().into()), SqlValue::Text(stream.into()), SqlValue::Integer(after), SqlValue::Integer(limit)],
1450        ).labelled("stream")).await?;
1451        let head = rows
1452            .first()
1453            .map(|row| {
1454                row.i64("head_seq").map_err(|_| {
1455                    RuntimeError::Internal("stream query missing integer head_seq".to_owned())
1456                })
1457            })
1458            .transpose()?
1459            .unwrap_or(0);
1460        let mut entries = Vec::new();
1461        let mut last = None;
1462        for row in &rows {
1463            if matches!(row.get("seq"), Some(SqlValue::Null)) {
1464                continue;
1465            }
1466            let seq = row.i64("seq").map_err(|_| {
1467                RuntimeError::Internal("stream query missing integer seq".to_owned())
1468            })?;
1469            let record: Value = serde_json::from_str(row.text("content").map_err(|_| {
1470                RuntimeError::Internal("stream query missing text content".to_owned())
1471            })?)
1472            .map_err(|e| RuntimeError::Internal(format!("invalid stream record JSON: {e}")))?;
1473            entries.push(json!({"seq": seq, "id": row.text("id").map_err(|_| RuntimeError::Internal("stream query missing text id".to_owned()))?, "record": record, "created_at": micros_to_iso(row.i64("created_at").map_err(|_| RuntimeError::Internal("stream query missing integer created_at".to_owned()))?)}));
1474            last = Some(seq);
1475        }
1476        Ok(
1477            json!({"entries": entries, "head_seq": head, "next_after": last.filter(|last| *last < head)}),
1478        )
1479    }
1480
1481    /// Independently count entries and read the head in the same statement.
1482    pub async fn stream_stat(&self, token: &NamespaceToken, stream: &str) -> RuntimeResult<Value> {
1483        validate_stream(stream)?;
1484        let row = self.sql().reader().await?.query_row(SqlStatement::new(
1485            "SELECT COUNT(*) AS count, COALESCE(MAX(seq),0) AS head_seq FROM note_streams WHERE namespace=?1 AND stream=?2",
1486            vec![SqlValue::Text(token.namespace().as_str().into()), SqlValue::Text(stream.into())],
1487        ).labelled("stream")).await?.ok_or_else(|| RuntimeError::Internal("stream.stat returned no aggregate row".into()))?;
1488        Ok(
1489            json!({"head_seq": row.i64("head_seq").map_err(|_| RuntimeError::Internal("stream query missing integer head_seq".to_owned()))?, "count": row.i64("count").map_err(|_| RuntimeError::Internal("stream query missing integer count".to_owned()))?}),
1490        )
1491    }
1492
1493    /// Membership lookup only after the caller has resolved an accessible note.
1494    /// Match the stored namespace as well as its globally unique id.
1495    pub(crate) async fn stream_member_error(
1496        &self,
1497        note: &Note,
1498    ) -> RuntimeResult<Option<RuntimeError>> {
1499        let row = self
1500            .sql()
1501            .reader()
1502            .await?
1503            .query_row(
1504                SqlStatement::new(
1505                    "SELECT stream,seq FROM note_streams WHERE namespace=?1 AND note_id=?2",
1506                    vec![
1507                        SqlValue::Text(note.namespace.clone()),
1508                        SqlValue::Text(note.id.to_string()),
1509                    ],
1510                )
1511                .labelled("stream"),
1512            )
1513            .await?;
1514        row.map(|row| {
1515            Ok(KhiveError::conflict("stream entries are immutable")
1516                .with_details(Details::new_owned([
1517                    ("reason", "stream_member".into()),
1518                    ("id", note.id.to_string()),
1519                    (
1520                        "stream",
1521                        row.text("stream")
1522                            .map_err(|_| {
1523                                RuntimeError::Internal(
1524                                    "stream query missing text stream".to_owned(),
1525                                )
1526                            })?
1527                            .into(),
1528                    ),
1529                    (
1530                        "seq",
1531                        row.i64("seq")
1532                            .map_err(|_| {
1533                                RuntimeError::Internal(
1534                                    "stream query missing integer seq".to_owned(),
1535                                )
1536                            })?
1537                            .to_string(),
1538                    ),
1539                ]))
1540                .into())
1541        })
1542        .transpose()
1543    }
1544}
1545
1546#[cfg(test)]
1547#[path = "streams_batch_tests.rs"]
1548mod batch_tests;
1549
1550#[cfg(test)]
1551#[path = "streams_unit_tests.rs"]
1552mod tests;