Skip to main content

netget/server/bolt/
actions.rs

1//! Bolt actions: what the model is told, and what its answers become.
2//!
3//! The model answers two questions — may this login in, and what does this Cypher query return —
4//! and never anything about the protocol's mechanics. Its answers are validated here and handed
5//! to the session as [`ActionResult::Custom`]; the session owns the state machine, turns a
6//! `send_bolt_records` answer into RUN's SUCCESS, the RECORDs PULL asks for and the summary, and
7//! writes every byte.
8
9use super::values;
10use crate::llm::actions::{
11    protocol_trait::{ActionResult, Protocol, Server},
12    ActionDefinition, Parameter,
13};
14use crate::protocol::log_template::LogTemplate;
15use crate::protocol::EventType;
16use crate::state::app_state::AppState;
17use anyhow::{anyhow, Context, Result};
18use serde_json::{json, Value};
19use std::sync::LazyLock;
20
21/// `ActionResult::Custom` names the session recognises.
22pub const LOGIN_RESULT: &str = "bolt_login";
23pub const RECORDS_RESULT: &str = "bolt_records";
24pub const FAILURE_RESULT: &str = "bolt_failure";
25
26/// The code a login rejection carries when the model names none.
27pub const UNAUTHORIZED: &str = "Neo.ClientError.Security.Unauthorized";
28
29pub struct BoltProtocol;
30
31impl BoltProtocol {
32    pub fn new() -> Self {
33        Self
34    }
35}
36
37impl Default for BoltProtocol {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43impl Protocol for BoltProtocol {
44    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
45        use crate::llm::actions::ParameterDefinition;
46        vec![
47            ParameterDefinition {
48                name: "password".to_string(),
49                type_hint: "string".to_string(),
50                description: "Password every basic-auth login must present. NetGet compares it \
51                              itself (constant time) and refuses a mismatch with \
52                              Neo.ClientError.Security.Unauthorized without asking anyone; the \
53                              credential never reaches an event. Unset: any password passes to \
54                              the bolt_authenticate event, which decides."
55                    .to_string(),
56                required: false,
57                example: json!("s3cret"),
58                default: None,
59            },
60            ParameterDefinition {
61                name: "neo4j_version".to_string(),
62                type_hint: "string".to_string(),
63                description: format!(
64                    "Neo4j version the server claims (default {}): the HELLO agent is \
65                     'Neo4j/<version>' - drivers refuse any agent not starting 'Neo4j/' - and \
66                     CALL dbms.components() reports it.",
67                    super::DEFAULT_NEO4J_VERSION
68                ),
69                required: false,
70                example: json!("5.26.0"),
71                default: Some(json!(super::DEFAULT_NEO4J_VERSION)),
72            },
73            ParameterDefinition {
74                name: "first_byte_timeout_secs".to_string(),
75                type_hint: "number".to_string(),
76                description: "Seconds a new connection may take to send the 20-byte Bolt \
77                              handshake (default 30). Bolt is client-speaks-first and every \
78                              driver sends the handshake the moment it connects."
79                    .to_string(),
80                required: false,
81                example: json!(30),
82                default: Some(json!(super::FIRST_BYTE_TIMEOUT.as_secs())),
83            },
84            ParameterDefinition {
85                name: "idle_timeout_secs".to_string(),
86                type_hint: "number".to_string(),
87                description: "Seconds the server waits for the next message once the handshake \
88                              is done (default 300). Drivers pool connections and leave them \
89                              idle between queries, so this is deliberately long. It never runs \
90                              while a query is waiting on the model or on a human."
91                    .to_string(),
92                required: false,
93                example: json!(300),
94                default: Some(json!(super::IDLE_TIMEOUT.as_secs())),
95            },
96        ]
97    }
98
99    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
100        Vec::new()
101    }
102
103    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
104        vec![
105            accept_bolt_login_action(),
106            reject_bolt_login_action(),
107            send_bolt_records_action(),
108            send_bolt_failure_action(),
109            close_connection_action(),
110        ]
111    }
112
113    fn protocol_name(&self) -> &'static str {
114        "Bolt"
115    }
116
117    fn get_event_types(&self) -> Vec<EventType> {
118        vec![BOLT_AUTHENTICATE_EVENT.clone(), BOLT_QUERY_EVENT.clone()]
119    }
120
121    fn stack_name(&self) -> &'static str {
122        "ETH>IP>TCP>BOLT"
123    }
124
125    fn keywords(&self) -> Vec<&'static str> {
126        vec!["bolt", "neo4j", "cypher", "graph database", "cypher-shell"]
127    }
128
129    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
130        use crate::protocol::metadata::{
131            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
132        };
133
134        ProtocolMetadataV2::builder()
135            .state(DevelopmentState::Beta)
136            .well_known_port(7687)
137            // 7687 is unprivileged, and so is every port a test picks.
138            .privilege_requirement(PrivilegeRequirement::None)
139            .implementation(
140                "Neo4j's Bolt protocol, hand-written over tokio TCP: the 60 60 B0 17 handshake \
141                 negotiating Bolt 5.0-5.8, a depth-bounded PackStream codec and message \
142                 chunking, and the server state machine (HELLO/LOGON, RUN/PULL/DISCARD, \
143                 BEGIN/COMMIT/ROLLBACK, RESET, ROUTE, LOGOFF, TELEMETRY, GOODBYE) owned by NetGet",
144            )
145            .llm_control(
146                "The graph database: whether a login is accepted, and what each Cypher query \
147                 returns - columns, rows (plain values, nodes, relationships, paths), update \
148                 statistics - or which Neo4j error it fails with. NetGet stores no graph.",
149            )
150            .e2e_testing(
151                "tests/server/bolt/real_client_test.rs drives the real cypher-shell (Neo4j's \
152                 Java client, neo4j-java-driver 6.2) against a script handler and a mocked \
153                 model: a query printed row for row, nodes and paths rendered as graph values, \
154                 stats, a model-chosen error printed as that error, a wrong password refused, \
155                 an explicit transaction and neo4j:// routing. It fails, never skips, when \
156                 cypher-shell is absent. tests/server/bolt/e2e_test.rs covers the mocked-model \
157                 path on a raw socket.",
158            )
159            .notes(
160                "Negotiates the highest of Bolt 5.0-5.8 the client offers (cypher-shell \
161                 2026.09 offers 5.8; the 5.7+ handshake manifest is declined). 5.0 \
162                 authenticates in HELLO, 5.1+ in LOGON. FAILURE carries {code, message} before \
163                 5.7 and the GQL error object (gql_status 50N42, neo4j_code) from 5.7. \
164                 Answered by NetGet without a model call: HELLO, ROUTE (a single-server \
165                 routing table naming the address the client used), BEGIN/COMMIT/ROLLBACK, \
166                 RESET, TELEMETRY, and three admin queries cypher-shell sends on connect - CALL \
167                 db.ping(), CALL dbms.licenseAgreementDetails() and CALL dbms.components(). \
168                 PULL/DISCARD require explicit integer n and typed qid over the model's rows; \
169                 known messages require exact arity. Owned model/injected actions are preflighted \
170                 before copies at JSON depth36/nodes65536/retained8MiB; emitted RECORDs include \
171                 their structure/list envelopes in the strict wire-depth32 check. Messages are capped at 1 \
172                 MiB summed over chunks and PackStream nesting at 32; either refusal is a \
173                 Neo.ClientError.Request.Invalid FAILURE and a close. Not implemented: TLS \
174                 (bolt+s), the handshake manifest (Bolt 6), notifications, temporal and \
175                 spatial values in answers. No pcap oracle: this Wireshark build has no Bolt \
176                 dissector.",
177            )
178            .max_inbound_bytes(super::packstream::MAX_MESSAGE_BYTES)
179            // LLM failure: FAILURE Neo.TransientError.General.DatabaseUnavailable with a fixed
180            // message per WireFailure category; the connection stays usable after RESET.
181            .answers_on_failure()
182            .build()
183    }
184
185    fn description(&self) -> &'static str {
186        "Neo4j graph database over Bolt - the model answers every Cypher query"
187    }
188
189    fn example_prompt(&self) -> &'static str {
190        "Neo4j Bolt server on port 7687 - a small movie graph: people who ACTED_IN and DIRECTED \
191         movies"
192    }
193
194    fn group_name(&self) -> &'static str {
195        "Database"
196    }
197
198    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
199        use crate::llm::actions::StartupExamples;
200        StartupExamples::new(
201            json!({
202                "type": "open_server",
203                "port": 7687,
204                "base_stack": "bolt",
205                "instruction": "Neo4j graph database of a film club: Person nodes (name, born) \
206                                who ACTED_IN or DIRECTED Movie nodes (title, released). Accept \
207                                any login. Answer Cypher queries with plausible rows."
208            }),
209            json!({
210                "type": "open_server",
211                "port": 7687,
212                "base_stack": "bolt",
213                "event_handlers": [{
214                    "event_pattern": "bolt_authenticate",
215                    "handler": {"type": "static", "actions": [{"type": "accept_bolt_login"}]}
216                }, {
217                    "event_pattern": "bolt_query",
218                    "handler": {
219                        "type": "script",
220                        "language": "python",
221                        "code": "import json, sys\nq = json.load(sys.stdin)['event'].get('query', '')\nif 'Person' in q:\n    a = [{'type': 'send_bolt_records', 'fields': ['name'], 'records': [['Alice'], ['Bob']]}]\nelse:\n    a = [{'type': 'send_bolt_failure', 'code': 'Neo.ClientError.Statement.SyntaxError', 'message': 'This graph only knows Person nodes'}]\nprint(json.dumps({'actions': a}))"
222                    }
223                }]
224            }),
225            json!({
226                "type": "open_server",
227                "port": 7687,
228                "base_stack": "bolt",
229                "event_handlers": [{
230                    "event_pattern": "bolt_authenticate",
231                    "handler": {"type": "static", "actions": [{"type": "accept_bolt_login"}]}
232                }, {
233                    "event_pattern": "bolt_query",
234                    "handler": {"type": "static", "actions": [{
235                        "type": "send_bolt_records",
236                        "fields": ["n"],
237                        "records": [[{"$node": {"id": 1, "labels": ["Person"],
238                                                 "properties": {"name": "Alice"}}}]]
239                    }]}
240                }]
241            }),
242        )
243    }
244}
245
246impl Server for BoltProtocol {
247    fn spawn(
248        &self,
249        ctx: crate::protocol::SpawnContext,
250    ) -> std::pin::Pin<
251        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
252    > {
253        Box::pin(async move {
254            let params = ctx.startup_params.as_ref();
255            let secs = |name: &str| -> anyhow::Result<Option<u64>> {
256                Ok(params
257                    .map(|p| p.get_optional_u64(name))
258                    .transpose()?
259                    .flatten())
260            };
261            let string = |name: &str| -> anyhow::Result<Option<String>> {
262                Ok(params
263                    .map(|p| p.get_optional_string(name))
264                    .transpose()?
265                    .flatten())
266            };
267            let config = super::BoltConfig {
268                password: string("password")?,
269                neo4j_version: string("neo4j_version")?
270                    .unwrap_or_else(|| super::DEFAULT_NEO4J_VERSION.to_string()),
271                first_byte_timeout: secs("first_byte_timeout_secs")?
272                    .map(std::time::Duration::from_secs)
273                    .unwrap_or(super::FIRST_BYTE_TIMEOUT),
274                idle_timeout: secs("idle_timeout_secs")?
275                    .map(std::time::Duration::from_secs)
276                    .unwrap_or(super::IDLE_TIMEOUT),
277            };
278            crate::server::bolt::BoltServer::spawn_with_llm_actions(
279                ctx.legacy_listen_addr(),
280                ctx.llm_client,
281                ctx.state,
282                ctx.status_tx,
283                ctx.server_id,
284                config,
285            )
286            .await
287        })
288    }
289
290    fn execute_action(&self, action: Value) -> Result<ActionResult> {
291        if !values::action_within_budget(&action) {
292            crate::utils::json_budget::drop_iteratively(action);
293            return Err(anyhow!(
294                "Bolt action exceeds the in-memory depth/node/retained-content budget"
295            ));
296        }
297        let action_type = action
298            .get("type")
299            .and_then(|v| v.as_str())
300            .context("Missing 'type' field in action")?;
301
302        match action_type {
303            "accept_bolt_login" => Ok(ActionResult::Custom {
304                name: LOGIN_RESULT.to_string(),
305                data: json!({"accept": true}),
306            }),
307            "reject_bolt_login" => {
308                let code = action
309                    .get("code")
310                    .and_then(Value::as_str)
311                    .unwrap_or(UNAUTHORIZED);
312                if !code.starts_with("Neo.ClientError.Security.")
313                    || !values::valid_failure_code(code)
314                {
315                    return Err(anyhow!(
316                        "reject_bolt_login code must be a Neo.ClientError.Security.* status \
317                         such as {UNAUTHORIZED}, got '{code}'"
318                    ));
319                }
320                let message = action
321                    .get("message")
322                    .and_then(Value::as_str)
323                    .filter(|m| !m.trim().is_empty())
324                    .unwrap_or("The client is unauthorized due to authentication failure.");
325                Ok(ActionResult::Custom {
326                    name: LOGIN_RESULT.to_string(),
327                    data: json!({"accept": false, "code": code, "message": message}),
328                })
329            }
330            "send_bolt_records" => {
331                values::query_answer(&action).map_err(|e| anyhow!("send_bolt_records: {e}"))?;
332                Ok(ActionResult::Custom {
333                    name: RECORDS_RESULT.to_string(),
334                    data: action.clone(),
335                })
336            }
337            "send_bolt_failure" => {
338                let code = action
339                    .get("code")
340                    .and_then(Value::as_str)
341                    .context("send_bolt_failure needs a 'code'")?;
342                if !values::valid_failure_code(code) {
343                    return Err(anyhow!(
344                        "send_bolt_failure code must look like Neo.ClientError.<Category>.<Title>, \
345                         Neo.TransientError.<Category>.<Title> or \
346                         Neo.DatabaseError.<Category>.<Title>, got '{code}'"
347                    ));
348                }
349                let message = action
350                    .get("message")
351                    .and_then(Value::as_str)
352                    .filter(|m| !m.trim().is_empty())
353                    .context("send_bolt_failure needs a non-empty 'message'")?;
354                Ok(ActionResult::Custom {
355                    name: FAILURE_RESULT.to_string(),
356                    data: json!({"code": code, "message": message}),
357                })
358            }
359            "close_connection" => Ok(ActionResult::CloseConnection),
360            _ => Err(anyhow!("Unknown Bolt action: {}", action_type)),
361        }
362    }
363}
364
365fn accept_bolt_login_action() -> ActionDefinition {
366    ActionDefinition {
367        name: "accept_bolt_login".to_string(),
368        description: "Let this login in. NetGet answers the client's LOGON with SUCCESS and the \
369                      connection can run queries."
370            .to_string(),
371        parameters: vec![],
372        example: json!({"type": "accept_bolt_login"}),
373        log_template: Some(
374            LogTemplate::new()
375                .with_info("-> Bolt login accepted")
376                .with_debug("Bolt accept_bolt_login"),
377        ),
378    }
379}
380
381fn reject_bolt_login_action() -> ActionDefinition {
382    ActionDefinition {
383        name: "reject_bolt_login".to_string(),
384        description: "Refuse this login. The client receives a FAILURE with the code (default \
385                      Neo.ClientError.Security.Unauthorized) and the connection closes."
386            .to_string(),
387        parameters: vec![
388            Parameter {
389                name: "code".to_string(),
390                type_hint: "string".to_string(),
391                description: "A Neo.ClientError.Security.* status, e.g. \
392                              Neo.ClientError.Security.Unauthorized or \
393                              Neo.ClientError.Security.AuthenticationRateLimit"
394                    .to_string(),
395                required: false,
396            },
397            Parameter {
398                name: "message".to_string(),
399                type_hint: "string".to_string(),
400                description: "The message the client prints".to_string(),
401                required: false,
402            },
403        ],
404        example: json!({
405            "type": "reject_bolt_login",
406            "code": "Neo.ClientError.Security.Unauthorized",
407            "message": "The client is unauthorized due to authentication failure."
408        }),
409        log_template: Some(
410            LogTemplate::new()
411                .with_info("-> Bolt login rejected: {code}")
412                .with_debug("Bolt reject_bolt_login: code={code}"),
413        ),
414    }
415}
416
417fn send_bolt_records_action() -> ActionDefinition {
418    ActionDefinition {
419        name: "send_bolt_records".to_string(),
420        description: "Answer the Cypher query with a result: column names and rows. NetGet sends \
421                      the column list, streams the rows as the client pulls them, and ends with \
422                      the summary. A query that returns nothing (a CREATE without RETURN) has an \
423                      empty 'fields' and 'records'. A value may be any JSON; a graph value is \
424                      {\"$node\": {id, labels, properties}}, {\"$relationship\": {id, type, \
425                      start, end, properties}} or {\"$path\": {nodes: [...], relationships: \
426                      [...]}} where relationships[i] joins nodes[i] and nodes[i+1]."
427            .to_string(),
428        parameters: vec![
429            Parameter {
430                name: "fields".to_string(),
431                type_hint: "array".to_string(),
432                description: "Column names, in order - the names after RETURN / AS".to_string(),
433                required: true,
434            },
435            Parameter {
436                name: "records".to_string(),
437                type_hint: "array".to_string(),
438                description: "Rows; each row is an array with one value per field, in field \
439                              order"
440                    .to_string(),
441                required: true,
442            },
443            Parameter {
444                name: "stats".to_string(),
445                type_hint: "object".to_string(),
446                description: "Update counts for a write, e.g. {\"nodes_created\": 1, \
447                              \"properties_set\": 2}. Keys: nodes_created, nodes_deleted, \
448                              relationships_created, relationships_deleted, properties_set, \
449                              labels_added, labels_removed, indexes_added, indexes_removed, \
450                              constraints_added, constraints_removed, system_updates"
451                    .to_string(),
452                required: false,
453            },
454            Parameter {
455                name: "query_type".to_string(),
456                type_hint: "string".to_string(),
457                description: "r (read, default), w (write), rw (read and write) or s (schema)"
458                    .to_string(),
459                required: false,
460            }
461            .with_choices(["r", "w", "rw", "s"]),
462        ],
463        example: json!({
464            "type": "send_bolt_records",
465            "fields": ["name", "born"],
466            "records": [["Keanu Reeves", 1964], ["Carrie-Anne Moss", 1967]]
467        }),
468        log_template: Some(
469            LogTemplate::new()
470                .with_info("-> Bolt result")
471                .with_debug("Bolt send_bolt_records"),
472        ),
473    }
474}
475
476fn send_bolt_failure_action() -> ActionDefinition {
477    ActionDefinition {
478        name: "send_bolt_failure".to_string(),
479        description: "Fail the query with a Neo4j error, which the client prints. Common codes: \
480                      Neo.ClientError.Statement.SyntaxError (bad Cypher), \
481                      Neo.ClientError.Statement.EntityNotFound, \
482                      Neo.ClientError.Schema.ConstraintValidationFailed, \
483                      Neo.ClientError.Security.Forbidden, \
484                      Neo.ClientError.Database.DatabaseNotFound, \
485                      Neo.TransientError.Transaction.DeadlockDetected."
486            .to_string(),
487        parameters: vec![
488            Parameter {
489                name: "code".to_string(),
490                type_hint: "string".to_string(),
491                description: "Neo.ClientError.*, Neo.TransientError.* or Neo.DatabaseError.* \
492                              status code"
493                    .to_string(),
494                required: true,
495            },
496            Parameter {
497                name: "message".to_string(),
498                type_hint: "string".to_string(),
499                description: "The error message".to_string(),
500                required: true,
501            },
502        ],
503        example: json!({
504            "type": "send_bolt_failure",
505            "code": "Neo.ClientError.Statement.SyntaxError",
506            "message": "Invalid input 'RETRUN': expected 'RETURN' (line 1, column 1 (offset: 0))"
507        }),
508        log_template: Some(
509            LogTemplate::new()
510                .with_info("-> Bolt FAILURE {code}")
511                .with_debug("Bolt send_bolt_failure: code={code}"),
512        ),
513    }
514}
515
516fn close_connection_action() -> ActionDefinition {
517    ActionDefinition {
518        name: "close_connection".to_string(),
519        description: "Close the Bolt connection without answering".to_string(),
520        parameters: vec![],
521        example: json!({"type": "close_connection"}),
522        log_template: Some(
523            LogTemplate::new()
524                .with_info("Bolt connection closed")
525                .with_debug("Bolt close_connection"),
526        ),
527    }
528}
529
530/// The `answer_with` field of `bolt_authenticate`: what the login is decided on.
531///
532/// Told "act as a Neo4j graph database that accepts any login. Reject any query that is not
533/// valid Cypher with Neo4j's syntax error", llama3.1:8b answered the login with
534/// `reject_bolt_login` five runs in five - "Reject" was the instruction's operative verb, and
535/// nothing in the login event said that no query was being decided yet. The request now says
536/// what the login turns on, and that query rules wait for the query.
537pub fn login_answer_with(principal: &str, scheme: &str, credentials_present: bool) -> String {
538    let user = if principal.is_empty() {
539        "no user name".to_string()
540    } else {
541        format!("user \"{principal}\"")
542    };
543    let credential = if credentials_present {
544        "a credential"
545    } else {
546        "no credential"
547    };
548    format!(
549        "decide only whether {user} (scheme {scheme}, {credential}) may log in. \
550         accept_bolt_login unless your instructions refuse this user, this scheme, or a login \
551         without a credential. No query has been sent yet: rules about queries - rejecting \
552         invalid Cypher, refusing writes - are applied to each query when it arrives, never \
553         to the login"
554    )
555}
556
557/// HELLO (Bolt 5.0) or LOGON (5.1+) with the client's credentials.
558pub static BOLT_AUTHENTICATE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
559    EventType::new(
560        "bolt_authenticate",
561        "A client is logging in to the graph database - this decision is about the login \
562         only, before any query has been sent. Decide whether this user may connect. Rules \
563         your instructions give about queries (reject invalid Cypher, refuse writes, ...) do \
564         not apply here: queries arrive later as bolt_query. The credential itself is never \
565         shown; when the server has a configured password NetGet has already checked it.",
566        json!({"type": "accept_bolt_login"}),
567    )
568    .with_parameters(vec![
569        Parameter {
570            name: "user_agent".to_string(),
571            type_hint: "string".to_string(),
572            description: "The client's own name, e.g. neo4j-cypher-shell/v2026.09.0".to_string(),
573            required: true,
574        },
575        Parameter {
576            name: "scheme".to_string(),
577            type_hint: "string".to_string(),
578            description: "Auth scheme: basic, none, bearer, kerberos or a custom one".to_string(),
579            required: true,
580        },
581        Parameter {
582            name: "principal".to_string(),
583            type_hint: "string".to_string(),
584            description: "The user name (empty for scheme none)".to_string(),
585            required: true,
586        },
587        Parameter {
588            name: "credentials_present".to_string(),
589            type_hint: "boolean".to_string(),
590            description: "Whether the client sent a credential at all".to_string(),
591            required: true,
592        },
593        Parameter {
594            name: "password_configured".to_string(),
595            type_hint: "boolean".to_string(),
596            description: "True when the server has a password and the client's matched it"
597                .to_string(),
598            required: true,
599        },
600        Parameter {
601            name: "answer_with".to_string(),
602            type_hint: "string".to_string(),
603            description: "What this login is decided on: the user, the scheme and whether a \
604                          credential was sent - never a query, which has not been sent yet"
605                .to_string(),
606            required: true,
607        },
608    ])
609    .with_log_template(
610        LogTemplate::new()
611            .with_info("Bolt login {principal} ({scheme})")
612            .with_debug("Bolt bolt_authenticate: principal={principal} scheme={scheme}"),
613    )
614    .with_actions(vec![
615        accept_bolt_login_action(),
616        reject_bolt_login_action(),
617        close_connection_action(),
618    ])
619    .with_alternative_example(json!({
620        "type": "reject_bolt_login",
621        "code": "Neo.ClientError.Security.Unauthorized",
622        "message": "The client is unauthorized due to authentication failure."
623    }))
624});
625
626/// RUN: one Cypher query.
627pub static BOLT_QUERY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
628    EventType::new(
629        "bolt_query",
630        "A client ran a Cypher query against the graph database. Answer with the rows it \
631         returns (send_bolt_records) or the Neo4j error it fails with (send_bolt_failure).",
632        json!({
633            "type": "send_bolt_records",
634            "fields": ["name"],
635            "records": [["Alice"], ["Bob"]]
636        }),
637    )
638    .with_parameters(vec![
639        Parameter {
640            name: "query".to_string(),
641            type_hint: "string".to_string(),
642            description: "The Cypher text".to_string(),
643            required: true,
644        },
645        Parameter {
646            name: "parameters".to_string(),
647            type_hint: "object".to_string(),
648            description: "The query's $parameters by name".to_string(),
649            required: true,
650        },
651        Parameter {
652            name: "database".to_string(),
653            type_hint: "string".to_string(),
654            description: "The database the client named, when it named one".to_string(),
655            required: false,
656        },
657        Parameter {
658            name: "mode".to_string(),
659            type_hint: "string".to_string(),
660            description: "read or write: the access mode the client asked for".to_string(),
661            required: true,
662        }
663        .with_choices(["read", "write"]),
664        Parameter {
665            name: "in_transaction".to_string(),
666            type_hint: "boolean".to_string(),
667            description: "True inside an explicit BEGIN ... COMMIT transaction".to_string(),
668            required: true,
669        },
670    ])
671    .with_log_template(
672        LogTemplate::new()
673            .with_info("Bolt query: {query}")
674            .with_debug("Bolt bolt_query: query={query} mode={mode}"),
675    )
676    .with_actions(vec![
677        send_bolt_records_action(),
678        send_bolt_failure_action(),
679        close_connection_action(),
680    ])
681    .with_alternative_example(json!({
682        "type": "send_bolt_failure",
683        "code": "Neo.ClientError.Statement.SyntaxError",
684        "message": "Invalid input 'RETRUN': expected 'RETURN' (line 1, column 1 (offset: 0))"
685    }))
686});