This is an automated email from the ASF dual-hosted git repository.

cmcfarlen pushed a commit to branch 10.2.x
in repository https://gitbox.apache.org/repos/asf/trafficserver.git

commit 0d03ba0e14e990b5d0fb872da17cfa35df98e321
Author: Leif Hedstrom <[email protected]>
AuthorDate: Thu Apr 2 10:14:27 2026 -0600

    hrw4u/header_rewrite: Add session-scope state variables (#12989)
    
    * hrw4u/header_rewrite: Add session-scope state variables
    
    Add SESSION-FLAG, SESSION-INT8, and SESSION-INT16 conditions and
    their corresponding set-session-flag, set-session-int8, and
    set-session-int16 operators to the header_rewrite plugin. These
    mirror the existing transaction-scoped state variables but persist
    across keep-alive requests on the same connection, using a
    TS_USER_ARGS_SSN slot. The condition and operator classes are
    parameterized with a TSUserArgType scope argument to avoid code
    duplication. The hrw4u transpiler adds a SESSION_VARS section for
    declaring session-scoped variables, and the reverse transpiler
    handles both scopes. Documentation and tests are included.
    
    Co-Authored-By: Craig Taylor
    
    * Address Copilot review comments
    
    Use SESSION_VARS instead of VARS for session sandbox check.
    Reserve user-arg slots lazily per scope in acquire_state_slot().
    Fix _state_vars type annotation to include VarScope in key tuple.
    
    * Address bneradt's review: wire SESSION_VARS into kg and LSP
    
    kg_visitor.py was missing sessionVarSection dispatch in visitSection,
    causing hrw4u-kg to silently drop SESSION_VARS blocks. lsp/strings.py
    only detected VARS { ... } for declaration mode, leaving session-scoped
    variables without hover/type metadata in hrw4u-lsp.
    
    (cherry picked from commit efeeffaffa661d0befcfaa26b564da6b0d9308b1)
---
 doc/admin-guide/configuration/hrw4u.en.rst         | 18 +++++-
 doc/admin-guide/plugins/header_rewrite.en.rst      | 71 ++++++++++++++++++++
 plugins/header_rewrite/conditions.cc               | 26 ++++----
 plugins/header_rewrite/conditions.h                | 58 +++++++++++------
 plugins/header_rewrite/factory.cc                  | 12 ++++
 plugins/header_rewrite/operators.cc                | 44 ++++++-------
 plugins/header_rewrite/operators.h                 | 45 +++++++++----
 plugins/header_rewrite/statement.cc                | 43 +++++++++----
 plugins/header_rewrite/statement.h                 | 47 ++++++++++++++
 .../header_rewrite_bundle.replay.yaml              | 63 ++++++++++++++++++
 .../header_rewrite/rules/rule_session_vars.conf    | 26 ++++++++
 tools/hrw4u/grammar/hrw4u.g4                       |  6 ++
 tools/hrw4u/src/hrw_symbols.py                     | 75 +++++++++++++---------
 tools/hrw4u/src/hrw_visitor.py                     | 14 ++--
 tools/hrw4u/src/kg_visitor.py                      | 21 ++++++
 tools/hrw4u/src/lsp/strings.py                     |  5 +-
 tools/hrw4u/src/symbols.py                         | 11 ++--
 tools/hrw4u/src/types.py                           | 37 ++++++++---
 tools/hrw4u/src/visitor.py                         | 23 ++++++-
 tools/hrw4u/tests/data/vars/session_assign.ast.txt |  1 +
 .../hrw4u/tests/data/vars/session_assign.input.txt | 15 +++++
 .../tests/data/vars/session_assign.output.txt      |  4 ++
 tools/hrw4u/tests/data/vars/session_bool.ast.txt   |  1 +
 tools/hrw4u/tests/data/vars/session_bool.input.txt |  9 +++
 .../hrw4u/tests/data/vars/session_bool.output.txt  |  3 +
 tools/hrw4u/tests/data/vars/session_int16.ast.txt  |  1 +
 .../hrw4u/tests/data/vars/session_int16.input.txt  |  9 +++
 .../hrw4u/tests/data/vars/session_int16.output.txt |  3 +
 tools/hrw4u/tests/data/vars/session_int8.ast.txt   |  1 +
 tools/hrw4u/tests/data/vars/session_int8.input.txt |  9 +++
 .../hrw4u/tests/data/vars/session_int8.output.txt  |  3 +
 tools/hrw4u/tests/test_coverage.py                 |  8 +--
 32 files changed, 580 insertions(+), 132 deletions(-)

diff --git a/doc/admin-guide/configuration/hrw4u.en.rst 
b/doc/admin-guide/configuration/hrw4u.en.rst
index 9e5c8fb39f..4a0fb3f696 100644
--- a/doc/admin-guide/configuration/hrw4u.en.rst
+++ b/doc/admin-guide/configuration/hrw4u.en.rst
@@ -405,7 +405,9 @@ TXN_CLOSE_HOOK                  TXN_CLOSE                
End of transaction
 =============================== ======================== 
================================
 
 A special section `VARS` is used to declare variables. There is no equivalent 
in
-`header_rewrite`, where you managed the variables manually.
+`header_rewrite`, where you managed the variables manually. Similarly,
+`SESSION_VARS` declares session-scoped variables that persist across all
+transactions on the same client connection (session).
 
 Variables and State Slots
 ^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -416,6 +418,10 @@ Each variable type has a limited number of slots available:
 - ``int8`` - 4 slots (0-3)
 - ``int16`` - 1 slot (0)
 
+These limits apply independently to both ``VARS`` (transaction-scoped) and
+``SESSION_VARS`` (session-scoped) sections. For example, you can have 16 bool
+transaction variables *and* 16 bool session variables.
+
 By default, slots are assigned automatically in declaration order. You can 
explicitly assign
 a slot number using the ``@`` syntax::
 
@@ -426,6 +432,16 @@ a slot number using the ``@`` syntax::
         counter: int8 @2;       # Explicitly use int8 slot 2
     }
 
+    SESSION_VARS {
+        is_suspicious: bool;    # Session-scoped, persists across requests
+        penalty_level: int8;    # Session-scoped 8-bit integer
+    }
+
+Transaction variables (``VARS``) are reset for each new HTTP transaction, while
+session variables (``SESSION_VARS``) persist for the lifetime of the client
+connection. Session variables are useful for tracking state across keep-alive
+requests, such as marking a connection as suspicious after the first bad 
request.
+
 Explicit slot assignment is useful when you need predictable slot numbers 
across configurations
 or when integrating with existing header_rewrite rules that reference specific 
slot numbers. In
 addition, a remap configuration can use ``@PPARAM`` to set one of these slot 
variables explicitly
diff --git a/doc/admin-guide/plugins/header_rewrite.en.rst 
b/doc/admin-guide/plugins/header_rewrite.en.rst
index 58ac015f79..2d57f64f97 100644
--- a/doc/admin-guide/plugins/header_rewrite.en.rst
+++ b/doc/admin-guide/plugins/header_rewrite.en.rst
@@ -862,6 +862,41 @@ There's only one such integer, and its value is returned 
from this condition.
 As such, the index, ``0``, is optional here. The initialized value of this
 state variable is ``0``.
 
+SESSION-FLAG
+~~~~~~~~~~~~
+::
+
+      cond %{SESSION-FLAG:<n>}
+
+This condition allows you to check the state of a session-scoped flag. The
+``<n>`` is the number of the flag, from 0 to 15. Unlike ``STATE-FLAG`` which
+is scoped to the current transaction, session flags persist across all
+transactions on the same client connection (session). The default value of all
+flags are ``false``.
+
+SESSION-INT8
+~~~~~~~~~~~~
+::
+
+      cond %{SESSION-INT8:<n>}
+
+This condition allows you to check the state of a session-scoped 8-bit unsigned
+integer. The ``<n>`` is the number of the integer, from 0 to 3. The current
+value is returned, and all 4 integers are initialized to 0. Session integers
+persist across all transactions on the same client connection.
+
+SESSION-INT16
+~~~~~~~~~~~~~
+::
+
+      cond %{SESSION-INT16:<0>}
+
+This condition allows you to check the state of a session-scoped 16-bit 
unsigned
+integer. There's only one such integer, and its value is returned from this
+condition. As such, the index, ``0``, is optional here. The initialized value
+is ``0``. Session integers persist across all transactions on the same client
+connection.
+
 STATUS
 ~~~~~~
 ::
@@ -1304,6 +1339,42 @@ The ``<value>`` is an unsigned 16-bit integer as well, 
0-65535. It can also
 be a condition, in which case thevalue of the condition is used. The index,
 0, is always required eventhough there is only one 16-bit integer state 
variable.
 
+set-session-flag
+~~~~~~~~~~~~~~~~
+::
+
+  set-session-flag <n> <value>
+
+This operator allows you to set the state of a session-scoped flag. The ``<n>``
+is the number of the flag, from 0 to 15. The ``<value>`` is either ``true`` or
+``false``, turning the flag on or off. Unlike ``set-state-flag``, session flags
+persist across all transactions on the same client connection.
+
+set-session-int8
+~~~~~~~~~~~~~~~~
+::
+
+   set-session-int8 <n> <value>
+
+This operator allows you to set the state of a session-scoped 8-bit unsigned
+integer. The ``<n>`` is the number of the integer, from 0 to 3. The ``<value>``
+is an unsigned 8-bit integer, 0-255. It can also be a condition, in which case
+the value of the condition is used. Session integers persist across all
+transactions on the same client connection.
+
+set-session-int16
+~~~~~~~~~~~~~~~~~
+::
+
+   set-session-int16 0 <value>
+
+This operator allows you to set the state of a session-scoped 16-bit unsigned
+integer. The ``<value>`` is an unsigned 16-bit integer, 0-65535. It can also
+be a condition, in which case the value of the condition is used. The index,
+0, is always required even though there is only one 16-bit session integer
+state variable. Session integers persist across all transactions on the same
+client connection.
+
 set-status
 ~~~~~~~~~~
 ::
diff --git a/plugins/header_rewrite/conditions.cc 
b/plugins/header_rewrite/conditions.cc
index 35497aaeeb..3d32c3515d 100644
--- a/plugins/header_rewrite/conditions.cc
+++ b/plugins/header_rewrite/conditions.cc
@@ -1654,9 +1654,9 @@ ConditionStateFlag::set_qualifier(const std::string &q)
 
   _flag_ix = strtol(q.c_str(), nullptr, 10);
   if (_flag_ix < 0 || _flag_ix >= NUM_STATE_FLAGS) {
-    TSError("[%s] STATE-FLAG index out of range: %s", PLUGIN_NAME, q.c_str());
+    TSError("[%s] %s-FLAG index out of range: %s", PLUGIN_NAME, 
_scope_label(_scope), q.c_str());
   } else {
-    Dbg(pi_dbg_ctl, "\tParsing %%{STATE-FLAG:%s}", q.c_str());
+    Dbg(pi_dbg_ctl, "\tParsing %%{%s-FLAG:%s}", _scope_label(_scope), 
q.c_str());
     _mask = 1ULL << _flag_ix;
   }
 }
@@ -1665,15 +1665,15 @@ void
 ConditionStateFlag::append_value(std::string &s, const Resources &res)
 {
   s += eval(res) ? "TRUE" : "FALSE";
-  Dbg(pi_dbg_ctl, "Evaluating STATE-FLAG(%d)", _flag_ix);
+  Dbg(pi_dbg_ctl, "Evaluating %s-FLAG(%d)", _scope_label(_scope), _flag_ix);
 }
 
 bool
 ConditionStateFlag::eval(const Resources &res)
 {
-  auto data = reinterpret_cast<uint64_t>(TSUserArgGet(res.state.txnp, 
_txn_slot));
+  auto data = _get_state_data(_scope, res);
 
-  Dbg(pi_dbg_ctl, "Evaluating STATE-FLAG()");
+  Dbg(pi_dbg_ctl, "Evaluating %s-FLAG()", _scope_label(_scope));
 
   return (data & _mask) == _mask;
 }
@@ -1696,9 +1696,9 @@ ConditionStateInt8::set_qualifier(const std::string &q)
 
   _byte_ix = strtol(q.c_str(), nullptr, 10);
   if (_byte_ix < 0 || _byte_ix >= NUM_STATE_INT8S) {
-    TSError("[%s] STATE-INT8 index out of range: %s", PLUGIN_NAME, q.c_str());
+    TSError("[%s] %s-INT8 index out of range: %s", PLUGIN_NAME, 
_scope_label(_scope), q.c_str());
   } else {
-    Dbg(pi_dbg_ctl, "\tParsing %%{STATE-INT8:%s}", q.c_str());
+    Dbg(pi_dbg_ctl, "\tParsing %%{%s-INT8:%s}", _scope_label(_scope), 
q.c_str());
   }
 }
 
@@ -1709,7 +1709,7 @@ ConditionStateInt8::append_value(std::string &s, const 
Resources &res)
 
   s += std::to_string(data);
 
-  Dbg(pi_dbg_ctl, "Appending STATE-INT8(%d) to evaluation value -> %s", data, 
s.c_str());
+  Dbg(pi_dbg_ctl, "Appending %s-INT8(%d) to evaluation value -> %s", 
_scope_label(_scope), data, s.c_str());
 }
 
 bool
@@ -1717,7 +1717,7 @@ ConditionStateInt8::eval(const Resources &res)
 {
   uint8_t data = _get_data(res);
 
-  Dbg(pi_dbg_ctl, "Evaluating STATE-INT8()");
+  Dbg(pi_dbg_ctl, "Evaluating %s-INT8()", _scope_label(_scope));
 
   return static_cast<const MatcherType *>(_matcher.get())->test(data, res);
 }
@@ -1742,9 +1742,9 @@ ConditionStateInt16::set_qualifier(const std::string &q)
     long ix = strtol(q.c_str(), nullptr, 10);
 
     if (ix != 0) {
-      TSError("[%s] STATE-INT16 index out of range: %s", PLUGIN_NAME, 
q.c_str());
+      TSError("[%s] %s-INT16 index out of range: %s", PLUGIN_NAME, 
_scope_label(_scope), q.c_str());
     } else {
-      Dbg(pi_dbg_ctl, "\tParsing %%{STATE-INT16:%s}", q.c_str());
+      Dbg(pi_dbg_ctl, "\tParsing %%{%s-INT16:%s}", _scope_label(_scope), 
q.c_str());
     }
   }
 }
@@ -1755,7 +1755,7 @@ ConditionStateInt16::append_value(std::string &s, const 
Resources &res)
   uint16_t data = _get_data(res);
 
   s += std::to_string(data);
-  Dbg(pi_dbg_ctl, "Appending STATE-INT16(%d) to evaluation value -> %s", data, 
s.c_str());
+  Dbg(pi_dbg_ctl, "Appending %s-INT16(%d) to evaluation value -> %s", 
_scope_label(_scope), data, s.c_str());
 }
 
 bool
@@ -1763,7 +1763,7 @@ ConditionStateInt16::eval(const Resources &res)
 {
   uint16_t data = _get_data(res);
 
-  Dbg(pi_dbg_ctl, "Evaluating STATE-INT8()");
+  Dbg(pi_dbg_ctl, "Evaluating %s-INT16()", _scope_label(_scope));
 
   return static_cast<const MatcherType *>(_matcher.get())->test(data, res);
 }
diff --git a/plugins/header_rewrite/conditions.h 
b/plugins/header_rewrite/conditions.h
index c393e60266..631854e8f4 100644
--- a/plugins/header_rewrite/conditions.h
+++ b/plugins/header_rewrite/conditions.h
@@ -800,17 +800,17 @@ private:
   bool       _end  = false;
 };
 
-// State Flags
+// State/Session Flags (parameterized by scope)
 class ConditionStateFlag : public Condition
 {
   using SelfType = ConditionStateFlag;
   // No matcher for this, it's all easy peasy
 
 public:
-  explicit ConditionStateFlag()
+  explicit ConditionStateFlag(TSUserArgType scope = TS_USER_ARGS_TXN) : 
_scope(scope)
   {
     static_assert(sizeof(void *) == 8, "State Variables requires a 64-bit 
system.");
-    Dbg(dbg_ctl, "Calling CTOR for ConditionStateFlag");
+    Dbg(dbg_ctl, "Calling CTOR for ConditionStateFlag (scope=%s)", 
_scope_label(_scope));
   }
 
   // noncopyable
@@ -826,15 +826,22 @@ protected:
   bool
   need_txn_slot() const override
   {
-    return true;
+    return _scope == TS_USER_ARGS_TXN;
+  }
+
+  bool
+  need_ssn_slot() const override
+  {
+    return _scope == TS_USER_ARGS_SSN;
   }
 
 private:
-  int      _flag_ix = -1;
-  uint64_t _mask    = 0;
+  TSUserArgType _scope   = TS_USER_ARGS_TXN;
+  int           _flag_ix = -1;
+  uint64_t      _mask    = 0;
 };
 
-// INT8 state variables
+// State/Session INT8 variables (parameterized by scope)
 class ConditionStateInt8 : public Condition
 {
   using DataType    = uint8_t;
@@ -842,10 +849,10 @@ class ConditionStateInt8 : public Condition
   using SelfType    = ConditionStateInt8;
 
 public:
-  explicit ConditionStateInt8()
+  explicit ConditionStateInt8(TSUserArgType scope = TS_USER_ARGS_TXN) : 
_scope(scope)
   {
     static_assert(sizeof(void *) == 8, "State Variables requires a 64-bit 
system.");
-    Dbg(dbg_ctl, "Calling CTOR for ConditionStateInt8");
+    Dbg(dbg_ctl, "Calling CTOR for ConditionStateInt8 (scope=%s)", 
_scope_label(_scope));
   }
 
   // noncopyable
@@ -862,25 +869,31 @@ protected:
   bool
   need_txn_slot() const override
   {
-    return true;
+    return _scope == TS_USER_ARGS_TXN;
+  }
+
+  bool
+  need_ssn_slot() const override
+  {
+    return _scope == TS_USER_ARGS_SSN;
   }
 
 private:
-  // Little helper function to extract out the data from the TXN user pointer
   uint8_t
   _get_data(const Resources &res) const
   {
     TSAssert(_byte_ix >= 0 && _byte_ix < NUM_STATE_INT8S);
-    auto    ptr  = reinterpret_cast<uint64_t>(TSUserArgGet(res.state.txnp, 
_txn_slot));
+    auto    ptr  = _get_state_data(_scope, res);
     uint8_t data = (ptr & STATE_INT8_MASKS[_byte_ix]) >> (NUM_STATE_FLAGS + 
_byte_ix * 8);
 
     return data;
   }
 
-  int _byte_ix = -1;
+  TSUserArgType _scope   = TS_USER_ARGS_TXN;
+  int           _byte_ix = -1;
 };
 
-// INT16 state variables
+// State/Session INT16 variables (parameterized by scope)
 class ConditionStateInt16 : public Condition
 {
   using DataType    = uint16_t;
@@ -888,10 +901,10 @@ class ConditionStateInt16 : public Condition
   using SelfType    = ConditionStateInt16;
 
 public:
-  explicit ConditionStateInt16()
+  explicit ConditionStateInt16(TSUserArgType scope = TS_USER_ARGS_TXN) : 
_scope(scope)
   {
     static_assert(sizeof(void *) == 8, "State Variables requires a 64-bit 
system.");
-    Dbg(dbg_ctl, "Calling CTOR for ConditionStateInt16");
+    Dbg(dbg_ctl, "Calling CTOR for ConditionStateInt16 (scope=%s)", 
_scope_label(_scope));
   }
 
   // noncopyable
@@ -908,18 +921,25 @@ protected:
   bool
   need_txn_slot() const override
   {
-    return true;
+    return _scope == TS_USER_ARGS_TXN;
+  }
+
+  bool
+  need_ssn_slot() const override
+  {
+    return _scope == TS_USER_ARGS_SSN;
   }
 
 private:
-  // Little helper function to extract out the data from the TXN user pointer
   uint16_t
   _get_data(const Resources &res) const
   {
-    auto ptr = reinterpret_cast<uint64_t>(TSUserArgGet(res.state.txnp, 
_txn_slot));
+    auto ptr = _get_state_data(_scope, res);
 
     return ((ptr & STATE_INT16_MASK) >> 48);
   }
+
+  TSUserArgType _scope = TS_USER_ARGS_TXN;
 };
 
 // Last regex capture
diff --git a/plugins/header_rewrite/factory.cc 
b/plugins/header_rewrite/factory.cc
index ffc999f3e0..530cf4cfe5 100644
--- a/plugins/header_rewrite/factory.cc
+++ b/plugins/header_rewrite/factory.cc
@@ -87,6 +87,12 @@ operator_factory(const std::string &op)
     o = new OperatorSetStateInt8();
   } else if (op == "set-state-int16") {
     o = new OperatorSetStateInt16();
+  } else if (op == "set-session-flag") {
+    o = new OperatorSetStateFlag(TS_USER_ARGS_SSN);
+  } else if (op == "set-session-int8") {
+    o = new OperatorSetStateInt8(TS_USER_ARGS_SSN);
+  } else if (op == "set-session-int16") {
+    o = new OperatorSetStateInt16(TS_USER_ARGS_SSN);
   } else if (op == "set-effective-address") {
     o = new OperatorSetEffectiveAddress();
   } else if (op == "set-next-hop-strategy") {
@@ -191,6 +197,12 @@ condition_factory(const std::string &cond)
     c = new ConditionStateInt8();
   } else if (c_name == "STATE-INT16") {
     c = new ConditionStateInt16();
+  } else if (c_name == "SESSION-FLAG") {
+    c = new ConditionStateFlag(TS_USER_ARGS_SSN);
+  } else if (c_name == "SESSION-INT8") {
+    c = new ConditionStateInt8(TS_USER_ARGS_SSN);
+  } else if (c_name == "SESSION-INT16") {
+    c = new ConditionStateInt16(TS_USER_ARGS_SSN);
   } else if (c_name == "LAST-CAPTURE") {
     c = new ConditionLastCapture();
   } else {
diff --git a/plugins/header_rewrite/operators.cc 
b/plugins/header_rewrite/operators.cc
index 76c4f6197b..20207bd24b 100644
--- a/plugins/header_rewrite/operators.cc
+++ b/plugins/header_rewrite/operators.cc
@@ -1409,7 +1409,7 @@ OperatorSetStateFlag::initialize(Parser &p)
   _flag_ix = strtol(p.get_arg().c_str(), nullptr, 10);
 
   if (_flag_ix < 0 || _flag_ix >= NUM_STATE_FLAGS) {
-    TSError("[%s] state flag with index %d is out of range", PLUGIN_NAME, 
_flag_ix);
+    TSError("[%s] %s flag with index %d is out of range", PLUGIN_NAME, 
_scope_label(_scope), _flag_ix);
     return;
   }
 
@@ -1443,16 +1443,16 @@ OperatorSetStateFlag::initialize_hooks()
 bool
 OperatorSetStateFlag::exec(const Resources &res) const
 {
-  if (!res.state.txnp) {
-    TSError("[%s] OperatorSetStateFlag() failed. Transaction is null", 
PLUGIN_NAME);
+  if (!_check_state_handle(_scope, res)) {
+    TSError("[%s] OperatorSetStateFlag() failed. %s handle is null", 
PLUGIN_NAME, _scope_label(_scope));
     return false;
   }
 
-  Dbg(pi_dbg_ctl, "   Setting state flag %d to %d", _flag_ix, _flag);
+  Dbg(pi_dbg_ctl, "   Setting %s flag %d to %d", _scope_label(_scope), 
_flag_ix, _flag);
 
-  auto data = reinterpret_cast<uint64_t>(TSUserArgGet(res.state.txnp, 
_txn_slot));
+  auto data = _get_state_data(_scope, res);
 
-  TSUserArgSet(res.state.txnp, _txn_slot, reinterpret_cast<void *>(_flag ? 
data | _mask : data & _mask));
+  _set_state_data(_scope, res, _flag ? data | _mask : data & _mask);
 
   return true;
 }
@@ -1465,7 +1465,7 @@ OperatorSetStateInt8::initialize(Parser &p)
   _byte_ix = strtol(p.get_arg().c_str(), nullptr, 10);
 
   if (_byte_ix < 0 || _byte_ix >= NUM_STATE_INT8S) {
-    TSError("[%s] state int8 with index %d is out of range", PLUGIN_NAME, 
_byte_ix);
+    TSError("[%s] %s int8 with index %d is out of range", PLUGIN_NAME, 
_scope_label(_scope), _byte_ix);
     return;
   }
 
@@ -1474,7 +1474,7 @@ OperatorSetStateInt8::initialize(Parser &p)
     int v = _value.get_int_value();
 
     if (v < 0 || v > 255) {
-      TSError("[%s] state int8 value %d is out of range", PLUGIN_NAME, v);
+      TSError("[%s] %s int8 value %d is out of range", PLUGIN_NAME, 
_scope_label(_scope), v);
       return;
     }
   }
@@ -1497,12 +1497,12 @@ OperatorSetStateInt8::initialize_hooks()
 bool
 OperatorSetStateInt8::exec(const Resources &res) const
 {
-  if (!res.state.txnp) {
-    TSError("[%s] OperatorSetStateInt8() failed. Transaction is null", 
PLUGIN_NAME);
+  if (!_check_state_handle(_scope, res)) {
+    TSError("[%s] OperatorSetStateInt8() failed. %s handle is null", 
PLUGIN_NAME, _scope_label(_scope));
     return false;
   }
 
-  auto ptr = reinterpret_cast<uint64_t>(TSUserArgGet(res.state.txnp, 
_txn_slot));
+  auto ptr = _get_state_data(_scope, res);
   int  val = 0;
 
   if (_value.has_conds()) { // If there are conditions, we need to evaluate 
them, which gives us a string
@@ -1511,7 +1511,7 @@ OperatorSetStateInt8::exec(const Resources &res) const
     _value.append_value(v, res);
     val = strtol(v.c_str(), nullptr, 10);
     if (val < 0 || val > 255) {
-      TSWarning("[%s] state int8 value %d is out of range", PLUGIN_NAME, val);
+      TSWarning("[%s] %s int8 value %d is out of range", PLUGIN_NAME, 
_scope_label(_scope), val);
       return false;
     }
   } else {
@@ -1519,10 +1519,10 @@ OperatorSetStateInt8::exec(const Resources &res) const
     val = _value.get_int_value();
   }
 
-  Dbg(pi_dbg_ctl, "   Setting state int8 %d to %d", _byte_ix, val);
+  Dbg(pi_dbg_ctl, "   Setting %s int8 %d to %d", _scope_label(_scope), 
_byte_ix, val);
   ptr &= ~STATE_INT8_MASKS[_byte_ix]; // Clear any old value
   ptr |= (static_cast<uint64_t>(val) << (NUM_STATE_FLAGS + _byte_ix * 8));
-  TSUserArgSet(res.state.txnp, _txn_slot, reinterpret_cast<void *>(ptr));
+  _set_state_data(_scope, res, ptr);
 
   return true;
 }
@@ -1535,7 +1535,7 @@ OperatorSetStateInt16::initialize(Parser &p)
   int ix = strtol(p.get_arg().c_str(), nullptr, 10);
 
   if (ix != 0) {
-    TSError("[%s] state int16 with index %d is out of range", PLUGIN_NAME, ix);
+    TSError("[%s] %s int16 with index %d is out of range", PLUGIN_NAME, 
_scope_label(_scope), ix);
     return;
   }
 
@@ -1544,7 +1544,7 @@ OperatorSetStateInt16::initialize(Parser &p)
     int v = _value.get_int_value();
 
     if (v < 0 || v > 65535) {
-      TSError("[%s] state int16 value %d is out of range", PLUGIN_NAME, v);
+      TSError("[%s] %s int16 value %d is out of range", PLUGIN_NAME, 
_scope_label(_scope), v);
       return;
     }
   }
@@ -1567,12 +1567,12 @@ OperatorSetStateInt16::initialize_hooks()
 bool
 OperatorSetStateInt16::exec(const Resources &res) const
 {
-  if (!res.state.txnp) {
-    TSError("[%s] OperatorSetStateInt16() failed. Transaction is null", 
PLUGIN_NAME);
+  if (!_check_state_handle(_scope, res)) {
+    TSError("[%s] OperatorSetStateInt16() failed. %s handle is null", 
PLUGIN_NAME, _scope_label(_scope));
     return false;
   }
 
-  auto ptr = reinterpret_cast<uint64_t>(TSUserArgGet(res.state.txnp, 
_txn_slot));
+  auto ptr = _get_state_data(_scope, res);
   int  val = 0;
 
   if (_value.has_conds()) { // If there are conditions, we need to evaluate 
them, which gives us a string
@@ -1581,7 +1581,7 @@ OperatorSetStateInt16::exec(const Resources &res) const
     _value.append_value(v, res);
     val = strtol(v.c_str(), nullptr, 10);
     if (val < 0 || val > 65535) {
-      TSWarning("[%s] state int8 value %d is out of range", PLUGIN_NAME, val);
+      TSWarning("[%s] %s int16 value %d is out of range", PLUGIN_NAME, 
_scope_label(_scope), val);
       return false;
     }
   } else {
@@ -1589,10 +1589,10 @@ OperatorSetStateInt16::exec(const Resources &res) const
     val = _value.get_int_value();
   }
 
-  Dbg(pi_dbg_ctl, "   Setting state int16 to %d", val);
+  Dbg(pi_dbg_ctl, "   Setting %s int16 to %d", _scope_label(_scope), val);
   ptr &= ~STATE_INT16_MASK; // Clear any old value
   ptr |= (static_cast<uint64_t>(val) << 48);
-  TSUserArgSet(res.state.txnp, _txn_slot, reinterpret_cast<void *>(ptr));
+  _set_state_data(_scope, res, ptr);
 
   return true;
 }
diff --git a/plugins/header_rewrite/operators.h 
b/plugins/header_rewrite/operators.h
index 2b48f813db..d20c08f405 100644
--- a/plugins/header_rewrite/operators.h
+++ b/plugins/header_rewrite/operators.h
@@ -547,7 +547,7 @@ private:
 class OperatorSetStateFlag : public Operator
 {
 public:
-  OperatorSetStateFlag()
+  explicit OperatorSetStateFlag(TSUserArgType scope = TS_USER_ARGS_TXN) : 
_scope(scope)
   {
     static_assert(sizeof(void *) == 8, "State Variables requires a 64-bit 
system.");
     Dbg(dbg_ctl, "Calling CTOR for OperatorSetStateFlag");
@@ -566,19 +566,26 @@ protected:
   bool
   need_txn_slot() const override
   {
-    return true;
+    return _scope == TS_USER_ARGS_TXN;
+  }
+
+  bool
+  need_ssn_slot() const override
+  {
+    return _scope == TS_USER_ARGS_SSN;
   }
 
 private:
-  int      _flag_ix = -1;
-  int      _flag    = false;
-  uint64_t _mask    = 0;
+  TSUserArgType _scope   = TS_USER_ARGS_TXN;
+  int           _flag_ix = -1;
+  int           _flag    = false;
+  uint64_t      _mask    = 0;
 };
 
 class OperatorSetStateInt8 : public Operator
 {
 public:
-  OperatorSetStateInt8()
+  explicit OperatorSetStateInt8(TSUserArgType scope = TS_USER_ARGS_TXN) : 
_scope(scope)
   {
     static_assert(sizeof(void *) == 8, "State Variables requires a 64-bit 
system.");
     Dbg(dbg_ctl, "Calling CTOR for OperatorSetStateInt8");
@@ -597,18 +604,25 @@ protected:
   bool
   need_txn_slot() const override
   {
-    return true;
+    return _scope == TS_USER_ARGS_TXN;
+  }
+
+  bool
+  need_ssn_slot() const override
+  {
+    return _scope == TS_USER_ARGS_SSN;
   }
 
 private:
-  int   _byte_ix = -1;
-  Value _value;
+  TSUserArgType _scope   = TS_USER_ARGS_TXN;
+  int           _byte_ix = -1;
+  Value         _value;
 };
 
 class OperatorSetStateInt16 : public Operator
 {
 public:
-  OperatorSetStateInt16()
+  explicit OperatorSetStateInt16(TSUserArgType scope = TS_USER_ARGS_TXN) : 
_scope(scope)
   {
     static_assert(sizeof(void *) == 8, "State Variables requires a 64-bit 
system.");
     Dbg(dbg_ctl, "Calling CTOR for OperatorSetStateInt16");
@@ -627,11 +641,18 @@ protected:
   bool
   need_txn_slot() const override
   {
-    return true;
+    return _scope == TS_USER_ARGS_TXN;
+  }
+
+  bool
+  need_ssn_slot() const override
+  {
+    return _scope == TS_USER_ARGS_SSN;
   }
 
 private:
-  Value _value;
+  TSUserArgType _scope = TS_USER_ARGS_TXN;
+  Value         _value;
 };
 
 class OperatorSetEffectiveAddress : public Operator
diff --git a/plugins/header_rewrite/statement.cc 
b/plugins/header_rewrite/statement.cc
index 57eab9d62f..6e2607da63 100644
--- a/plugins/header_rewrite/statement.cc
+++ b/plugins/header_rewrite/statement.cc
@@ -74,20 +74,41 @@ Statement::initialize_hooks()
 }
 
 int
-Statement::acquire_txn_slot()
+Statement::acquire_state_slot(TSUserArgType type)
 {
-  // Only call the index reservation once per plugin load
-  static int txn_slot_index = []() -> int {
-    int index = -1;
+  if (type == TS_USER_ARGS_SSN) {
+    static int ssn_slot_index = []() -> int {
+      int index = -1;
+      if (TS_ERROR == TSUserArgIndexReserve(TS_USER_ARGS_SSN, PLUGIN_NAME, 
"HRW ssn variables", &index)) {
+        TSError("[%s] failed to reserve ssn user arg index", PLUGIN_NAME);
+      }
+      return index;
+    }();
+
+    return ssn_slot_index;
+  } else {
+    static int txn_slot_index = []() -> int {
+      int index = -1;
+      if (TS_ERROR == TSUserArgIndexReserve(TS_USER_ARGS_TXN, PLUGIN_NAME, 
"HRW txn variables", &index)) {
+        TSError("[%s] failed to reserve txn user arg index", PLUGIN_NAME);
+      }
+      return index;
+    }();
+
+    return txn_slot_index;
+  }
+}
 
-    if (TS_ERROR == TSUserArgIndexReserve(TS_USER_ARGS_TXN, PLUGIN_NAME, "HRW 
txn variables", &index)) {
-      TSError("[%s] failed to reserve user arg index", PLUGIN_NAME);
-      return -1; // Fallback value
-    }
-    return index;
-  }();
+int
+Statement::acquire_txn_slot()
+{
+  return acquire_state_slot(TS_USER_ARGS_TXN);
+}
 
-  return txn_slot_index;
+int
+Statement::acquire_ssn_slot()
+{
+  return acquire_state_slot(TS_USER_ARGS_SSN);
 }
 
 int
diff --git a/plugins/header_rewrite/statement.h 
b/plugins/header_rewrite/statement.h
index aac03d878a..2557d4482c 100644
--- a/plugins/header_rewrite/statement.h
+++ b/plugins/header_rewrite/statement.h
@@ -173,6 +173,9 @@ public:
     if (need_txn_slot()) {
       _txn_slot = acquire_txn_slot();
     }
+    if (need_ssn_slot()) {
+      _ssn_slot = acquire_ssn_slot();
+    }
     if (need_txn_private_slot()) {
       _txn_private_slot = acquire_txn_private_slot();
     }
@@ -194,6 +197,8 @@ public:
 
   static int acquire_txn_slot();
   static int acquire_txn_private_slot();
+  static int acquire_ssn_slot();
+  static int acquire_state_slot(TSUserArgType type);
 
 protected:
   virtual void initialize_hooks();
@@ -214,9 +219,51 @@ protected:
     return false;
   }
 
+  virtual bool
+  need_ssn_slot() const
+  {
+    return false;
+  }
+
+  // Scope-aware helpers for state variable access
+  uint64_t
+  _get_state_data(TSUserArgType scope, const Resources &res) const
+  {
+    if (scope == TS_USER_ARGS_SSN) {
+      return reinterpret_cast<uint64_t>(TSUserArgGet(res.state.ssnp, 
_ssn_slot));
+    }
+    return reinterpret_cast<uint64_t>(TSUserArgGet(res.state.txnp, _txn_slot));
+  }
+
+  void
+  _set_state_data(TSUserArgType scope, const Resources &res, uint64_t data) 
const
+  {
+    if (scope == TS_USER_ARGS_SSN) {
+      TSUserArgSet(res.state.ssnp, _ssn_slot, reinterpret_cast<void *>(data));
+    } else {
+      TSUserArgSet(res.state.txnp, _txn_slot, reinterpret_cast<void *>(data));
+    }
+  }
+
+  bool
+  _check_state_handle(TSUserArgType scope, const Resources &res) const
+  {
+    if (scope == TS_USER_ARGS_SSN) {
+      return res.state.ssnp != nullptr;
+    }
+    return res.state.txnp != nullptr;
+  }
+
+  static const char *
+  _scope_label(TSUserArgType scope)
+  {
+    return (scope == TS_USER_ARGS_SSN) ? "SESSION" : "STATE";
+  }
+
   Statement *_next             = nullptr; // Linked list
   int        _txn_slot         = -1;
   int        _txn_private_slot = -1;
+  int        _ssn_slot         = -1;
 
 private:
   ResourceIDs               _rsrc = RSRC_NONE;
diff --git 
a/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_bundle.replay.yaml 
b/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_bundle.replay.yaml
index 85de50a5e8..97e1d6d285 100644
--- 
a/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_bundle.replay.yaml
+++ 
b/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_bundle.replay.yaml
@@ -169,6 +169,13 @@ autest:
             args:
               - "rules/rule_server_conditions.conf"
 
+      - from: "http://www.example.com/from_17/";
+        to: "http://backend.ex:{SERVER_HTTP_PORT}/to_17/";
+        plugins:
+          - name: "header_rewrite.so"
+            args:
+              - "rules/rule_session_vars.conf"
+
     log_validation:
       traffic_out:
         excludes:
@@ -1873,3 +1880,59 @@ sessions:
         - [ X-Marker-Found, { value: "Yes", as: equal } ]
         - [ X-Server-Host-Header, { value: "backend.ex", as: contains } ]
         - [ X-Path-Match, { value: "Yes", as: equal } ]
+
+# Test 64: SESSION-FLAG persists across keep-alive transactions.
+# The first request sees the flag unset; set-session-flag marks it true.
+# The second request on the same keep-alive session sees the flag set.
+- transactions:
+  # First transaction - flag is unset, header absent; flag is then set.
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_17/
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ Connection, keep-alive ]
+        - [ Content-Length, "0" ]
+        - [ uuid, 65 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Server, microserver ]
+        - [ Content-Length, "0" ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Session-Seen, { as: absent } ]
+
+  # Second transaction - flag set by previous request; header must appear.
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_17/
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ Connection, keep-alive ]
+        - [ Content-Length, "0" ]
+        - [ uuid, 66 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Server, microserver ]
+        - [ Content-Length, "0" ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Session-Seen, { value: "yes", as: equal } ]
diff --git 
a/tests/gold_tests/pluginTest/header_rewrite/rules/rule_session_vars.conf 
b/tests/gold_tests/pluginTest/header_rewrite/rules/rule_session_vars.conf
new file mode 100644
index 0000000000..d4b1376195
--- /dev/null
+++ b/tests/gold_tests/pluginTest/header_rewrite/rules/rule_session_vars.conf
@@ -0,0 +1,26 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# Test SESSION-FLAG: on the first request the flag is unset so the header is
+# absent; set-session-flag then marks it true.  On the second keep-alive
+# request the flag is still true, so X-Session-Seen is added.
+cond %{SEND_RESPONSE_HDR_HOOK} [AND]
+cond %{SESSION-FLAG:0} =TRUE
+  set-header X-Session-Seen "yes"
+
+cond %{SEND_RESPONSE_HDR_HOOK}
+  set-session-flag 0 true
diff --git a/tools/hrw4u/grammar/hrw4u.g4 b/tools/hrw4u/grammar/hrw4u.g4
index 48fe316c5c..1527778716 100644
--- a/tools/hrw4u/grammar/hrw4u.g4
+++ b/tools/hrw4u/grammar/hrw4u.g4
@@ -21,6 +21,7 @@ grammar hrw4u;
 // Lexer Rules
 // -----------------------------
 VARS          : 'VARS';
+SESSION_VARS  : 'SESSION_VARS';
 IF            : 'if';
 ELIF          : 'elif';
 ELSE          : 'else';
@@ -131,6 +132,7 @@ paramRef
 
 section
     : varSection
+    | sessionVarSection
     | name=IDENT LBRACE sectionBody+ RBRACE
     ;
 
@@ -138,6 +140,10 @@ varSection
     : VARS LBRACE variables RBRACE
     ;
 
+sessionVarSection
+    : SESSION_VARS LBRACE variables RBRACE
+    ;
+
 sectionBody
     : statement
     | conditional
diff --git a/tools/hrw4u/src/hrw_symbols.py b/tools/hrw4u/src/hrw_symbols.py
index 2eaa9608cc..aa7ef58d4d 100644
--- a/tools/hrw4u/src/hrw_symbols.py
+++ b/tools/hrw4u/src/hrw_symbols.py
@@ -34,7 +34,7 @@ class InverseSymbolResolver(SymbolResolverBase):
 
     def __init__(self, dbg: Dbg | None = None) -> None:
         super().__init__(debug=False, dbg=dbg)
-        self._state_vars: dict[tuple[types.VarType, int], str] = {}
+        self._state_vars: dict[tuple[types.VarType, int, types.VarScope], str] 
= {}
 
     @cached_property
     def _rev_conditions_exact(self) -> dict[str, str]:
@@ -99,11 +99,12 @@ class InverseSymbolResolver(SymbolResolverBase):
             return f"{context_info}{payload}", False
         return None
 
-    def _get_or_create_var_name(self, var_type: types.VarType, index: int) -> 
str:
-        key = (var_type, index)
+    def _get_or_create_var_name(self, var_type: types.VarType, index: int, 
scope: types.VarScope = types.VarScope.TXN) -> str:
+        key = (var_type, index, scope)
         if key not in self._state_vars:
             type_name = var_type.name.lower()
-            self._state_vars[key] = f"{type_name}_{index}"
+            prefix = "ssn_" if scope == types.VarScope.SESSION else ""
+            self._state_vars[key] = f"{prefix}{type_name}_{index}"
         return self._state_vars[key]
 
     def _resolve_from_context_map(self, context_map: dict, section: 
SectionType | None, default: str) -> str:
@@ -152,15 +153,16 @@ class InverseSymbolResolver(SymbolResolverBase):
 
         return None
 
-    def _handle_state_tag(self, tag: str, payload: str | None) -> tuple[str, 
bool]:
-        state_type = tag[6:]
+    def _handle_state_tag(self, tag: str, payload: str | None, scope: 
types.VarScope = types.VarScope.TXN) -> tuple[str, bool]:
+        prefix_len = len(scope.cond_prefix) + 1  # "STATE-" or "SESSION-"
+        state_type = tag[prefix_len:]
         if payload is None:
             raise SymbolResolutionError(f"%{{{tag}}}", f"Missing index for 
{tag}")
         try:
             index = int(payload)
             for var_type in types.VarType:
                 if var_type.cond_tag == state_type:
-                    return self._get_or_create_var_name(var_type, index), False
+                    return self._get_or_create_var_name(var_type, index, 
scope), False
             raise SymbolResolutionError(f"%{{{tag}}}", f"Unknown state type: 
{state_type}")
         except ValueError:
             raise SymbolResolutionError(f"%{{{tag}}}", f"Invalid index for 
{tag}: {payload}")
@@ -363,12 +365,22 @@ class InverseSymbolResolver(SymbolResolverBase):
             return '{' + content + '}'
         return iprange_text
 
-    def get_var_declarations(self) -> list[str]:
-        """Get variable declarations in hrw4u format."""
-        declarations = []
-        for (var_type, _), var_name in sorted(self._state_vars.items(), 
key=lambda x: (x[0][0].name, x[0][1])):
-            declarations.append(f"{var_name}: {var_type.name.lower()};")
-        return declarations
+    def get_var_declarations(self) -> tuple[list[str], list[str]]:
+        """Get variable declarations in hrw4u format, separated by scope.
+
+        Returns a tuple of (txn_declarations, session_declarations).
+        """
+        txn_decls = []
+        ssn_decls = []
+
+        for (var_type, _, scope), var_name in sorted(self._state_vars.items(), 
key=lambda x: (x[0][2].name, x[0][0].name, x[0][1])):
+            decl = f"{var_name}: {var_type.name.lower()};"
+            if scope == types.VarScope.SESSION:
+                ssn_decls.append(decl)
+            else:
+                txn_decls.append(decl)
+
+        return txn_decls, ssn_decls
 
     def negate_expression(self, term: str) -> str:
         """Negate a logical expression appropriately."""
@@ -419,7 +431,10 @@ class InverseSymbolResolver(SymbolResolverBase):
             return percent, False
 
         if tag.startswith("STATE-"):
-            return self._handle_state_tag(tag, payload)
+            return self._handle_state_tag(tag, payload, types.VarScope.TXN)
+
+        if tag.startswith("SESSION-"):
+            return self._handle_state_tag(tag, payload, types.VarScope.SESSION)
 
         if tag == "IP" and payload:
             result = self._handle_ip_tag(payload)
@@ -470,21 +485,23 @@ class InverseSymbolResolver(SymbolResolverBase):
         line = " ".join(toks)
 
         for var_type in types.VarType:
-            if cmd == var_type.op_tag:
-                if len(toks) < 3:
-                    raise SymbolResolutionError(line, f"Missing arguments for 
{cmd}")
-                try:
-                    index = int(toks[1])
-                    value = " ".join(toks[2:])
-                except ValueError:
-                    raise SymbolResolutionError(line, f"Invalid index for 
{cmd}: {toks[1]}")
-
-                var_name = self._get_or_create_var_name(var_type, index)
-                if value.startswith('%{') and value.endswith('}'):
-                    rewritten_value, _ = self.percent_to_ident_or_func(value, 
section)
-                else:
-                    rewritten_value = self._rewrite_inline_percents(value, 
section)
-                return f"{var_name} = {rewritten_value}"
+            for scope in types.VarScope:
+                op_tag = f"{scope.op_prefix}-{var_type.op_suffix}"
+                if cmd == op_tag:
+                    if len(toks) < 3:
+                        raise SymbolResolutionError(line, f"Missing arguments 
for {cmd}")
+                    try:
+                        index = int(toks[1])
+                        value = " ".join(toks[2:])
+                    except ValueError:
+                        raise SymbolResolutionError(line, f"Invalid index for 
{cmd}: {toks[1]}")
+
+                    var_name = self._get_or_create_var_name(var_type, index, 
scope)
+                    if value.startswith('%{') and value.endswith('}'):
+                        rewritten_value, _ = 
self.percent_to_ident_or_func(value, section)
+                    else:
+                        rewritten_value = self._rewrite_inline_percents(value, 
section)
+                    return f"{var_name} = {rewritten_value}"
 
         for lhs_key, params in tables.OPERATOR_MAP.items():
             commands = params.target if params else None
diff --git a/tools/hrw4u/src/hrw_visitor.py b/tools/hrw4u/src/hrw_visitor.py
index 2878da0be2..b6dc61a90d 100644
--- a/tools/hrw4u/src/hrw_visitor.py
+++ b/tools/hrw4u/src/hrw_visitor.py
@@ -154,10 +154,16 @@ class HRWInverseVisitor(u4wrhVisitor, BaseHRWVisitor):
                 self.visit(line)
             self._close_if_and_section()
 
-            var_declarations = self.symbol_resolver.get_var_declarations()
-            if var_declarations:
-                vars_output = '\n'.join(["VARS {", 
*[self.format_with_indent(decl, 1) for decl in var_declarations], "}", ""])
-                self.output = vars_output.split('\n') + self.output
+            txn_decls, ssn_decls = self.symbol_resolver.get_var_declarations()
+            preamble = []
+
+            if txn_decls:
+                preamble += ["VARS {"] + [self.format_with_indent(d, 1) for d 
in txn_decls] + ["}", ""]
+            if ssn_decls:
+                preamble += ["SESSION_VARS {"] + [self.format_with_indent(d, 
1) for d in ssn_decls] + ["}", ""]
+
+            if preamble:
+                self.output = preamble + self.output
 
             return self.output
 
diff --git a/tools/hrw4u/src/kg_visitor.py b/tools/hrw4u/src/kg_visitor.py
index 8b4b88929a..4f7ec6c353 100644
--- a/tools/hrw4u/src/kg_visitor.py
+++ b/tools/hrw4u/src/kg_visitor.py
@@ -242,6 +242,8 @@ class KnowledgeGraphVisitor(hrw4uVisitor, BaseHRWVisitor):
         with self.debug_context("visitSection"):
             if ctx.varSection():
                 return self.visitVarSection(ctx.varSection())
+            if ctx.sessionVarSection():
+                return self.visitSessionVarSection(ctx.sessionVarSection())
 
             if ctx.name is None:
                 return
@@ -294,6 +296,25 @@ class KnowledgeGraphVisitor(hrw4uVisitor, BaseHRWVisitor):
 
             self.current_section_id = old_section_id
 
+    def visitSessionVarSection(self, ctx) -> None:
+        with self.debug_context("visitSessionVarSection"):
+            vars_section_id = self._add_node(
+                "VarSection", {
+                    "scope": "session",
+                    "line_start": ctx.start.line if ctx.start else None,
+                    "line_end": ctx.stop.line if ctx.stop else None
+                }, f"{self.filename}:SESSION_VARS")
+
+            if self.current_file_id:
+                self._add_edge(self.current_file_id, vars_section_id, 
"CONTAINS")
+
+            old_section_id = self.current_section_id
+            self.current_section_id = vars_section_id
+
+            self.visit(ctx.variables())
+
+            self.current_section_id = old_section_id
+
     def visitVariableDecl(self, ctx) -> None:
         with self.debug_context("visitVariableDecl"):
             if ctx.name is None or ctx.typeName is None:
diff --git a/tools/hrw4u/src/lsp/strings.py b/tools/hrw4u/src/lsp/strings.py
index a16595da8c..e4b373cc4b 100644
--- a/tools/hrw4u/src/lsp/strings.py
+++ b/tools/hrw4u/src/lsp/strings.py
@@ -212,8 +212,9 @@ class DocumentAnalyzer:
             if not stripped or stripped.startswith('//') or 
stripped.startswith('#'):
                 continue
 
-            # Check for VARS section start
-            if stripped == 'VARS {' or stripped.startswith('VARS {'):
+            # Check for VARS or SESSION_VARS section start
+            if stripped == 'VARS {' or stripped.startswith('VARS {') or \
+               stripped == 'SESSION_VARS {' or 
stripped.startswith('SESSION_VARS {'):
                 in_vars_section = True
                 brace_count = 1
                 continue
diff --git a/tools/hrw4u/src/symbols.py b/tools/hrw4u/src/symbols.py
index 7ef52fcf59..a57c6f2704 100644
--- a/tools/hrw4u/src/symbols.py
+++ b/tools/hrw4u/src/symbols.py
@@ -46,7 +46,8 @@ class SymbolResolver(SymbolResolverBase):
             return params.target, params.validate
         raise SymbolResolutionError(name, "Unknown operator or invalid 
standalone use")
 
-    def declare_variable(self, name: str, type_name: str, explicit_slot: int | 
None = None) -> str:
+    def declare_variable(
+            self, name: str, type_name: str, explicit_slot: int | None = None, 
scope: types.VarScope = types.VarScope.TXN) -> str:
         try:
             var_type = types.VarType.from_str(type_name)
         except ValueError as e:
@@ -54,24 +55,24 @@ class SymbolResolver(SymbolResolverBase):
             error.add_note(f"Available types: {', '.join([vt.name for vt in 
types.VarType])}")
             raise error
 
-        # Determine slot number
+        # Determine slot number (separate slot pools per scope)
         if explicit_slot is not None:
             if explicit_slot < 0 or explicit_slot >= var_type.limit:
                 raise SymbolResolutionError(
                     name, f"Slot @{explicit_slot} out of range for type 
'{type_name}' (valid: 0-{var_type.limit-1})")
             for var_name, sym in self._symbols.items():
-                if sym.var_type == var_type and sym.slot == explicit_slot:
+                if sym.var_type == var_type and sym.scope == scope and 
sym.slot == explicit_slot:
                     raise SymbolResolutionError(name, f"Slot @{explicit_slot} 
already used by variable '{var_name}'")
 
             slot = explicit_slot
         else:
-            used_slots = {sym.slot for sym in self._symbols.values() if 
sym.var_type == var_type}
+            used_slots = {sym.slot for sym in self._symbols.values() if 
sym.var_type == var_type and sym.scope == scope}
             slot = next((i for i in range(var_type.limit) if i not in 
used_slots), None)
 
             if slot is None:
                 raise SymbolResolutionError(name, f"No available slots for 
type '{type_name}' (max {var_type.limit})")
 
-        symbol = types.Symbol(var_type, slot)
+        symbol = types.Symbol(var_type, slot, scope)
         self._symbols[name] = symbol
         return symbol.as_cond()
 
diff --git a/tools/hrw4u/src/types.py b/tools/hrw4u/src/types.py
index 3db3e1bfab..97c28438be 100644
--- a/tools/hrw4u/src/types.py
+++ b/tools/hrw4u/src/types.py
@@ -121,15 +121,33 @@ class SuffixGroup(Enum):
                     f"Must be one of: {', '.join(sorted(self.value))}")
 
 
+class VarScope(Enum):
+    TXN = ("STATE", "set-state", "Transaction-scoped variable")
+    SESSION = ("SESSION", "set-session", "Session-scoped variable")
+
+    def __init__(self, cond_prefix: str, op_prefix: str, description: str) -> 
None:
+        self._cond_prefix = cond_prefix
+        self._op_prefix = op_prefix
+        self._description = description
+
+    @property
+    def cond_prefix(self) -> str:
+        return self._cond_prefix
+
+    @property
+    def op_prefix(self) -> str:
+        return self._op_prefix
+
+
 class VarType(Enum):
-    BOOL = ("bool", "FLAG", "set-state-flag", 16, "Boolean variable type - 
stores true/false values")
-    INT8 = ("int8", "INT8", "set-state-int8", 4, "8-bit integer variable type 
- stores values from 0 to 255")
-    INT16 = ("int16", "INT16", "set-state-int16", 1, "16-bit integer variable 
type - stores values from 0 to 65535")
+    BOOL = ("bool", "FLAG", "flag", 16, "Boolean variable type - stores 
true/false values")
+    INT8 = ("int8", "INT8", "int8", 4, "8-bit integer variable type - stores 
values from 0 to 255")
+    INT16 = ("int16", "INT16", "int16", 1, "16-bit integer variable type - 
stores values from 0 to 65535")
 
-    def __init__(self, name: str, cond_tag: str, op_tag: str, limit: int, 
description: str) -> None:
+    def __init__(self, name: str, cond_tag: str, op_suffix: str, limit: int, 
description: str) -> None:
         self._name = name
         self._cond_tag = cond_tag
-        self._op_tag = op_tag
+        self._op_suffix = op_suffix
         self._limit = limit
         self._description = description
 
@@ -138,8 +156,8 @@ class VarType(Enum):
         return self._cond_tag
 
     @property
-    def op_tag(self) -> str:
-        return self._op_tag
+    def op_suffix(self) -> str:
+        return self._op_suffix
 
     @property
     def limit(self) -> int:
@@ -165,12 +183,13 @@ class VarType(Enum):
 class Symbol:
     var_type: VarType
     slot: int
+    scope: VarScope = VarScope.TXN
 
     def as_cond(self) -> str:
-        return f"%{{STATE-{self.var_type.cond_tag}:{self.slot}}}"
+        return 
f"%{{{self.scope.cond_prefix}-{self.var_type.cond_tag}:{self.slot}}}"
 
     def as_operator(self, value: str) -> str:
-        return f"{self.var_type.op_tag} {self.slot} {value}"
+        return f"{self.scope.op_prefix}-{self.var_type.op_suffix} {self.slot} 
{value}"
 
 
 class MapParams:
diff --git a/tools/hrw4u/src/visitor.py b/tools/hrw4u/src/visitor.py
index 68182fb2b9..f3fb38c3a3 100644
--- a/tools/hrw4u/src/visitor.py
+++ b/tools/hrw4u/src/visitor.py
@@ -38,6 +38,7 @@ from hrw4u.visitor_base import BaseHRWVisitor
 from hrw4u.validation import Validator
 from hrw4u.procedures import resolve_use_path
 from hrw4u.sandbox import SandboxConfig, SandboxDenialError
+import hrw4u.types as types
 
 _regex_validator = Validator.regex_pattern()
 
@@ -91,6 +92,7 @@ class HRW4UVisitor(hrw4uVisitor, BaseHRWVisitor):
         self._proc_call_stack: list[str] = []
         self._proc_search_paths: list[Path] = list(proc_search_paths) if 
proc_search_paths else []
         self._source_text: str = ""
+        self._current_var_scope: types.VarScope = types.VarScope.TXN
 
     def _sandbox_check(self, ctx, check_fn) -> bool:
         """Run a sandbox check, trapping any denial error into the error 
collector.
@@ -725,6 +727,8 @@ class HRW4UVisitor(hrw4uVisitor, BaseHRWVisitor):
         with self.debug_context("visitSection"):
             if ctx.varSection():
                 return self.visitVarSection(ctx.varSection())
+            if ctx.sessionVarSection():
+                return self.visitSessionVarSection(ctx.sessionVarSection())
 
             hook = None
             with self.trap(ctx):
@@ -822,6 +826,23 @@ class HRW4UVisitor(hrw4uVisitor, BaseHRWVisitor):
                 return
             if not self._sandbox_check(ctx, lambda: 
self._sandbox.check_language("variables")):
                 return
+            self._current_var_scope = types.VarScope.TXN
+            self.visit(ctx.variables())
+
+    def visitSessionVarSection(self, ctx) -> None:
+        if self.current_section is not None:
+            error = hrw4u_error(self.filename, ctx, "Session variable section 
must be first in a section")
+            if self.error_collector:
+                self.error_collector.add_error(error)
+                return
+            else:
+                raise error
+        with self.debug_context("visitSessionVarSection"):
+            if not self._sandbox_check(ctx, lambda: 
self._sandbox.check_section("SESSION_VARS")):
+                return
+            if not self._sandbox_check(ctx, lambda: 
self._sandbox.check_language("variables")):
+                return
+            self._current_var_scope = types.VarScope.SESSION
             self.visit(ctx.variables())
 
     def visitCommentLine(self, ctx) -> None:
@@ -924,7 +945,7 @@ class HRW4UVisitor(hrw4uVisitor, BaseHRWVisitor):
                 if '.' in name or ':' in name:
                     raise SymbolResolutionError("variable", f"Variable name 
'{name}' cannot contain '.' or ':' characters")
 
-                symbol = self.symbol_resolver.declare_variable(name, 
type_name, explicit_slot)
+                symbol = self.symbol_resolver.declare_variable(name, 
type_name, explicit_slot, self._current_var_scope)
                 slot_info = f" @{explicit_slot}" if explicit_slot is not None 
else ""
                 self._dbg(f"bind `{name}' to {symbol}{slot_info}")
             except Exception as e:
diff --git a/tools/hrw4u/tests/data/vars/session_assign.ast.txt 
b/tools/hrw4u/tests/data/vars/session_assign.ast.txt
new file mode 100644
index 0000000000..05dd8336cd
--- /dev/null
+++ b/tools/hrw4u/tests/data/vars/session_assign.ast.txt
@@ -0,0 +1 @@
+(program (programItem (section (varSection VARS { (variables (variablesItem 
(variableDecl bool_0 : bool ;))) }))) (programItem (section (sessionVarSection 
SESSION_VARS { (variables (variablesItem (variableDecl ssn_bool_0 : bool ;)) 
(variablesItem (variableDecl ssn_int8_0 : int8 ;))) }))) (programItem (section 
REMAP { (sectionBody (conditional (ifStatement if (condition (expression (term 
(factor bool_0)))) (block { (blockItem (statement ssn_bool_0 = (value true) ;)) 
(blockItem (statement  [...]
diff --git a/tools/hrw4u/tests/data/vars/session_assign.input.txt 
b/tools/hrw4u/tests/data/vars/session_assign.input.txt
new file mode 100644
index 0000000000..3b1dc592e0
--- /dev/null
+++ b/tools/hrw4u/tests/data/vars/session_assign.input.txt
@@ -0,0 +1,15 @@
+VARS {
+    bool_0: bool;
+}
+
+SESSION_VARS {
+    ssn_bool_0: bool;
+    ssn_int8_0: int8;
+}
+
+REMAP {
+    if bool_0 {
+        ssn_bool_0 = true;
+        ssn_int8_0 = 3;
+    }
+}
diff --git a/tools/hrw4u/tests/data/vars/session_assign.output.txt 
b/tools/hrw4u/tests/data/vars/session_assign.output.txt
new file mode 100644
index 0000000000..2cc526681d
--- /dev/null
+++ b/tools/hrw4u/tests/data/vars/session_assign.output.txt
@@ -0,0 +1,4 @@
+cond %{REMAP_PSEUDO_HOOK} [AND]
+cond %{STATE-FLAG:0}
+    set-session-flag 0 true
+    set-session-int8 0 3
diff --git a/tools/hrw4u/tests/data/vars/session_bool.ast.txt 
b/tools/hrw4u/tests/data/vars/session_bool.ast.txt
new file mode 100644
index 0000000000..f418b5cb2f
--- /dev/null
+++ b/tools/hrw4u/tests/data/vars/session_bool.ast.txt
@@ -0,0 +1 @@
+(program (programItem (section (sessionVarSection SESSION_VARS { (variables 
(variablesItem (variableDecl ssn_bool_0 : bool ;))) }))) (programItem (section 
REMAP { (sectionBody (conditional (ifStatement if (condition (expression (term 
(factor ssn_bool_0)))) (block { (blockItem (statement inbound.req.X-suspicious 
= (value "true") ;)) })))) })) <EOF>)
diff --git a/tools/hrw4u/tests/data/vars/session_bool.input.txt 
b/tools/hrw4u/tests/data/vars/session_bool.input.txt
new file mode 100644
index 0000000000..e4d335b386
--- /dev/null
+++ b/tools/hrw4u/tests/data/vars/session_bool.input.txt
@@ -0,0 +1,9 @@
+SESSION_VARS {
+    ssn_bool_0: bool;
+}
+
+REMAP {
+    if ssn_bool_0 {
+        inbound.req.X-suspicious = "true";
+    }
+}
diff --git a/tools/hrw4u/tests/data/vars/session_bool.output.txt 
b/tools/hrw4u/tests/data/vars/session_bool.output.txt
new file mode 100644
index 0000000000..c324be3f97
--- /dev/null
+++ b/tools/hrw4u/tests/data/vars/session_bool.output.txt
@@ -0,0 +1,3 @@
+cond %{REMAP_PSEUDO_HOOK} [AND]
+cond %{SESSION-FLAG:0}
+    set-header X-suspicious "true"
diff --git a/tools/hrw4u/tests/data/vars/session_int16.ast.txt 
b/tools/hrw4u/tests/data/vars/session_int16.ast.txt
new file mode 100644
index 0000000000..c2a9f5f8a2
--- /dev/null
+++ b/tools/hrw4u/tests/data/vars/session_int16.ast.txt
@@ -0,0 +1 @@
+(program (programItem (section (sessionVarSection SESSION_VARS { (variables 
(variablesItem (variableDecl ssn_int16_0 : int16 ;))) }))) (programItem 
(section REMAP { (sectionBody (conditional (ifStatement if (condition 
(expression (term (factor (comparison (comparable ssn_int16_0) > (value 
10000)))))) (block { (blockItem (statement inbound.req.X-heavy = (value "true") 
;)) })))) })) <EOF>)
diff --git a/tools/hrw4u/tests/data/vars/session_int16.input.txt 
b/tools/hrw4u/tests/data/vars/session_int16.input.txt
new file mode 100644
index 0000000000..d231f39eda
--- /dev/null
+++ b/tools/hrw4u/tests/data/vars/session_int16.input.txt
@@ -0,0 +1,9 @@
+SESSION_VARS {
+    ssn_int16_0: int16;
+}
+
+REMAP {
+    if ssn_int16_0 > 10000 {
+        inbound.req.X-heavy = "true";
+    }
+}
diff --git a/tools/hrw4u/tests/data/vars/session_int16.output.txt 
b/tools/hrw4u/tests/data/vars/session_int16.output.txt
new file mode 100644
index 0000000000..90d97854b6
--- /dev/null
+++ b/tools/hrw4u/tests/data/vars/session_int16.output.txt
@@ -0,0 +1,3 @@
+cond %{REMAP_PSEUDO_HOOK} [AND]
+cond %{SESSION-INT16:0} >10000
+    set-header X-heavy "true"
diff --git a/tools/hrw4u/tests/data/vars/session_int8.ast.txt 
b/tools/hrw4u/tests/data/vars/session_int8.ast.txt
new file mode 100644
index 0000000000..f0b4235b86
--- /dev/null
+++ b/tools/hrw4u/tests/data/vars/session_int8.ast.txt
@@ -0,0 +1 @@
+(program (programItem (section (sessionVarSection SESSION_VARS { (variables 
(variablesItem (variableDecl ssn_int8_0 : int8 ;))) }))) (programItem (section 
REMAP { (sectionBody (conditional (ifStatement if (condition (expression (term 
(factor (comparison (comparable ssn_int8_0) > (value 5)))))) (block { 
(blockItem (statement inbound.req.X-throttle = (value "true") ;)) })))) })) 
<EOF>)
diff --git a/tools/hrw4u/tests/data/vars/session_int8.input.txt 
b/tools/hrw4u/tests/data/vars/session_int8.input.txt
new file mode 100644
index 0000000000..e1ff2f0a2d
--- /dev/null
+++ b/tools/hrw4u/tests/data/vars/session_int8.input.txt
@@ -0,0 +1,9 @@
+SESSION_VARS {
+    ssn_int8_0: int8;
+}
+
+REMAP {
+    if ssn_int8_0 > 5 {
+        inbound.req.X-throttle = "true";
+    }
+}
diff --git a/tools/hrw4u/tests/data/vars/session_int8.output.txt 
b/tools/hrw4u/tests/data/vars/session_int8.output.txt
new file mode 100644
index 0000000000..cbfd9271b9
--- /dev/null
+++ b/tools/hrw4u/tests/data/vars/session_int8.output.txt
@@ -0,0 +1,3 @@
+cond %{REMAP_PSEUDO_HOOK} [AND]
+cond %{SESSION-INT8:0} >5
+    set-header X-throttle "true"
diff --git a/tools/hrw4u/tests/test_coverage.py 
b/tools/hrw4u/tests/test_coverage.py
index a0b31334d3..8af2de3dbf 100644
--- a/tools/hrw4u/tests/test_coverage.py
+++ b/tools/hrw4u/tests/test_coverage.py
@@ -366,14 +366,14 @@ class TestInverseSymbolResolver:
 
     def test_get_var_declarations_empty(self):
         r = self._resolver()
-        assert r.get_var_declarations() == []
+        assert r.get_var_declarations() == ([], [])
 
     def test_get_var_declarations_after_state_tag(self):
         r = self._resolver()
         r._handle_state_tag("STATE-FLAG", "0")
-        decls = r.get_var_declarations()
-        assert len(decls) == 1
-        assert "bool" in decls[0]
+        txn_decls, ssn_decls = r.get_var_declarations()
+        assert len(txn_decls) == 1
+        assert "bool" in txn_decls[0]
 
     def test_percent_to_ident_invalid(self):
         r = self._resolver()

Reply via email to