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