promptforge-core 0.1.0

PromptForge runtime core: prompt parser, HTTP client, section execution
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
//! Report-only observation for a run in flight.
//!
//! [`Observer`] receives a borrowed `(execution, section)` pair and one typed
//! [`Observation`] at operational boundaries. The observation is the complete
//! trace record. Fixed runtime observations carry no raw prompt prose, model
//! input or output, tool arguments or results, store paths or contents,
//! credentials, or fetched content. Reports are synchronous and never consulted
//! for a decision. [`NullObserver`] provides silence without a second execution
//! path.
//!
//! # Sensitivity of metadata
//! The variant *identity* of a fixed [`Observation`] is safe, but three inputs
//! are author-controlled and must be treated as potentially sensitive untrusted
//! metadata, not as safe fixed vocabulary:
//! - `execution` - a caller-chosen run identifier;
//! - `section` - the prompt's H2 heading text, authored in the prompt file;
//! - [`Observation::Lua`] and [`Observation::Other`] messages - a validated Lua
//!   `log(message)` checkpoint and the forward-compatible escape hatch.
//!
//! An [`Observer`] that persists or forwards reports owns treating `execution`,
//! `section`, and any message-carrying variant as untrusted: they can echo
//! prompt-authored text, so a sink must not log them into a trusted context, and
//! prompt authors must never place arguments, replies, tool data, credentials,
//! paths, or store contents in a `log(message)`.

use std::fmt;

/// One typed operational observation emitted by the runtime.
///
/// Every fixed variant maps 1:1 to a fixed lifecycle boundary; its
/// [`Display`](fmt::Display) rendering is the stable trace string. A consumer
/// may match individual variants for cosmetic presentation, but must tolerate
/// unknown variants (this enum is `#[non_exhaustive]`) and must never use an
/// observation to steer execution.
///
/// [`Observation::Lua`] carries the one intentionally author-controlled
/// checkpoint (the Lua `log(message)` callback); [`Observation::Other`] is a
/// forward-compatible escape hatch. Both own their message, so an observation
/// crosses a thread boundary (fanout arms report through a channel) without
/// borrowing the emitting frame.
///
/// # Examples
/// Match the variants a consumer cares about, use [`label`](Observation::label)
/// and [`Display`](fmt::Display), and tolerate unknown variants through a
/// wildcard arm (the enum is `#[non_exhaustive]`):
///
/// ```
/// use promptforge_core::observe::Observation;
///
/// fn describe(event: &Observation) -> String {
///     match event {
///         Observation::RunStarted => "run began".to_owned(),
///         // The author-controlled checkpoint owns its message.
///         Observation::Lua(message) => format!("lua says: {message}"),
///         // A forward-compatible escape hatch.
///         Observation::Other(message) => format!("other: {message}"),
///         // Any other fixed variant renders through its stable label.
///         fixed => fixed.label().unwrap_or("unknown").to_owned(),
///     }
/// }
///
/// assert_eq!(describe(&Observation::RunStarted), "run began");
/// assert_eq!(describe(&Observation::Lua("hi".to_owned())), "lua says: hi");
/// assert_eq!(describe(&Observation::Other("x".to_owned())), "other: x");
/// assert_eq!(describe(&Observation::SectionFinished), "Section finished");
///
/// // Fixed variants expose a stable label; message-carrying ones do not.
/// assert_eq!(Observation::RunStarted.label(), Some("Run started"));
/// assert_eq!(Observation::Lua("hi".to_owned()).label(), None);
/// assert_eq!(Observation::RunStarted.to_string(), "Run started");
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Observation {
    /// Prompt parsing began.
    ParseStarted,
    /// Prompt parsing and parse-time compilation completed successfully.
    ParseSucceeded,
    /// Prompt parsing or parse-time compilation returned an error.
    ParseFailed,
    /// A run passed its version gate and began.
    RunStarted,
    /// A run returned a value.
    RunSucceeded,
    /// A run returned an error.
    RunFailed,
    /// A top-level section began.
    SectionStarted,
    /// A top-level section completed successfully.
    SectionFinished,
    /// A model round trip completed successfully.
    ModelTurnCompleted,
    /// A model round trip returned an error.
    ModelTurnFailed,
    /// A successful parse ended because the model hit its length limit.
    ModelTurnTruncated,
    /// A tool dispatch completed successfully.
    ToolCallSucceeded,
    /// A tool dispatch returned an error.
    ToolCallFailed,
    /// Lua source compilation began.
    LuaCompilationStarted,
    /// Lua source compilation completed successfully.
    LuaCompilationSucceeded,
    /// Lua source compilation returned an error.
    LuaCompilationFailed,
    /// A section VM began loading and executing its shared program.
    LuaSharedLoadStarted,
    /// A section VM loaded and executed its shared program successfully.
    LuaSharedLoadSucceeded,
    /// A section VM failed to load or execute its shared program.
    LuaSharedLoadFailed,
    /// A section VM began executing its prologue.
    LuaPrologueStarted,
    /// A section VM executed its prologue successfully.
    LuaPrologueSucceeded,
    /// A section VM failed to execute its prologue.
    LuaPrologueFailed,
    /// A section VM began binding a model reply.
    LuaReplyBindingStarted,
    /// A section VM bound a model reply successfully.
    LuaReplyBindingSucceeded,
    /// A section VM failed to bind a model reply.
    LuaReplyBindingFailed,
    /// A section VM began executing its epilog.
    LuaEpilogStarted,
    /// A section VM executed its epilog successfully.
    LuaEpilogSucceeded,
    /// A section VM failed to execute its epilog.
    LuaEpilogFailed,
    /// A section VM began teardown.
    LuaTeardownStarted,
    /// A section VM completed teardown.
    LuaTeardownSucceeded,
    /// Live-registry and one-to-one binding validation began.
    ToolRegistryValidationStarted,
    /// Live-registry and one-to-one binding validation succeeded.
    ToolRegistryValidationSucceeded,
    /// Live-registry or one-to-one binding validation failed.
    ToolRegistryValidationFailed,
    /// A section began closing its effective tool scope.
    ToolScopeClosing,
    /// A section's effective tool scope was closed successfully.
    ToolScopeClosed,
    /// A section's effective tool scope could not be closed.
    ToolScopeFailed,
    /// Semantic validation of a model-visible tool scope began.
    ToolScopeValidationStarted,
    /// A model-visible tool scope passed semantic validation.
    ToolScopeValidationSucceeded,
    /// A model-visible tool scope failed semantic validation.
    ToolScopeValidationFailed,
    /// Live-catalog model binding validation began.
    ModelCatalogValidationStarted,
    /// Live-catalog model binding validation succeeded.
    ModelCatalogValidationSucceeded,
    /// Live-catalog model binding validation failed.
    ModelCatalogValidationFailed,
    /// A section began closing its model selection.
    ModelScopeClosing,
    /// A section's model selection was closed successfully.
    ModelScopeClosed,
    /// A section's model selection could not be closed.
    ModelScopeFailed,
    /// A harness-mediated store write succeeded.
    StoreWriteSucceeded,
    /// A harness-mediated store write failed.
    StoreWriteFailed,
    /// A harness-mediated store append succeeded.
    StoreAppendSucceeded,
    /// A harness-mediated store append failed.
    StoreAppendFailed,
    /// A harness-mediated store read_lines succeeded.
    StoreReadLinesSucceeded,
    /// A harness-mediated store read_lines failed.
    StoreReadLinesFailed,
    /// A harness-mediated store read (verbatim) succeeded.
    StoreReadSucceeded,
    /// A harness-mediated store read (verbatim) failed.
    StoreReadFailed,
    /// A harness-mediated store inject succeeded.
    StoreInjectSucceeded,
    /// A harness-mediated store inject failed.
    StoreInjectFailed,
    /// A harness-mediated store replacement succeeded.
    StoreReplaceSucceeded,
    /// A harness-mediated store replacement failed.
    StoreReplaceFailed,
    /// A harness-mediated store deletion succeeded.
    StoreDeleteSucceeded,
    /// A harness-mediated store deletion failed.
    StoreDeleteFailed,
    /// A harness-mediated store glob succeeded.
    StoreGlobSucceeded,
    /// A harness-mediated store glob failed.
    StoreGlobFailed,
    /// A fanout arm began execution.
    ///
    /// Every arm emits exactly one [`FanoutArmStarted`](Observation::FanoutArmStarted)
    /// followed by exactly one terminal event: one of
    /// [`FanoutArmSucceeded`](Observation::FanoutArmSucceeded),
    /// [`FanoutArmExhausted`](Observation::FanoutArmExhausted),
    /// [`FanoutArmFailed`](Observation::FanoutArmFailed), or
    /// [`FanoutArmCancelled`](Observation::FanoutArmCancelled). The runtime
    /// enforces this state machine with a drop guard, so an aborted or
    /// cancelled arm still reports a terminal event.
    FanoutArmStarted,
    /// Legacy generic terminal, retained only so an older consumer's match arm
    /// stays valid. The current runtime never emits it: a finishing arm always
    /// reports one of the specific terminal variants below (succeeded /
    /// exhausted / failed / cancelled).
    FanoutArmFinished,
    /// Terminal: a fanout arm finished with a normal successful result.
    FanoutArmSucceeded,
    /// Terminal: a fanout arm soft-degraded because its tool loop was exhausted.
    FanoutArmExhausted,
    /// Terminal: a fanout arm ended with a hard error.
    FanoutArmFailed,
    /// Terminal: a fanout arm was cancelled or aborted (Ctrl-C or a sibling's
    /// hard error) before it could finalize.
    FanoutArmCancelled,
    /// The one author-controlled checkpoint: a validated Lua `log(message)`.
    ///
    /// Prompt authors must never place arguments, replies, tool data,
    /// credentials, paths, or store contents in this message.
    Lua(String),
    /// A forward-compatible escape hatch for an observation with no fixed
    /// variant.
    Other(String),
}

impl Observation {
    /// Returns the fixed trace label for a fixed variant, or `None` for the
    /// message-carrying [`Observation::Lua`] / [`Observation::Other`].
    #[must_use]
    pub fn label(&self) -> Option<&'static str> {
        let label = match self {
            Observation::ParseStarted => "Parse started",
            Observation::ParseSucceeded => "Parse succeeded",
            Observation::ParseFailed => "Parse failed",
            Observation::RunStarted => "Run started",
            Observation::RunSucceeded => "Run succeeded",
            Observation::RunFailed => "Run failed",
            Observation::SectionStarted => "Section started",
            Observation::SectionFinished => "Section finished",
            Observation::ModelTurnCompleted => "Model turn completed",
            Observation::ModelTurnFailed => "Model turn failed",
            Observation::ModelTurnTruncated => "Model turn truncated",
            Observation::ToolCallSucceeded => "Tool call succeeded",
            Observation::ToolCallFailed => "Tool call failed",
            Observation::LuaCompilationStarted => "Lua compilation started",
            Observation::LuaCompilationSucceeded => "Lua compilation succeeded",
            Observation::LuaCompilationFailed => "Lua compilation failed",
            Observation::LuaSharedLoadStarted => "Lua shared load started",
            Observation::LuaSharedLoadSucceeded => "Lua shared load succeeded",
            Observation::LuaSharedLoadFailed => "Lua shared load failed",
            Observation::LuaPrologueStarted => "Lua prologue started",
            Observation::LuaPrologueSucceeded => "Lua prologue succeeded",
            Observation::LuaPrologueFailed => "Lua prologue failed",
            Observation::LuaReplyBindingStarted => "Lua reply binding started",
            Observation::LuaReplyBindingSucceeded => "Lua reply binding succeeded",
            Observation::LuaReplyBindingFailed => "Lua reply binding failed",
            Observation::LuaEpilogStarted => "Lua epilog started",
            Observation::LuaEpilogSucceeded => "Lua epilog succeeded",
            Observation::LuaEpilogFailed => "Lua epilog failed",
            Observation::LuaTeardownStarted => "Lua teardown started",
            Observation::LuaTeardownSucceeded => "Lua teardown succeeded",
            Observation::ToolRegistryValidationStarted => "Tool registry validation started",
            Observation::ToolRegistryValidationSucceeded => "Tool registry validation succeeded",
            Observation::ToolRegistryValidationFailed => "Tool registry validation failed",
            Observation::ToolScopeClosing => "Tool scope closing",
            Observation::ToolScopeClosed => "Tool scope closed",
            Observation::ToolScopeFailed => "Tool scope failed",
            Observation::ToolScopeValidationStarted => "Tool scope validation started",
            Observation::ToolScopeValidationSucceeded => "Tool scope validation succeeded",
            Observation::ToolScopeValidationFailed => "Tool scope validation failed",
            Observation::ModelCatalogValidationStarted => "Model catalog validation started",
            Observation::ModelCatalogValidationSucceeded => "Model catalog validation succeeded",
            Observation::ModelCatalogValidationFailed => "Model catalog validation failed",
            Observation::ModelScopeClosing => "Model scope closing",
            Observation::ModelScopeClosed => "Model scope closed",
            Observation::ModelScopeFailed => "Model scope failed",
            Observation::StoreWriteSucceeded => "Store write succeeded",
            Observation::StoreWriteFailed => "Store write failed",
            Observation::StoreAppendSucceeded => "Store append succeeded",
            Observation::StoreAppendFailed => "Store append failed",
            Observation::StoreReadLinesSucceeded => "Store read_lines succeeded",
            Observation::StoreReadLinesFailed => "Store read_lines failed",
            Observation::StoreReadSucceeded => "Store read succeeded",
            Observation::StoreReadFailed => "Store read failed",
            Observation::StoreInjectSucceeded => "Store inject succeeded",
            Observation::StoreInjectFailed => "Store inject failed",
            Observation::StoreReplaceSucceeded => "Store replace succeeded",
            Observation::StoreReplaceFailed => "Store replace failed",
            Observation::StoreDeleteSucceeded => "Store delete succeeded",
            Observation::StoreDeleteFailed => "Store delete failed",
            Observation::StoreGlobSucceeded => "Store glob succeeded",
            Observation::StoreGlobFailed => "Store glob failed",
            Observation::FanoutArmStarted => "Fanout arm started",
            Observation::FanoutArmFinished => "Fanout arm finished",
            Observation::FanoutArmSucceeded => "Fanout arm succeeded",
            Observation::FanoutArmExhausted => "Fanout arm exhausted",
            Observation::FanoutArmFailed => "Fanout arm failed",
            Observation::FanoutArmCancelled => "Fanout arm cancelled",
            Observation::Lua(_) | Observation::Other(_) => return None,
        };
        Some(label)
    }
}

impl fmt::Display for Observation {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Observation::Lua(message) => write!(f, "Lua: {message}"),
            Observation::Other(message) => f.write_str(message),
            fixed => f.write_str(fixed.label().unwrap_or_default()),
        }
    }
}

/// Fixed observations emitted by the currently shipped runtime.
///
/// These crate-private constants let emit sites name a lifecycle boundary
/// (`detail::RUN_STARTED`) without repeating the enum path; each is exactly the
/// matching [`Observation`] variant.
pub(crate) mod detail {
    use super::Observation;

    pub(crate) const PARSE_STARTED: Observation = Observation::ParseStarted;
    pub(crate) const PARSE_SUCCEEDED: Observation = Observation::ParseSucceeded;
    pub(crate) const PARSE_FAILED: Observation = Observation::ParseFailed;
    pub(crate) const RUN_STARTED: Observation = Observation::RunStarted;
    pub(crate) const RUN_SUCCEEDED: Observation = Observation::RunSucceeded;
    pub(crate) const RUN_FAILED: Observation = Observation::RunFailed;
    pub(crate) const SECTION_STARTED: Observation = Observation::SectionStarted;
    pub(crate) const SECTION_FINISHED: Observation = Observation::SectionFinished;
    pub(crate) const MODEL_TURN_COMPLETED: Observation = Observation::ModelTurnCompleted;
    pub(crate) const MODEL_TURN_FAILED: Observation = Observation::ModelTurnFailed;
    pub(crate) const MODEL_TURN_TRUNCATED: Observation = Observation::ModelTurnTruncated;
    pub(crate) const TOOL_CALL_SUCCEEDED: Observation = Observation::ToolCallSucceeded;
    pub(crate) const TOOL_CALL_FAILED: Observation = Observation::ToolCallFailed;
    pub(crate) const LUA_COMPILATION_STARTED: Observation = Observation::LuaCompilationStarted;
    pub(crate) const LUA_COMPILATION_SUCCEEDED: Observation = Observation::LuaCompilationSucceeded;
    pub(crate) const LUA_COMPILATION_FAILED: Observation = Observation::LuaCompilationFailed;
    pub(crate) const LUA_SHARED_LOAD_STARTED: Observation = Observation::LuaSharedLoadStarted;
    pub(crate) const LUA_SHARED_LOAD_SUCCEEDED: Observation = Observation::LuaSharedLoadSucceeded;
    pub(crate) const LUA_SHARED_LOAD_FAILED: Observation = Observation::LuaSharedLoadFailed;
    pub(crate) const LUA_PROLOGUE_STARTED: Observation = Observation::LuaPrologueStarted;
    pub(crate) const LUA_PROLOGUE_SUCCEEDED: Observation = Observation::LuaPrologueSucceeded;
    pub(crate) const LUA_PROLOGUE_FAILED: Observation = Observation::LuaPrologueFailed;
    pub(crate) const LUA_REPLY_BINDING_STARTED: Observation = Observation::LuaReplyBindingStarted;
    pub(crate) const LUA_REPLY_BINDING_SUCCEEDED: Observation =
        Observation::LuaReplyBindingSucceeded;
    pub(crate) const LUA_REPLY_BINDING_FAILED: Observation = Observation::LuaReplyBindingFailed;
    pub(crate) const LUA_EPILOG_STARTED: Observation = Observation::LuaEpilogStarted;
    pub(crate) const LUA_EPILOG_SUCCEEDED: Observation = Observation::LuaEpilogSucceeded;
    pub(crate) const LUA_EPILOG_FAILED: Observation = Observation::LuaEpilogFailed;
    pub(crate) const LUA_TEARDOWN_STARTED: Observation = Observation::LuaTeardownStarted;
    pub(crate) const LUA_TEARDOWN_SUCCEEDED: Observation = Observation::LuaTeardownSucceeded;
    pub(crate) const TOOL_SCOPE_CLOSING: Observation = Observation::ToolScopeClosing;
    pub(crate) const TOOL_SCOPE_CLOSED: Observation = Observation::ToolScopeClosed;
    pub(crate) const TOOL_SCOPE_FAILED: Observation = Observation::ToolScopeFailed;
    pub(crate) const TOOL_SCOPE_VALIDATION_STARTED: Observation =
        Observation::ToolScopeValidationStarted;
    pub(crate) const TOOL_SCOPE_VALIDATION_SUCCEEDED: Observation =
        Observation::ToolScopeValidationSucceeded;
    pub(crate) const TOOL_SCOPE_VALIDATION_FAILED: Observation =
        Observation::ToolScopeValidationFailed;
    pub(crate) const MODEL_SCOPE_CLOSING: Observation = Observation::ModelScopeClosing;
    pub(crate) const MODEL_SCOPE_CLOSED: Observation = Observation::ModelScopeClosed;
    pub(crate) const MODEL_SCOPE_FAILED: Observation = Observation::ModelScopeFailed;
    pub(crate) const STORE_WRITE_SUCCEEDED: Observation = Observation::StoreWriteSucceeded;
    pub(crate) const STORE_WRITE_FAILED: Observation = Observation::StoreWriteFailed;
    pub(crate) const STORE_APPEND_SUCCEEDED: Observation = Observation::StoreAppendSucceeded;
    pub(crate) const STORE_APPEND_FAILED: Observation = Observation::StoreAppendFailed;
    pub(crate) const STORE_READ_LINES_SUCCEEDED: Observation = Observation::StoreReadLinesSucceeded;
    pub(crate) const STORE_READ_LINES_FAILED: Observation = Observation::StoreReadLinesFailed;
    pub(crate) const STORE_READ_SUCCEEDED: Observation = Observation::StoreReadSucceeded;
    pub(crate) const STORE_READ_FAILED: Observation = Observation::StoreReadFailed;
    pub(crate) const STORE_INJECT_SUCCEEDED: Observation = Observation::StoreInjectSucceeded;
    pub(crate) const STORE_INJECT_FAILED: Observation = Observation::StoreInjectFailed;
    pub(crate) const STORE_REPLACE_SUCCEEDED: Observation = Observation::StoreReplaceSucceeded;
    pub(crate) const STORE_REPLACE_FAILED: Observation = Observation::StoreReplaceFailed;
    pub(crate) const STORE_DELETE_SUCCEEDED: Observation = Observation::StoreDeleteSucceeded;
    pub(crate) const STORE_DELETE_FAILED: Observation = Observation::StoreDeleteFailed;
    pub(crate) const STORE_GLOB_SUCCEEDED: Observation = Observation::StoreGlobSucceeded;
    pub(crate) const STORE_GLOB_FAILED: Observation = Observation::StoreGlobFailed;
    pub(crate) const FANOUT_ARM_STARTED: Observation = Observation::FanoutArmStarted;
    pub(crate) const FANOUT_ARM_SUCCEEDED: Observation = Observation::FanoutArmSucceeded;
    pub(crate) const FANOUT_ARM_EXHAUSTED: Observation = Observation::FanoutArmExhausted;
    pub(crate) const FANOUT_ARM_FAILED: Observation = Observation::FanoutArmFailed;
    pub(crate) const FANOUT_ARM_CANCELLED: Observation = Observation::FanoutArmCancelled;
}

/// A report-only sink for operational observations.
///
/// The runtime calls [`observe`](Self::observe) synchronously from the task
/// driving a run, so implementations must be `Send + Sync`, non-blocking, and
/// non-panicking. A forwarding implementation should copy the observation into
/// a queue and return rather than awaiting or performing I/O. Concrete
/// observers own synchronization; core provides no global observer lock and
/// holds no observer-owned guard across an await.
///
/// An observation is never read back by the runtime. Recording every report or
/// discarding all of them must leave outputs, errors, ordering, and side effects
/// unchanged.
///
/// # Examples
/// ```
/// use std::sync::atomic::{AtomicUsize, Ordering};
///
/// use promptforge_core::observe::{Observation, Observer};
///
/// #[derive(Default)]
/// struct Counter(AtomicUsize);
///
/// impl Observer for Counter {
///     fn observe(&self, _execution: &str, _section: &str, _event: Observation) {
///         self.0.fetch_add(1, Ordering::Relaxed);
///     }
/// }
///
/// let counter = Counter::default();
/// counter.observe("example-run", "Gather", Observation::SectionFinished);
/// assert_eq!(counter.0.load(Ordering::Relaxed), 1);
/// ```
pub trait Observer: Send + Sync {
    /// Reports one typed [`Observation`] for `execution` and `section`.
    ///
    /// Fixed runtime observations carry no payloads or secrets. The only
    /// author-controlled variant is [`Observation::Lua`]; prompt authors must
    /// never put arguments, replies, tool data, credentials, paths, or store
    /// contents in it. Reports must not affect any execution decision.
    /// Implementations must return promptly and must not panic.
    ///
    /// # Examples
    /// A handler matches the typed event and treats the author-controlled
    /// [`Observation::Lua`] checkpoint as untrusted metadata (never logged
    /// verbatim or forwarded to a model-facing sink), while fixed lifecycle
    /// variants carry no payload and are safe to record. [`Observation`] is
    /// `#[non_exhaustive]`, so a wildcard arm is required:
    /// ```
    /// use promptforge_core::observe::{Observation, NullObserver, Observer};
    ///
    /// let observer = NullObserver::default();
    /// let event = Observation::Lua("author checkpoint text".to_owned());
    /// match event {
    ///     Observation::Lua(note) => {
    ///         // Author-controlled: keep only a payload-free signal (its length),
    ///         // never `note` verbatim.
    ///         let _sensitive_len = note.len();
    ///     }
    ///     safe => observer.observe("example-run", "Gather", safe),
    /// }
    /// ```
    fn observe(&self, execution: &str, section: &str, event: Observation);
}

/// An [`Observer`] that discards every observation.
///
/// This is what a caller wanting no progress passes, so the executor never
/// needs an `Option<&dyn Observer>` and never branches on one.
///
/// # Examples
/// ```
/// use promptforge_core::observe::{Observation, NullObserver, Observer};
///
/// // `#[non_exhaustive]`, so construct it through `Default` rather than the
/// // unit literal.
/// let observer = NullObserver::default();
/// observer.observe("example-run", "Example prompt", Observation::RunSucceeded);
/// ```
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct NullObserver;

impl Observer for NullObserver {
    fn observe(&self, _execution: &str, _section: &str, _event: Observation) {}
}

#[cfg(test)]
mod tests {
    use std::sync::{Arc, Barrier, Mutex};

    use super::*;

    #[test]
    fn null_observer_accepts_reports() {
        let observer = NullObserver;
        observer.observe("example-run", "Prompt", Observation::RunStarted);
        observer.observe("example-run", "Gather", Observation::SectionStarted);
        observer.observe("example-run", "Gather", Observation::SectionFinished);
        observer.observe("example-run", "Prompt", Observation::RunSucceeded);
    }

    #[test]
    fn display_renders_stable_strings() {
        assert_eq!(Observation::RunStarted.to_string(), "Run started");
        assert_eq!(
            Observation::StoreReadLinesSucceeded.to_string(),
            "Store read_lines succeeded"
        );
        assert_eq!(Observation::Lua("hi".to_owned()).to_string(), "Lua: hi");
        assert_eq!(Observation::Other("x".to_owned()).to_string(), "x");
        assert_eq!(Observation::RunStarted.label(), Some("Run started"));
        assert_eq!(Observation::Lua("hi".to_owned()).label(), None);
    }

    #[test]
    fn observer_is_dyn_compatible_and_shareable() {
        fn assert_send_sync<T: Send + Sync + ?Sized>() {}
        assert_send_sync::<dyn Observer>();

        let observer: &dyn Observer = &NullObserver;
        observer.observe("example-run", "Gather", Observation::SectionFinished);
    }

    /// A recorder that keeps every correlated `(execution, section, event)`
    /// record, for the cross-module contract tests below.
    #[derive(Default)]
    struct Recorder(Mutex<Vec<(String, String, Observation)>>);

    impl Observer for Recorder {
        fn observe(&self, execution: &str, section: &str, event: Observation) {
            self.0
                .lock()
                .expect("recorder mutex must remain usable")
                .push((execution.to_owned(), section.to_owned(), event));
        }
    }

    impl Recorder {
        fn records(&self) -> Vec<(String, String, Observation)> {
            self.0
                .lock()
                .expect("recorder mutex must remain usable")
                .clone()
        }
    }

    #[test]
    fn unknown_and_message_variants_are_tolerated_by_a_wildcard_consumer() {
        // F7 (unknown events): a consumer that matches only the variants it
        // knows must tolerate `Other` (a forward-compatible variant it does not
        // model) through a wildcard arm, and the message-carrying variants must
        // preserve their author-controlled text verbatim.
        fn classify(event: &Observation) -> &'static str {
            match event {
                Observation::RunStarted => "known-fixed",
                Observation::Lua(_) => "lua-checkpoint",
                _ => "unknown-or-other",
            }
        }
        assert_eq!(classify(&Observation::RunStarted), "known-fixed");
        assert_eq!(
            classify(&Observation::Lua("hi".to_owned())),
            "lua-checkpoint"
        );
        // `Other` stands in for a future variant this consumer has never seen.
        assert_eq!(
            classify(&Observation::Other("future".to_owned())),
            "unknown-or-other"
        );
        assert_eq!(classify(&Observation::SectionFinished), "unknown-or-other");
        assert_eq!(
            Observation::Lua("secret note".to_owned()).to_string(),
            "Lua: secret note"
        );
        assert_eq!(
            Observation::Other("verbatim".to_owned()).to_string(),
            "verbatim"
        );
    }

    #[test]
    fn parse_failure_pairs_started_with_failed_and_carries_author_labels() {
        // F7 (failure lifecycle pairing + sensitive labels), cross-module
        // through the parser: a failed parse emits `ParseStarted` first and
        // `ParseFailed` last, and the caller-chosen `execution` id (untrusted,
        // author-controlled metadata) is carried verbatim to the observer.
        use crate::parser::Prompt;

        let recorder = Recorder::default();
        let execution = "author/controlled:run id";
        let _ = Prompt::parse("no frontmatter here", execution, &recorder)
            .expect_err("a source without frontmatter must fail to parse");

        let records = recorder.records();
        assert_eq!(
            records.first().map(|(_, _, event)| event),
            Some(&Observation::ParseStarted),
            "the lifecycle must open with ParseStarted: {records:?}"
        );
        assert_eq!(
            records.last().map(|(_, _, event)| event),
            Some(&Observation::ParseFailed),
            "a failed parse must close with ParseFailed: {records:?}"
        );
        assert!(
            records
                .iter()
                .all(|(seen_execution, _, _)| seen_execution == execution),
            "the author-controlled execution id must be carried verbatim: {records:?}"
        );

        // The success lifecycle pairs Started with Succeeded instead.
        let recorder = Recorder::default();
        let source =
            "---\nname: greeter\ndescription: d\npromptforge: 1\n---\n\n# T\n\n## S\n\nhi\n";
        Prompt::parse(source, execution, &recorder).expect("a well-formed source must parse");
        let events: Vec<Observation> = recorder
            .records()
            .into_iter()
            .map(|(_, _, event)| event)
            .collect();
        assert_eq!(events.first(), Some(&Observation::ParseStarted));
        assert_eq!(events.last(), Some(&Observation::ParseSucceeded));
    }

    #[test]
    fn interleaved_reports_stay_correlated_by_execution_and_section() {
        #[derive(Default)]
        struct Recorder(Mutex<Vec<(String, String, Observation)>>);

        impl Observer for Recorder {
            fn observe(&self, execution: &str, section: &str, event: Observation) {
                self.0
                    .lock()
                    .expect("recorder mutex must remain usable")
                    .push((execution.to_owned(), section.to_owned(), event));
            }
        }

        let recorder = Arc::new(Recorder::default());
        let barrier = Arc::new(Barrier::new(2));
        let first_recorder = Arc::clone(&recorder);
        let first_barrier = Arc::clone(&barrier);
        let first = std::thread::spawn(move || {
            first_recorder.observe("execution-a", "First", detail::SECTION_STARTED);
            first_barrier.wait();
            first_barrier.wait();
            first_recorder.observe("execution-a", "First", detail::SECTION_FINISHED);
            first_barrier.wait();
            first_barrier.wait();
        });
        let second_recorder = Arc::clone(&recorder);
        let second = std::thread::spawn(move || {
            barrier.wait();
            second_recorder.observe("execution-b", "Second", detail::SECTION_STARTED);
            barrier.wait();
            barrier.wait();
            second_recorder.observe("execution-b", "Second", detail::SECTION_FINISHED);
            barrier.wait();
        });

        first.join().expect("first recording thread must finish");
        second.join().expect("second recording thread must finish");
        assert_eq!(
            *recorder
                .0
                .lock()
                .expect("recorder mutex must remain usable"),
            [
                (
                    "execution-a".to_owned(),
                    "First".to_owned(),
                    Observation::SectionStarted,
                ),
                (
                    "execution-b".to_owned(),
                    "Second".to_owned(),
                    Observation::SectionStarted,
                ),
                (
                    "execution-a".to_owned(),
                    "First".to_owned(),
                    Observation::SectionFinished,
                ),
                (
                    "execution-b".to_owned(),
                    "Second".to_owned(),
                    Observation::SectionFinished,
                ),
            ]
        );
    }
}