htlcswitch/hop: decode next_node_id blinded hops
What changed, and why it matters
This commit adds support in LND for a different way of identifying the next hop in a private ('blinded') Lightning payment route. Previously LND expected the next hop to be identified by a channel ID; now it also accepts the next node's public key, which some other Lightning implementations already use. The change also enforces a BOLT 4 rule that a blinded hop must set exactly one of those two identifiers, rejecting any hop that sets both. It is primarily an interoperability/standards-compliance change, not a clear security fix, though the new mutual-exclusivity check removes a small ambiguity in route parsing.
Treat as a normal standards-compliance/interoperability commit. Include in routine review and testing; no urgent security response required. Verify downstream switch logic correctly resolves a node-ID next hop to an outgoing channel before this code path is used in production routes.
Security signals we found
New validation enforces BOLT 4 mutual exclusivity of short_channel_id and next_node_id in blinded route data
Previously ambiguous or unhandled next_node_id forwarding case now explicitly decoded
No memory-safety, cryptographic, or authorization changes observed
No vendor disclosure, CVE, or researcher attribution present in commit
Evidence from the diff
The patch extends htlcswitch/hop to decode next_node_id in addition to short_channel_id for non-final blinded hops. It introduces NewNodeNextHop and NextHopNode accessors, updates deriveBlindedRouteForwardingInfo to build an Either
Changed components
htlcswitch/hop/forwarding_info.gohtlcswitch/hop/iterator.gorecord/blinded_data.gopayments/db/migration1/record/blinded_data.goInspect captured patch +471 / −8
diff --git a/htlcswitch/hop/forwarding_info.go b/htlcswitch/hop/forwarding_info.go
index 8e05872..ed0a95d 100644
--- a/htlcswitch/hop/forwarding_info.go
+++ b/htlcswitch/hop/forwarding_info.go
@@ -48,6 +48,15 @@ func NewChannelNextHop(
return fn.NewLeft[lnwire.ShortChannelID, [33]byte](scid)
}
+// NewNodeNextHop returns a next-hop value that identifies the next hop by the
+// next node's compressed public key, as used by blinded routes that set
+// next_node_id instead of a short channel ID.
+func NewNodeNextHop(
+ nodeID [33]byte) fn.Either[lnwire.ShortChannelID, [33]byte] {
+
+ return fn.NewRight[lnwire.ShortChannelID, [33]byte](nodeID)
+}
+
// IsExit returns true if this forwarding info denotes the exit hop, i.e. we are
// the final recipient of the HTLC. This is the case when the next hop is a
// short channel ID equal to hop.Exit. A node-ID next hop (used by some blinded
@@ -69,6 +78,13 @@ func (f ForwardingInfo) NextHopChannel() fn.Option[lnwire.ShortChannelID] {
return f.NextHop.LeftToSome()
}
+// NextHopNode returns the next hop's compressed pubkey when it is identified by
+// node ID (blinded routes via next_node_id), or None when identified by
+// channel.
+func (f ForwardingInfo) NextHopNode() fn.Option[[33]byte] {
+ return f.NextHop.RightToSome()
+}
+
// FinalHtlcValidationResult describes the result of checking a final-hop
// HTLC against the onion payload and supported final-hop CLTV range.
type FinalHtlcValidationResult uint8
diff --git a/htlcswitch/hop/forwarding_info_test.go b/htlcswitch/hop/forwarding_info_test.go
index 284c7c3..3ca5fbe 100644
--- a/htlcswitch/hop/forwarding_info_test.go
+++ b/htlcswitch/hop/forwarding_info_test.go
@@ -3,6 +3,7 @@ package hop
import (
"testing"
+ "github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/stretchr/testify/require"
)
@@ -135,3 +136,41 @@ func TestValidateFinalHtlc(t *testing.T) {
})
}
}
+
+// TestForwardingInfoNextHop asserts the next-hop accessors for both the short
+// channel ID (Left) and node ID (Right) representations, including the
+// invariant that the zero-value ForwardingInfo denotes the exit hop.
+func TestForwardingInfoNextHop(t *testing.T) {
+ t.Parallel()
+
+ scid := lnwire.NewShortChanIDFromInt(12345)
+ nodeID := [33]byte{0x02}
+
+ // The zero-value ForwardingInfo must denote the exit hop, since its
+ // NextHop is a Left equal to hop.Exit. Callers rely on this to detect
+ // that we are the final recipient.
+ zero := ForwardingInfo{}
+ require.True(t, zero.IsExit(), "zero value must be the exit hop")
+ require.Equal(
+ t, fn.Some(Exit), zero.NextHopChannel(),
+ "zero value must expose the Exit channel",
+ )
+
+ // An explicit channel next hop equal to Exit is likewise the exit hop.
+ exit := ForwardingInfo{NextHop: NewChannelNextHop(Exit)}
+ require.True(t, exit.IsExit())
+
+ // A channel next hop with a real SCID is a forward, and exposes that
+ // SCID through NextHopChannel.
+ channel := ForwardingInfo{NextHop: NewChannelNextHop(scid)}
+ require.False(t, channel.IsExit())
+ require.Equal(t, fn.Some(scid), channel.NextHopChannel())
+
+ // A node-ID next hop is always a forward and never exposes an outgoing
+ // channel, since the switch selects one via non-strict forwarding.
+ node := ForwardingInfo{NextHop: NewNodeNextHop(nodeID)}
+ require.False(t, node.IsExit())
+ require.Equal(
+ t, fn.None[lnwire.ShortChannelID](), node.NextHopChannel(),
+ )
+}
diff --git a/htlcswitch/hop/iterator.go b/htlcswitch/hop/iterator.go
index 1ef559f..f6f0393 100644
--- a/htlcswitch/hop/iterator.go
+++ b/htlcswitch/hop/iterator.go
@@ -10,6 +10,7 @@ import (
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/chainhash/v2"
sphinx "github.com/lightningnetwork/lightning-onion"
+ "github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/record"
"github.com/lightningnetwork/lnd/tlv"
@@ -231,6 +232,13 @@ func parseAndValidateRecipientData(r *sphinxHopIterator, payload *Payload,
return nil, routeRole, err
}
+ // BOLT 4 requires a blinded hop to set exactly one of short_channel_id
+ // or next_node_id. Reject a hop that sets both here.
+ if routeData.ShortChannelID.IsSome() && routeData.NextNodeID.IsSome() {
+ return nil, routeRole, fmt.Errorf("blinded hop sets both " +
+ "short channel ID and next node ID")
+ }
+
// This is the final node in the blinded route.
if isFinal {
return deriveBlindedRouteFinalHopForwardingInfo(
@@ -318,15 +326,35 @@ func deriveBlindedRouteForwardingInfo(r *sphinxHopIterator,
)
}
- nextSCID, err := routeData.ShortChannelID.UnwrapOrErr(
- fmt.Errorf("next SCID not set for non-final blinded hop"),
- )
- if err != nil {
- return nil, routeRole, err
+ // Determine the next hop. The recipient identifies it either by a short
+ // channel ID (the common case) or, as some implementations do for
+ // blinded routes, by the next node's ID (next_node_id). Setting both is
+ // already rejected upstream, and the dummy hop check above has handled
+ // a next_node_id that points at us.
+ var nextHop fn.Either[lnwire.ShortChannelID, [33]byte]
+ switch {
+ case routeData.ShortChannelID.IsSome():
+ scid := routeData.ShortChannelID.UnwrapOr(
+ routeData.ShortChannelID.Zero(),
+ )
+ nextHop = NewChannelNextHop(scid.Val)
+
+ case routeData.NextNodeID.IsSome():
+ nodeID := routeData.NextNodeID.UnwrapOr(
+ routeData.NextNodeID.Zero(),
+ )
+ var pubKey [33]byte
+ copy(pubKey[:], nodeID.Val.SerializeCompressed())
+
+ nextHop = NewNodeNextHop(pubKey)
+
+ default:
+ return nil, routeRole, fmt.Errorf("next hop not set for " +
+ "non-final blinded hop")
}
payload.FwdInfo = ForwardingInfo{
- NextHop: NewChannelNextHop(nextSCID.Val),
+ NextHop: nextHop,
AmountToForward: fwdAmt,
OutgoingCLTV: r.blindingKit.IncomingCltv - uint32(
relayInfo.Val.CltvExpiryDelta,
diff --git a/htlcswitch/hop/iterator_test.go b/htlcswitch/hop/iterator_test.go
index 7e6b030..2297de6 100644
--- a/htlcswitch/hop/iterator_test.go
+++ b/htlcswitch/hop/iterator_test.go
@@ -9,6 +9,7 @@ import (
"github.com/btcsuite/btcd/btcec/v2"
"github.com/davecgh/go-spew/spew"
sphinx "github.com/lightningnetwork/lightning-onion"
+ "github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/record"
"github.com/lightningnetwork/lnd/tlv"
@@ -304,3 +305,378 @@ func TestParseAndValidateRecipientData(t *testing.T) {
})
}
}
+
+// TestDeriveBlindedRouteNextHop asserts how a non-final blinded hop's next hop
+// is derived from the recipient data: a short channel ID becomes a Left, a
+// next_node_id becomes a Right, having both set is rejected with an error, and
+// the absence of both is also an error.
+func TestDeriveBlindedRouteNextHop(t *testing.T) {
+ t.Parallel()
+
+ nodeKey, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+
+ nextNodeKey, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+ nextNodePub := nextNodeKey.PubKey()
+
+ var nextNodeRaw [33]byte
+ copy(nextNodeRaw[:], nextNodePub.SerializeCompressed())
+
+ scid := lnwire.NewShortChanIDFromInt(1500)
+
+ relayInfo := tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType10](
+ record.PaymentRelayInfo{
+ CltvExpiryDelta: 10,
+ BaseFee: 100,
+ FeeRate: 0,
+ },
+ ))
+ constraints := tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType12](
+ record.PaymentConstraints{
+ MaxCltvExpiry: 1000,
+ HtlcMinimumMsat: lnwire.MilliSatoshi(1),
+ },
+ ))
+ scidRecord := tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType2](scid))
+ nodeIDRecord := tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType4](nextNodePub),
+ )
+
+ tests := []struct {
+ name string
+ data *record.BlindedRouteData
+ expectedHop fn.Either[lnwire.ShortChannelID, [33]byte]
+ expectedErr string
+ }{
+ {
+ name: "short channel id only",
+ data: &record.BlindedRouteData{
+ ShortChannelID: scidRecord,
+ RelayInfo: relayInfo,
+ Constraints: constraints,
+ },
+ expectedHop: NewChannelNextHop(scid),
+ },
+ {
+ name: "next node id only",
+ data: &record.BlindedRouteData{
+ NextNodeID: nodeIDRecord,
+ RelayInfo: relayInfo,
+ Constraints: constraints,
+ },
+ expectedHop: NewNodeNextHop(nextNodeRaw),
+ },
+ {
+ // BOLT 4 requires a non-final blinded hop to set
+ // exactly one of short_channel_id or next_node_id, so
+ // setting both must be rejected.
+ name: "both present is an error",
+ data: &record.BlindedRouteData{
+ ShortChannelID: scidRecord,
+ NextNodeID: nodeIDRecord,
+ RelayInfo: relayInfo,
+ Constraints: constraints,
+ },
+ expectedErr: "both short channel ID and next node ID",
+ },
+ {
+ name: "neither present",
+ data: &record.BlindedRouteData{
+ RelayInfo: relayInfo,
+ Constraints: constraints,
+ },
+ expectedErr: "next hop not set",
+ },
+ }
+
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ t.Parallel()
+
+ data, err := record.EncodeBlindedRouteData(
+ testCase.data,
+ )
+ require.NoError(t, err)
+
+ kit := BlindingKit{
+ Processor: &mockProcessor{},
+ IncomingAmount: 10000,
+ IncomingCltv: 500,
+ UpdateAddBlinding: tlv.SomeRecordT(
+ //nolint:ll
+ tlv.NewPrimitiveRecord[lnwire.BlindingPointTlvType](&btcec.PublicKey{}),
+ ),
+ }
+ iterator := &sphinxHopIterator{
+ blindingKit: kit,
+ router: sphinx.NewRouter(
+ &sphinx.PrivKeyECDH{PrivKey: nodeKey},
+ sphinx.NewMemoryReplayLog(),
+ ),
+ }
+
+ payload, _, err := parseAndValidateRecipientData(
+ iterator, &Payload{encryptedData: data},
+ false, RouteRoleCleartext,
+ )
+
+ if testCase.expectedErr != "" {
+ require.ErrorContains(
+ t, err, testCase.expectedErr,
+ )
+
+ return
+ }
+
+ require.NoError(t, err)
+ require.Equal(
+ t, testCase.expectedHop,
+ payload.FwdInfo.NextHop,
+ )
+ })
+ }
+}
+
+// TestBlindedHopBothNextHopFieldsRejected asserts that a blinded hop setting
+// both short_channel_id and next_node_id is rejected for a final hop and for a
+// dummy hop (next_node_id == our own pubkey), not just an intermediate hop. The
+// mutual-exclusivity check runs before the final-hop and dummy-hop branches, so
+// none of them accept a hop that violates BOLT 4. The intermediate case is
+// already covered by TestDeriveBlindedRouteNextHop.
+func TestBlindedHopBothNextHopFieldsRejected(t *testing.T) {
+ t.Parallel()
+
+ nodeKey, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+ nodePub := nodeKey.PubKey()
+
+ // Route data that sets both short_channel_id and next_node_id. The node
+ // ID is our own pubkey, which for a non-final hop would otherwise
+ // signal a dummy hop; the both-set check must still fire first.
+ bothData := &record.BlindedRouteData{
+ ShortChannelID: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType2](
+ lnwire.NewShortChanIDFromInt(1500),
+ ),
+ ),
+ NextNodeID: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType4](nodePub),
+ ),
+ RelayInfo: tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType10](
+ record.PaymentRelayInfo{
+ CltvExpiryDelta: 10,
+ BaseFee: 100,
+ FeeRate: 0,
+ },
+ )),
+ Constraints: tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType12](
+ record.PaymentConstraints{
+ MaxCltvExpiry: 1000,
+ HtlcMinimumMsat: lnwire.MilliSatoshi(1),
+ },
+ )),
+ }
+ data, err := record.EncodeBlindedRouteData(bothData)
+ require.NoError(t, err)
+
+ // Both the dummy/forwarding path (isFinal=false, next_node_id points at
+ // us) and the final path (isFinal=true) must reject the hop.
+ for _, isFinal := range []bool{false, true} {
+ name := "forwarding hop"
+ if isFinal {
+ name = "final hop"
+ }
+
+ t.Run(name, func(t *testing.T) {
+ kit := BlindingKit{
+ Processor: &mockProcessor{},
+ IncomingAmount: 10000,
+ IncomingCltv: 500,
+ UpdateAddBlinding: tlv.SomeRecordT(
+ //nolint:ll
+ tlv.NewPrimitiveRecord[lnwire.BlindingPointTlvType](&btcec.PublicKey{}),
+ ),
+ }
+ iterator := &sphinxHopIterator{
+ blindingKit: kit,
+ router: sphinx.NewRouter(
+ &sphinx.PrivKeyECDH{PrivKey: nodeKey},
+ sphinx.NewMemoryReplayLog(),
+ ),
+ }
+
+ _, _, err := parseAndValidateRecipientData(
+ iterator, &Payload{encryptedData: data},
+ isFinal, RouteRoleCleartext,
+ )
+ require.ErrorContains(
+ t, err,
+ "both short channel ID and next node ID",
+ )
+ })
+ }
+}
+
+// TestBlindedRouteDummyHopPeeledLocally asserts that a blinded route hop where
+// next_node_id is our own public key is recognized as a dummy hop and is peeled
+// locally rather than falling through to the generic next_node_id forwarding
+// branch.
+func TestBlindedRouteDummyHopPeeledLocally(t *testing.T) {
+ t.Parallel()
+
+ // Construct a realistic onion packet that contains a blinded final hop.
+ // We'll use this to test that we can peel a dummy hop locally and
+ // extract the forwarding information from the decrypted final hop's
+ // payload.
+ nodeKey, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+ nodePub := nodeKey.PubKey()
+
+ relayInfo := tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType10](
+ record.PaymentRelayInfo{
+ CltvExpiryDelta: 10,
+ BaseFee: 100,
+ FeeRate: 0,
+ },
+ ))
+ constraints := tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType12](
+ record.PaymentConstraints{
+ MaxCltvExpiry: 1000,
+ HtlcMinimumMsat: lnwire.MilliSatoshi(1),
+ },
+ ))
+
+ // Set next_node_id to our own public key. This signals a dummy hop.
+ nodeIDRecord := tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType4](nodePub),
+ )
+
+ // We'll generate a valid, cryptographically blinded final hop's payload
+ // using sphinx.BuildBlindedPath. This contains the PathID.
+ secret := make([]byte, 32)
+ secret[0] = 2
+ finalHopData := &record.BlindedRouteData{
+ PathID: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType6](secret),
+ ),
+ }
+ finalHopDataBytes, err := record.EncodeBlindedRouteData(finalHopData)
+ require.NoError(t, err)
+
+ hopInfo := &sphinx.HopInfo{
+ NodePub: nodePub,
+ PlainText: finalHopDataBytes,
+ }
+
+ blindingSessionKey, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+
+ blindedPathInfo, err := sphinx.BuildBlindedPath(
+ blindingSessionKey, []*sphinx.HopInfo{hopInfo},
+ )
+ require.NoError(t, err)
+
+ // Since we are peeling a dummy hop locally, we want the next blinding
+ // override to be the blinding point generated for our blinded final
+ // hop.
+ dummyHopData := &record.BlindedRouteData{
+ NextNodeID: nodeIDRecord,
+ RelayInfo: relayInfo,
+ Constraints: constraints,
+ NextBlindingOverride: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType8](
+ blindedPathInfo.Path.BlindingPoint,
+ ),
+ ),
+ }
+
+ data, err := record.EncodeBlindedRouteData(dummyHopData)
+ require.NoError(t, err)
+
+ // Encode a valid TLV payload for the next hop (which we will peel).
+ var hop2Buffer bytes.Buffer
+ amt := uint64(10000)
+ cltv := uint32(500)
+ encryptedDataRecord := record.NewEncryptedDataRecord(
+ &blindedPathInfo.Path.BlindedHops[0].CipherText,
+ )
+ tlvRecords := []tlv.Record{
+ record.NewAmtToFwdRecord(&amt),
+ record.NewLockTimeRecord(&cltv),
+ encryptedDataRecord,
+ }
+ tlvStream, err := tlv.NewStream(tlvRecords...)
+ require.NoError(t, err)
+ err = tlvStream.Encode(&hop2Buffer)
+ require.NoError(t, err)
+
+ hopPayload, err := sphinx.NewTLVHopPayload(hop2Buffer.Bytes())
+ require.NoError(t, err)
+
+ // Create a valid 1-hop onion path using our blinded public key.
+ var paymentPath sphinx.PaymentPath
+ paymentPath[0] = sphinx.OnionHop{
+ NodePub: *blindedPathInfo.Path.BlindedHops[0].BlindedNodePub,
+ HopPayload: hopPayload,
+ }
+
+ sessionKey, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+
+ rHash := [32]byte{1}
+
+ // Generate a cryptographically valid onion packet for this path.
+ onionPacket, err := sphinx.NewOnionPacket(
+ &paymentPath, sessionKey, rHash[:],
+ sphinx.DeterministicPacketFiller,
+ )
+ require.NoError(t, err)
+
+ // Simulate an incoming HTLC with a blinding point and a valid onion
+ // packet. The blinding point is used to decrypt the dummy hop's
+ // payload, which contains the blinding point for the next hop (the
+ // blinded final hop).
+ kit := BlindingKit{
+ Processor: &mockProcessor{},
+ IncomingAmount: 12000,
+ IncomingCltv: 510,
+ UpdateAddBlinding: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[lnwire.BlindingPointTlvType](
+ nodePub,
+ ),
+ ),
+ }
+
+ iterator := &sphinxHopIterator{
+ blindingKit: kit,
+ rHash: rHash[:],
+ router: sphinx.NewRouter(
+ &sphinx.PrivKeyECDH{PrivKey: nodeKey},
+ sphinx.NewMemoryReplayLog(),
+ ),
+ // Set our valid onion packet to be peeled.
+ processedPacket: &sphinx.ProcessedPacket{
+ NextPacket: onionPacket,
+ },
+ }
+
+ // When we parse and validate the recipient data, it should enter the
+ // dummy-hop peeling path. Since our onion packet is valid and matches
+ // our private key, it should be successfully peeled and parsed.
+ pld, _, err := parseAndValidateRecipientData(
+ iterator, &Payload{encryptedData: data},
+ false, RouteRoleCleartext,
+ )
+
+ // Assert that we successfully peeled the dummy hop and extracted the
+ // decrypted final payload.
+ require.NoError(t, err)
+ require.NotNil(t, pld)
+
+ fwdInfo := pld.ForwardingInfo()
+ require.Equal(t, lnwire.MilliSatoshi(0), fwdInfo.AmountToForward)
+ require.Equal(t, uint32(0), fwdInfo.OutgoingCLTV)
+ require.NotNil(t, fwdInfo.PathID)
+ require.Equal(t, secret, fwdInfo.PathID[:])
+}
diff --git a/payments/db/migration1/record/blinded_data.go b/payments/db/migration1/record/blinded_data.go
index 22c0967..52f0e65 100644
--- a/payments/db/migration1/record/blinded_data.go
+++ b/payments/db/migration1/record/blinded_data.go
@@ -31,7 +31,9 @@ type BlindedRouteData struct {
// NextNodeID is the node ID of the next node on the path. In the
// context of blinded path payments, this is used to indicate the
- // presence of dummy hops that need to be peeled from the onion.
+ // presence of dummy hops that need to be peeled from the onion, or to
+ // identify a real next-node forwarding target when the public key is
+ // not ours.
NextNodeID tlv.OptionalRecordT[tlv.TlvType4, *btcec.PublicKey]
// PathID is a secret set of bytes that the blinded path creator will
diff --git a/record/blinded_data.go b/record/blinded_data.go
index 8bcc0dd..5992957 100644
--- a/record/blinded_data.go
+++ b/record/blinded_data.go
@@ -32,7 +32,9 @@ type BlindedRouteData struct {
// NextNodeID is the node ID of the next node on the path. In the
// context of blinded path payments, this is used to indicate the
- // presence of dummy hops that need to be peeled from the onion.
+ // presence of dummy hops that need to be peeled from the onion, or to
+ // identify a real next-node forwarding target when the public key is
+ // not ours.
NextNodeID tlv.OptionalRecordT[tlv.TlvType4, *btcec.PublicKey]
// PathID is a secret set of bytes that the blinded path creator will
Why this scored 29/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.