forge_sdk/request_id.rs
1//! Request ID generator for outbound `control_request` frames.
2//!
3//! Shape: `forge_<counter>_<hex8>` (e.g. `forge_42_3a9f2b0e`). The
4//! `forge_` prefix lets stream-json logs distinguish
5//! forge-sdk-originated requests from CLI-originated ones at a
6//! glance. The CLI treats request IDs as opaque - it only echoes
7//! them back in the matching `control_response` - so the prefix
8//! has no wire-protocol effect.
9
10use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
11
12static COUNTER: AtomicU64 = AtomicU64::new(0);
13
14/// Generate a new opaque request id in the shape `forge_<counter>_<hex8>`.
15/// The 8-hex (4 bytes) suffix is random. Falls back to counter bytes
16/// if `getrandom` fails - surfaces a one-shot `tracing::warn!` so a
17/// sandboxed runtime where the entropy source is unreachable
18/// (chroot without `/dev/urandom`, seccomp filter, etc.) is visible
19/// to log readers without breaking ID generation.
20pub fn next() -> String {
21 mint("forge")
22}
23
24/// Generate a new prompt id in the shape `forge_prompt_<counter>_<hex8>`.
25///
26/// Stamped as the `uuid` on every user frame forge writes. The CLI echoes it
27/// back on each of that prompt's `command_lifecycle` frames, which is what
28/// lets a view key a queued card to its own send; the distinct prefix keeps
29/// prompt ids apart from control request ids in a stream-json log.
30///
31/// Never reuse one: the CLI treats a repeated id as a duplicate and drops the
32/// prompt silently (measured, no frames and no delivery).
33pub fn next_prompt_id() -> String {
34 mint("forge_prompt")
35}
36
37/// Generate a new prompt id for a prompt forge's own machinery dispatches: a
38/// chase nudge, a browser warning, a kick, an auto-continue.
39///
40/// **The distinct prefix is a marker, not decoration.** The id is the only
41/// fact about the sender the CLI carries into the transcript - as a row's
42/// `uuid`, or the `source_uuid` of its queued attachment - so a view keys on
43/// the prefix to draw the machinery's words as a work row rather than as the
44/// reader's own, and a reloaded row draws what the live one did. The CLI
45/// treats ids as opaque, so the prefix has no wire-protocol effect.
46pub fn next_forged_prompt_id() -> String {
47 mint("forge_nudge")
48}
49
50/// Generate a prompt id for the handoff's procedure turn: the judgment prompt
51/// forge itself writes, which asks the handing-over session to run the handoff
52/// skill.
53///
54/// **The extra segment sits under the machinery's prefix on purpose.** Every
55/// row that reads `forge_nudge_` to tell the machinery's words from the
56/// reader's keeps answering the same way, while a view that knows the longer
57/// prefix draws the procedure as its own row. A client that does not know it
58/// falls back to the ordinary machinery row rather than to the reader's own.
59pub fn next_handoff_prompt_id() -> String {
60 mint(HANDOFF_PROMPT_PREFIX)
61}
62
63/// The prefix [`next_handoff_prompt_id`] mints under, shared so a reader
64/// matching the procedure row and the mint cannot drift apart.
65pub const HANDOFF_PROMPT_PREFIX: &str = "forge_nudge_handoff";
66
67/// The mint itself: `<prefix>_<counter>_<hex8>`, with the counter-derived
68/// suffix standing in when the entropy source is unreachable.
69fn mint(prefix: &str) -> String {
70 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
71 let mut bytes = [0_u8; 4];
72 if let Err(e) = getrandom::fill(&mut bytes) {
73 static LOGGED_ONCE: AtomicBool = AtomicBool::new(false);
74 if !LOGGED_ONCE.swap(true, Ordering::Relaxed) {
75 tracing::warn!(
76 error = %e,
77 "getrandom failed; falling back to counter-derived id suffix"
78 );
79 }
80 bytes.copy_from_slice(&n.to_le_bytes()[..4]);
81 }
82 format!("{prefix}_{n}_{}", hex::encode(bytes))
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88
89 /// The two namespaces are a view's marker: it tells the machinery's
90 /// prompts from the reader's own by the prefix alone, so a renamed
91 /// mint silently redraws one as the other.
92 #[test]
93 fn the_minted_prefixes_separate_the_machinery_from_the_reader() {
94 assert!(next_prompt_id().starts_with("forge_prompt_"), "a reader's send");
95 assert!(next_forged_prompt_id().starts_with("forge_nudge_"), "the machinery's own");
96 }
97
98 /// **The procedure's id is still the machinery's.** Its marker is the extra
99 /// segment, so every existing check keeps matching it while a view can pick
100 /// it out; a mint outside `forge_nudge_` would redraw it as the reader's.
101 #[test]
102 fn the_procedure_prompt_is_a_machinery_row_with_its_own_marker() {
103 let id = next_handoff_prompt_id();
104 assert!(id.starts_with("forge_nudge_handoff_"), "the procedure's own marker: {id}");
105 assert!(id.starts_with("forge_nudge_"), "and still the machinery's: {id}");
106 }
107}