Skip to content

feat(messaging): implement NATS messaging integration (topic 7) - #15

Closed
gabriel-farache wants to merge 2 commits into
dcm-project:mainfrom
gabriel-farache:feat/topic7-messaging-integration
Closed

feat(messaging): implement NATS messaging integration (topic 7)#15
gabriel-farache wants to merge 2 commits into
dcm-project:mainfrom
gabriel-farache:feat/topic7-messaging-integration

Conversation

@gabriel-farache

@gabriel-farache gabriel-farache commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements the NATS JetStream messaging subsystem for the environment agent (spec §7):

  • CloudEvent builder (internal/cloudevent): NewCloudEvent constructor and FormatSource helper for building spec-compliant CloudEvents
  • Messaging client (internal/messaging): NATS JetStream connection lifecycle, topic subscription with handler dispatch, response CE publishing, and cancel-request deny-list management
  • Topic validation: whitelist regex-based topic name validation, DeriveTopicNames helper for main/retry/cancel topic derivation (response CEs use the fixed dcm.agents.responses subject)
  • Handler separation: message handlers split into dedicated file with typed CE payload extraction and compile-time interface assertions
  • Main wiring: setupMessaging helper replaces stub implementations (messagingStatus, stubConsumerLagProvider) with real messaging.Client — satisfies IsConnected() and ConsumerLag() interfaces for health and DCM registrar
  • Config: extended with NATS URL and messaging topic fields
  • Test plans: added UT-MSG-037–041 (whitelist regex edge cases) and IT-MSG-071–073 (nested payload, delete response, publish error nak) with updated traceability matrices

Files changed (topic 7 only)

Area Files
CloudEvent builder internal/cloudevent/builder.go, builder_test.go, suite_test.go
Messaging client internal/messaging/client.go, handlers.go, topics.go, topics_test.go, suite_test.go, client_integration_test.go
Config internal/config/config.go, config_test.go
Main cmd/environment-agent/main.go, main_test.go
Build Makefile
Dependencies go.mod, go.sum
Test plans .ai/test-plans/*.md

+1,602 −43 across 18 files.

Dependencies

Depends on #14 (topic 6 — DCM registration and heartbeat). Must be merged first; this branch builds on its commit (2425f8c).

Test plan

  • make test-unit — messaging unit tests (topic validation, CE builder) pass
  • make test-integration — messaging integration tests (NATS JetStream subscribe/publish/nak lifecycle) pass
  • make lint — no new lint issues
  • Verify stubs (messagingStatus, stubConsumerLagProvider) are fully removed from main.go

Made with Cursor

@qodo-code-review

qodo-code-review Bot commented Jul 31, 2026

Copy link
Copy Markdown

PR Summary by Qodo

Implement NATS JetStream messaging client and wire into agent startup

✨ Enhancement 🧪 Tests ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Add NATS JetStream messaging client with topic management, handlers, and response publishing.
• Wire messaging + DCM registration/heartbeat into main agent lifecycle and health reporting.
• Expand configuration and add unit/integration coverage for backoff, topics, and messaging flows.
Diagram

graph TD
  A["cmd/environment-agent/main.go"] --> B["config.Load/Validate"] --> C["messaging.Client"] --> D[("NATS JetStream")]
  A --> E["dcm.Registrar"] --> F["DCM HTTP API"]
  C --> G["response publisher"] --> D
  C --> H["health.Service"]
  E --> C
  E --> I["provider.Service + monitor"]
  subgraph Legend
    direction LR
    _app["App"] ~~~ _svc["Service"] ~~~ _db[("External system")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Publish response CEs via JetStream (js.Publish) instead of conn.Publish
  • ➕ Consistent delivery semantics with consumed messages (durable, observable ack)
  • ➕ Easier to reason about retries/backpressure on the response channel
  • ➖ Requires provisioning a response stream (global/shared) and deciding retention/limits
  • ➖ May be overkill if responses are strictly best-effort notifications
2. Use NATS subject validation utilities / stricter subject model
  • ➕ Closer alignment with NATS subject grammar (tokens, separators) vs regex approximation
  • ➕ Reduces risk of allowing subtly-invalid subjects
  • ➖ Adds dependency on NATS parsing conventions or extra code
  • ➖ May restrict currently-allowed names (compat risk) if requirements are looser
3. Generate DCM client from OpenAPI (when available)
  • ➕ Stronger typing and consistent error handling across control-plane calls
  • ➕ Easier evolution as spec changes (regen vs hand-maintain)
  • ➖ Not possible until the upstream spec is published and stable
  • ➖ Adds build/tooling complexity for codegen

Recommendation: The current approach (JetStream for command topics + simple CloudEvent response publish) is a sensible incremental baseline. The most impactful follow-up would be deciding whether response events need JetStream durability; if yes, switch response publishing to js.Publish with a defined stream. Otherwise, keep conn.Publish but document it explicitly as best-effort.

Files changed (35) +3782 / -61

Enhancement (13) +1236 / -25
main.goWire messaging client and DCM registrar into agent startup +94/-4

Wire messaging client and DCM registrar into agent startup

• Replaces messaging stubs with real messaging.Client initialization and lifecycle management. Adds DCM Registrar creation using derived topic name and wires service-type change notifications via monitor/provider callbacks.

cmd/environment-agent/main.go

backoff.goIntroduce reusable exponential backoff + jitter utilities +23/-0

Introduce reusable exponential backoff + jitter utilities

• Adds CalculateBackoff (capped exponential) and ApplyJitter (full jitter) helpers to share retry behavior across subsystems.

internal/backoff/backoff.go

builder.goAdd CloudEvent v1.0 builder for agent events +33/-0

Add CloudEvent v1.0 builder for agent events

• Implements NewCloudEvent with standard attributes (id, source, type, time) and FormatSource for consistent agent source formatting.

internal/cloudevent/builder.go

validation.goRefactor validators: required-string, duration-range, and cost enum helper +19/-2

Refactor validators: required-string, duration-range, and cost enum helper

• Adds validateRequired (TrimSpace) and simplifies validateDurationRange formatting. Introduces isValidCost helper for cost validation.

internal/config/validation.go

backoff.goImplement Retry-After parsing for DCM rate-limits +31/-0

Implement Retry-After parsing for DCM rate-limits

• Adds ParseRetryAfter supporting seconds and HTTP-date formats with safe handling for empty/invalid/negative values.

internal/dcm/backoff.go

client.goAdd hand-rolled DCM HTTP client for registration and heartbeat +142/-0

Add hand-rolled DCM HTTP client for registration and heartbeat

• Implements POST registration and PUT heartbeat calls with structured payloads and response parsing. Introduces RateLimitError carrying optional Retry-After duration.

internal/dcm/client.go

registrar.goImplement DCM registrar lifecycle with backoff, heartbeats, and updates +285/-0

Implement DCM registrar lifecycle with backoff, heartbeats, and updates

• Adds async registration loop gated on advertisable service types, exponential backoff with jitter, Retry-After handling, periodic heartbeats with consumer lag, and re-registration on service-type change signals.

internal/dcm/registrar.go

monitor.goAdd on-transition callback hook with panic isolation +54/-9

Add on-transition callback hook with panic isolation

• Introduces TransitionFunc and SetOnTransition to notify callers when provider health state changes. Ensures callback runs outside mutex and is protected against panics.

internal/health/monitor/monitor.go

client.goImplement NATS JetStream client with streams, consumers, and lag reporting +311/-0

Implement NATS JetStream client with streams, consumers, and lag reporting

• Adds JetStream connection lifecycle with reconnect handling, creates main/retry/cancel streams and durable consumers, drains cancel topic on startup, and exposes IsConnected/ConsumerLag for health and DCM.

internal/messaging/client.go

handlers.goAdd main/cancel message handlers with deny-list and response CE publishing +128/-0

Add main/cancel message handlers with deny-list and response CE publishing

• Implements CloudEvent parsing, deny-list updates via cancel topic, handler dispatch with ack/nak behavior, and response CloudEvent generation/publishing with status mapping.

internal/messaging/handlers.go

topics.goDerive and validate main/retry/cancel topic names +44/-0

Derive and validate main/retry/cancel topic names

• Adds DeriveTopicNames helper and ValidateTopicName enforcing a whitelist regex, non-empty constraint, and 255-char max.

internal/messaging/topics.go

provider.goAdd trimmed non-empty validators for provider name and service type +17/-0

Add trimmed non-empty validators for provider name and service type

• Introduces ValidateName and ValidateServiceType helpers to reject empty/whitespace values.

internal/provider/provider.go

service.goAdd provider change callback and improve embedded provider cleanup +55/-10

Add provider change callback and improve embedded provider cleanup

• Adds SetOnChange callback invoked after successful registration/update, improves embedded provider stale-record cleanup and logging, assigns embedded endpoints, and defaults missing health state to Unavailable to prevent advertisement.

internal/provider/service/service.go

Bug fix (2) +7 / -1
handler.goValidate provider name and service type on CreateProvider +6/-0

Validate provider name and service type on CreateProvider

• Adds explicit validation for provider name and service_type fields before schema version validation.

internal/handler/handler.go

checker.goNormalize health check endpoint by trimming trailing slashes +1/-1

Normalize health check endpoint by trimming trailing slashes

• Ensures external checker endpoints don't accumulate double slashes by trimming trailing '/'.

internal/health/monitor/checker.go

Tests (16) +2404 / -18
2026-07-17-16-28-integration-tests.mdAdd DCM 429 backoff test and new messaging integration cases +46/-5

Add DCM 429 backoff test and new messaging integration cases

• Adds IT-DCM-105 to validate standard backoff when 429 lacks Retry-After. Adds IT-MSG-071–073 for nested payload extraction, delete response semantics, and nak/redelivery on response publish failure, and updates traceability mappings.

.ai/test-plans/2026-07-17-16-28-integration-tests.md

2026-07-17-16-28-unit-tests.mdExpand unit plan for heartbeat boundaries and messaging topic regex edges +20/-9

Expand unit plan for heartbeat boundaries and messaging topic regex edges

• Updates duration boundary tests to target heartbeat interval range and adds additional Retry-After parsing and topic whitelist edge cases. Updates traceability matrices accordingly.

.ai/test-plans/2026-07-17-16-28-unit-tests.md

main_test.goSet required Topic 6/7 env vars for main run unit test +5/-0

Set required Topic 6/7 env vars for main run unit test

• Adds AGENT_NAME/ENVIRONMENT/COST, DCM_REGISTRATION_URL, and AGENT_MESSAGING_URL env vars to satisfy config validation in run().

cmd/environment-agent/main_test.go

backoff_test.goUnit-test backoff calculation and jitter behavior +47/-0

Unit-test backoff calculation and jitter behavior

• Covers deterministic backoff progression, max capping/overflow safety, and jitter bounds using injected randFn.

internal/backoff/backoff_test.go

suite_test.goAdd Ginkgo suite for backoff package +13/-0

Add Ginkgo suite for backoff package

• Registers a dedicated Ginkgo suite for internal/backoff tests.

internal/backoff/suite_test.go

builder_test.goUnit-test CloudEvent builder and source formatting +47/-0

Unit-test CloudEvent builder and source formatting

• Validates required CE fields, uniqueness of generated IDs, and error handling for empty agent IDs.

internal/cloudevent/builder_test.go

suite_test.goAdd Ginkgo suite for cloudevent package +13/-0

Add Ginkgo suite for cloudevent package

• Registers a dedicated Ginkgo suite for internal/cloudevent tests.

internal/cloudevent/suite_test.go

config_test.goAdd comprehensive config parsing/validation tests for new fields +221/-1

Add comprehensive config parsing/validation tests for new fields

• Introduces helper to set valid env defaults and adds tests for required fields, cost enum validation, heartbeat bounds, and cross-field duration constraints.

internal/config/config_test.go

backoff_unit_test.goUnit-test DCM Retry-After parsing edge cases +61/-0

Unit-test DCM Retry-After parsing edge cases

• Covers numeric, HTTP-date, zero, empty/whitespace, negative, invalid, and very-large values.

internal/dcm/backoff_unit_test.go

registrar_integration_test.goIntegration-test DCM registration/heartbeat flows with mock server +726/-0

Integration-test DCM registration/heartbeat flows with mock server

• Introduces a mock DCM server and validates registration gating, retry/backoff behavior (including 429 semantics), heartbeat payloads, and update triggers across many scenarios.

internal/dcm/registrar_integration_test.go

suite_test.goAdd Ginkgo suite for DCM package +13/-0

Add Ginkgo suite for DCM package

• Registers a dedicated Ginkgo suite for internal/dcm tests.

internal/dcm/suite_test.go

monitor_test.goTest monitor transition callback behavior and panic recovery +149/-0

Test monitor transition callback behavior and panic recovery

• Adds tests ensuring callback fires for initial/periodic transitions, does not fire without transitions, and that panicking callbacks don't break monitoring.

internal/health/monitor/monitor_test.go

topics_test.goUnit-test topic derivation and whitelist validation +50/-0

Unit-test topic derivation and whitelist validation

• Validates override precedence and covers invalid character cases and boundary length acceptance.

internal/messaging/topics_test.go

suite_test.goAdd messaging Ginkgo suite with embedded JetStream server +41/-0

Add messaging Ginkgo suite with embedded JetStream server

• Bootstraps a real NATS JetStream server in BeforeSuite/AfterSuite to support integration tests.

internal/messaging/suite_test.go

client_integration_test.goIntegration-test JetStream streams/consumers, ack/nak, and response CEs +780/-0

Integration-test JetStream streams/consumers, ack/nak, and response CEs

• Adds integration coverage for stream/consumer creation, durability/redelivery, deny-list behavior, drain ordering, reconnect behavior, and CloudEvent response compliance and failure modes.

internal/messaging/client_integration_test.go

service_test.goTest provider change callback and embedded cleanup; adjust health fallback expectations +172/-3

Test provider change callback and embedded cleanup; adjust health fallback expectations

• Adds tests for SetOnChange behavior (including reentrancy safety) and embedded stale cleanup on registry conflicts. Updates existing tests to expect Unavailable default when no health state exists.

internal/provider/service/service_test.go

Other (4) +135 / -17
MakefileExpand unit/integration package lists and add race target +6/-10

Expand unit/integration package lists and add race target

• Includes new internal packages (backoff, dcm, messaging, cloudevent) in labeled ginkgo runs. Adds test-race target and updates PHONY list.

Makefile

go.modAdd CloudEvents and NATS dependencies +17/-0

Add CloudEvents and NATS dependencies

• Introduces cloudevents/sdk-go and NATS (nats.go + nats-server test) plus transitive dependencies required for JetStream and test servers.

go.mod

go.sumLock new dependency checksums for CloudEvents and NATS stack +41/-0

Lock new dependency checksums for CloudEvents and NATS stack

• Adds checksum entries for newly introduced modules and their transitive dependencies.

go.sum

config.goAdd Agent/DCM/Heartbeat/Messaging config and validation +71/-7

Add Agent/DCM/Heartbeat/Messaging config and validation

• Extends Config with new Topic 6/7 sections and validates required fields, cost enum, and duration ranges (including heartbeat). Adds required AGENT_MESSAGING_URL validation.

internal/config/config.go

@qodo-code-review

qodo-code-review Bot commented Jul 31, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (6) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Negative Retry-After backoff 🐞 Bug ☼ Reliability ⭐ New
Description
dcm.ParseRetryAfter returns a negative duration when an HTTP-date Retry-After is in the past, but
still reports parsing success. Registrar.computeBackoff then turns non-positive Retry-After values
into a 0s delay, so repeated 429 responses with stale dates can cause immediate retry loops against
DCM.
Code

internal/dcm/backoff.go[R26-28]

+	if t, err := http.ParseTime(value); err == nil {
+		return t.Sub(now), true
+	}
Evidence
The HTTP-date parsing path returns t.Sub(now) directly (can be negative). That value is stored in
RateLimitError on 429 responses, and the registrar explicitly returns a 0 backoff for non-positive
Retry-After values, which then feeds a timer-based wait in the retry loop.

internal/dcm/backoff.go[10-30]
internal/dcm/client.go[88-109]
internal/dcm/registrar.go[172-215]
internal/dcm/registrar.go[218-230]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`ParseRetryAfter` returns `t.Sub(now)` for HTTP-date values without guarding against past dates (negative duration). Downstream, `Registrar.computeBackoff` treats `RetryAfter <= 0` as a 0s wait, which can create an immediate retry loop if DCM (or an intermediary) sends stale HTTP-date Retry-After values.

### Issue Context
- `handleRegistrationResponse` propagates `ParseRetryAfter` output into `RateLimitError` for HTTP 429.
- `Registrar.computeBackoff` prioritizes `RateLimitError.RetryAfter` when `HasRetryAfter` is true.

### Fix Focus Areas
- internal/dcm/backoff.go[10-30]
- internal/dcm/registrar.go[218-230]
- internal/dcm/backoff_unit_test.go[12-61]

### Suggested fix
1. In `ParseRetryAfter`, after parsing an HTTP-date, compute `d := t.Sub(now)` and treat `d <= 0` as **not usable** (e.g., return `(0, false)`), or clamp to `0` but return `ok=false` so callers fall back to standard exponential backoff.
2. In `Registrar.computeBackoff`, if `rle.HasRetryAfter` but `rle.RetryAfter <= 0`, fall back to the standard backoff path rather than returning `0`.
3. Add a unit test for an HTTP-date in the past (e.g., `now` later than the parsed date) to ensure it does not produce `ok=true` with a negative duration / does not lead to a 0s backoff path.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Cancel always acked 🐞 Bug ≡ Correctness
Description
Both cancel-topic and main-topic consumers acknowledge JetStream messages even when CloudEvent
parsing/JSON decoding fails or handlers fail, meaning invalid or transiently failing requests are
permanently dropped and may proceed without deny-list enforcement. This also allows publishing
acknowledgements with an empty resourceId, breaking deny-list matching and preventing retries.
Code

internal/messaging/client.go[R159-162]

+	cc, err := cancelCons.Consume(func(msg jetstream.Msg) {
+		c.handleCancelMessage(msg)
+		_ = msg.Ack()
+	})
Evidence
The cited code paths show that cancel processing can return early on JSON/CloudEvent parse errors
and also ignores the result of cancelHandler, yet the surrounding JetStream consume callback still
calls Ack() unconditionally, making failed cancels non-retryable and potentially leaving the
deny-list unchanged. Similarly, the main-message handler ignores json.Unmarshal errors, continues
using resourceID := payload.ResourceID even if empty, and proceeds to deny-list operations and
response publication before the message is Ack()ed, which both loses malformed requests permanently
and can emit responses with empty identifiers.

internal/messaging/client.go[157-162]
internal/messaging/client.go[232-250]
internal/messaging/handlers.go[22-41]
internal/messaging/handlers.go[59-66]
internal/messaging/handlers.go[75-82]
internal/messaging/handlers.go[109-114]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
JetStream messages for both cancel-topic and main-topic handling are effectively treated as successfully processed even when CloudEvent parsing/JSON decoding fails, `resourceId` is missing/empty, or downstream handlers fail. Because the consumer still `Ack()`s these messages, invalid events and transient errors are not retried, cancels can be dropped without updating the deny-list, and responses can be published with an empty `resourceId`.

## Issue Context
- `handleCancelMessage` can exit early on JSON/CloudEvent parse errors and ignores the return value from `cancelHandler`, but the consumer callback still acknowledges the message.
- `handleMainMessage` ignores the `json.Unmarshal(event.Data(), &payload)` error and then uses `payload.ResourceID` directly (even if empty) to drive deny-list deletion, invoke `mainHandler`, and publish a response, after which the message is Ack()ed.

## Fix Focus Areas
- internal/messaging/client.go[159-162]
- internal/messaging/client.go[232-250]
- internal/messaging/handlers.go[22-41]
- internal/messaging/handlers.go[59-82]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. JetStream setup never retries 🐞 Bug ☼ Reliability
Description
internal/messaging.Client.doSetup guards setup with sync.Once, but setupStreamsAndConsume logs and
returns on any initialization error. A transient JetStream/stream/consumer failure permanently
disables consumption because later (re)connections can’t rerun setup.
Code

internal/messaging/client.go[R124-130]

+func (c *Client) doSetup(ctx context.Context, conn *nats.Conn) {
+	if c.stopped.Load() {
+		return
+	}
+	c.setupOnce.Do(func() {
+		c.setupStreamsAndConsume(ctx, conn)
+	})
Evidence
doSetup() executes setup exactly once. setupStreamsAndConsume() has multiple failure branches
that only log and return, meaning a single transient failure consumes the Once and prevents any
later successful initialization.

internal/messaging/client.go[94-107]
internal/messaging/client.go[124-131]
internal/messaging/client.go[133-177]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`doSetup()` uses `sync.Once`, but `setupStreamsAndConsume()` returns early on errors. If setup fails once (permissions, JetStream temporarily unavailable, stream creation conflict, etc.), the client will never attempt setup again, leaving the agent connected but non-consuming.

## Issue Context
- The connect handler calls `doSetup()`, but the reconnect handler does not.
- Even if reconnect did call it, `sync.Once` would prevent any retry after the first failure.

## Fix Focus Areas
- internal/messaging/client.go[94-107]
- internal/messaging/client.go[124-131]
- internal/messaging/client.go[133-177]

## Suggested fix
- Replace `sync.Once` with an explicit state machine / mutex-protected flag that only flips to “setup complete” after successful initialization.
- On setup failure, schedule retry with backoff (or re-attempt on subsequent reconnect events).
- If you allow retries, ensure you don’t leak duplicate consumers/ConsumeContexts (stop previous ones before starting new ones).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View action required (2)
4. Derived topics unvalidated 🐞 Bug ≡ Correctness
Description
setupMessaging validates only the base topic (topics.Main), but DeriveTopicNames appends
".retry"/".cancel" which can exceed ValidateTopicName’s 255-character limit. This allows a
configuration that passes validation to later fail JetStream stream initialization at runtime.
Code

cmd/environment-agent/main.go[R169-173]

+func setupMessaging(cfg *config.Config, logger *slog.Logger) (*messaging.Client, string, error) {
+	topics := messaging.DeriveTopicNames(cfg.Agent.Name, cfg.Messaging.TopicName)
+	if err := messaging.ValidateTopicName(topics.Main); err != nil {
+		return nil, "", fmt.Errorf("invalid topic name: %w", err)
+	}
Evidence
The code validates only topics.Main. The derived retry/cancel subjects are created by
concatenation and later used as JetStream subjects during stream creation, but they are never
validated for length/format.

cmd/environment-agent/main.go[169-178]
internal/messaging/topics.go[18-29]
internal/messaging/topics.go[33-43]
internal/messaging/client.go[183-201]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Only `topics.Main` is validated, but the client uses derived subjects (`.retry`, `.cancel`) when creating streams. A 255-character base topic passes validation but produces derived subjects >255 characters, causing messaging startup to fail later.

## Issue Context
`DeriveTopicNames()` always appends suffixes; `Client.initStreams()` uses those derived values as JetStream subjects.

## Fix Focus Areas
- cmd/environment-agent/main.go[169-179]
- internal/messaging/topics.go[18-29]
- internal/messaging/topics.go[33-43]
- internal/messaging/client.go[183-201]

## Suggested fix
- Validate *all* derived subjects before constructing/starting the client:
 - `ValidateTopicName(topics.Main)`
 - `ValidateTopicName(topics.Retry)`
 - `ValidateTopicName(topics.Cancel)`
- Alternatively, enforce a stricter max length for the base topic so that derived names remain <=255 (but validating each derived value is clearer).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Registrar never wakes ✓ Resolved 🐞 Bug ≡ Correctness
Description
cmd/environment-agent/main.go starts dcm.Registrar without wiring any production call to
Registrar.NotifyServiceTypeChange(), so Registrar.run can block indefinitely in its prerequisite
gate when no providers are advertisable at startup. This prevents DCM registration/heartbeats from
ever starting even if providers become advertisable later.
Code

cmd/environment-agent/main.go[R123-148]

+	registrar, err := dcm.NewRegistrar(
+		dcm.RegistrarConfig{
+			AgentName:         cfg.Agent.Name,
+			Environment:       cfg.Agent.Environment,
+			Cost:              cfg.Agent.Cost,
+			TopicName:         topicMain,
+			RegistrationURL:   cfg.DCM.RegistrationURL,
+			InitialBackoff:    cfg.DCM.InitialBackoff,
+			MaxBackoff:        cfg.DCM.MaxBackoff,
+			HeartbeatInterval: cfg.Heartbeat.Interval,
+		},
+		&serviceTypeLister{providerSvc: providerSvc},
+		msgClient,
+		nil,
+		logger,
+	)
+	if err != nil {
+		logger.Error("failed to create DCM registrar", "error", err)
+		return 1
+	}
+	regCtx, regCancel := context.WithCancel(context.Background())
+	registrar.Start(regCtx)
+	defer func() {
+		regCancel()
+		<-registrar.Done()
+	}()
Evidence
The registrar has an explicit prerequisite gate that only progresses when
AdvertisableServiceTypes() returns a non-empty list, otherwise it blocks waiting for notifyCh.
The agent starts the registrar but includes a TODO stating NotifyServiceTypeChange is not wired, so
the gate can remain blocked forever when no advertisable types exist at startup.

cmd/environment-agent/main.go[48-49]
cmd/environment-agent/main.go[122-148]
internal/dcm/registrar.go[122-134]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The registrar blocks until it sees at least one advertisable service type, but nothing in the running agent triggers `NotifyServiceTypeChange()`. As a result, if `Provider.EmbeddedSPs` is empty at startup (default), the agent can never register with DCM even after providers are later registered or become healthy.

## Issue Context
`Registrar.run()` waits for `notifyCh` to re-check service types; without notifications it won’t progress.

## Fix Focus Areas
- cmd/environment-agent/main.go[29-49]
- cmd/environment-agent/main.go[122-148]
- internal/dcm/registrar.go[122-134]

## Suggested fix
- Add real wiring so changes that can affect `AdvertisableServiceTypes()` call `registrar.NotifyServiceTypeChange()`, e.g.:
 - after `providerSvc.RegisterEmbedded(...)` (one-time kick), and
 - on provider registration/unregistration paths and/or health monitor state transitions (Unavailable <-> Unhealthy/Ready).
- Also log and/or propagate errors from `serviceTypeLister.AdvertisableServiceTypes()` instead of returning `nil` silently, because it can also deadlock the prerequisite gate.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

6. Empty subject tokens allowed 🐞 Bug ≡ Correctness
Description
internal/messaging.ValidateTopicName claims to validate “NATS subject token rules” but only checks a
character whitelist, so it accepts names with empty tokens like ".foo", "foo." or "foo..bar". These
accepted values are then used as NATS/JetStream subjects during stream setup.
Code

internal/messaging/topics.go[R9-43]

+var validTopicNameRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
+
+// TopicNames holds the derived main, retry, and cancel topic names.
+type TopicNames struct {
+	Main   string
+	Retry  string
+	Cancel string
+}
+
+// DeriveTopicNames derives main/retry/cancel topic names from the agent name
+// and an optional override. If topicNameOverride is non-empty, it takes precedence.
+func DeriveTopicNames(agentName, topicNameOverride string) TopicNames {
+	base := agentName
+	if topicNameOverride != "" {
+		base = topicNameOverride
+	}
+	return TopicNames{
+		Main:   base,
+		Retry:  base + ".retry",
+		Cancel: base + ".cancel",
+	}
+}
+
+// ValidateTopicName validates that the given name conforms to NATS subject token rules.
+func ValidateTopicName(name string) error {
+	if name == "" {
+		return errors.New("topic name must not be empty")
+	}
+	if len(name) > 255 {
+		return errors.New("topic name exceeds 255 characters")
+	}
+	if !validTopicNameRe.MatchString(name) {
+		return errors.New("topic name contains invalid characters (allowed: alphanumeric, hyphens, dots, underscores)")
+	}
+	return nil
Evidence
The validator’s regex allows any combination of dots and does not check token boundaries, so empty
tokens are permitted even though the function claims to validate token rules.

internal/messaging/topics.go[9-43]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`ValidateTopicName()` does not actually enforce non-empty dot-separated tokens even though its comment says it validates subject token rules. This allows malformed subjects that are likely to fail later during stream/consumer setup.

## Issue Context
The current validation only checks:
- non-empty string
- length <= 255
- characters in `[A-Za-z0-9._-]`

## Fix Focus Areas
- internal/messaging/topics.go[9-43]

## Suggested fix
- Add token validation, e.g.:
 - reject strings starting/ending with `.`
 - reject `..` anywhere
 - optionally split on `.` and ensure every token is non-empty
- Keep wildcard rejection (already implicit via the regex) as-is.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

7. Backoff uses float exponent 🐞 Bug ☼ Reliability ⭐ New
Description
backoff.CalculateBackoff computes initial × 2^attempt using float64 math and converts to
time.Duration before applying the max cap. For very large attempt values, that float→int64
conversion is not guaranteed to behave deterministically, so the backoff is not guaranteed to cap
cleanly at max.
Code

internal/backoff/backoff.go[R11-14]

+	shift := math.Pow(2, float64(attempt))
+	d := time.Duration(float64(initial) * shift)
+	if d <= 0 || d > max {
+		return max
Evidence
The new backoff helper does float exponentiation and float-to-duration conversion before capping,
while the registrar retry loop increments the attempt counter monotonically and uses this helper to
compute waits.

internal/backoff/backoff.go[9-16]
internal/dcm/registrar.go[172-215]
internal/dcm/registrar.go[218-230]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`CalculateBackoff` uses `math.Pow` and converts the float64 result to `time.Duration` before bounding by `max`. For sufficiently large `attempt`, the float64 product exceeds the representable `time.Duration` range, making the conversion behavior non-deterministic and weakening the guarantee that backoff cleanly caps at `max`.

### Issue Context
`Registrar.doRegistration` increments `attempt` on each retry and passes it into `backoff.CalculateBackoff` via `computeBackoff`, so `attempt` is unbounded over long-running outage scenarios.

### Fix Focus Areas
- internal/backoff/backoff.go[9-16]
- internal/dcm/registrar.go[172-215]
- internal/dcm/registrar.go[218-230]
- internal/backoff/backoff_test.go[12-23]

### Suggested fix
1. Re-implement `CalculateBackoff` using integer arithmetic that avoids float conversion, e.g.:
  - Start `d := initial` and loop `attempt` times doubling with an early stop when `d >= max/2`.
  - Or use checked shifts/multiplication with overflow detection and cap at `max`.
2. Keep existing unit tests and add a regression test with a very large `attempt` ensuring the function deterministically returns `max`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can group findings by type and pick your Finding display, from Minimal to Full

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Previous review results

Review updated until commit d065c08 ⚖️ Balanced

Results up to commit 73a467b ⚖️ Balanced


🐞 Bugs (3) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Action required
1. Derived topics unvalidated 🐞 Bug ≡ Correctness
Description
setupMessaging validates only the base topic (topics.Main), but DeriveTopicNames appends
".retry"/".cancel" which can exceed ValidateTopicName’s 255-character limit. This allows a
configuration that passes validation to later fail JetStream stream initialization at runtime.
Code

cmd/environment-agent/main.go[R169-173]

+func setupMessaging(cfg *config.Config, logger *slog.Logger) (*messaging.Client, string, error) {
+	topics := messaging.DeriveTopicNames(cfg.Agent.Name, cfg.Messaging.TopicName)
+	if err := messaging.ValidateTopicName(topics.Main); err != nil {
+		return nil, "", fmt.Errorf("invalid topic name: %w", err)
+	}
Evidence
The code validates only topics.Main. The derived retry/cancel subjects are created by
concatenation and later used as JetStream subjects during stream creation, but they are never
validated for length/format.

cmd/environment-agent/main.go[169-178]
internal/messaging/topics.go[18-29]
internal/messaging/topics.go[33-43]
internal/messaging/client.go[183-201]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Only `topics.Main` is validated, but the client uses derived subjects (`.retry`, `.cancel`) when creating streams. A 255-character base topic passes validation but produces derived subjects >255 characters, causing messaging startup to fail later.

## Issue Context
`DeriveTopicNames()` always appends suffixes; `Client.initStreams()` uses those derived values as JetStream subjects.

## Fix Focus Areas
- cmd/environment-agent/main.go[169-179]
- internal/messaging/topics.go[18-29]
- internal/messaging/topics.go[33-43]
- internal/messaging/client.go[183-201]

## Suggested fix
- Validate *all* derived subjects before constructing/starting the client:
 - `ValidateTopicName(topics.Main)`
 - `ValidateTopicName(topics.Retry)`
 - `ValidateTopicName(topics.Cancel)`
- Alternatively, enforce a stricter max length for the base topic so that derived names remain <=255 (but validating each derived value is clearer).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. JetStream setup never retries 🐞 Bug ☼ Reliability
Description
internal/messaging.Client.doSetup guards setup with sync.Once, but setupStreamsAndConsume logs and
returns on any initialization error. A transient JetStream/stream/consumer failure permanently
disables consumption because later (re)connections can’t rerun setup.
Code

internal/messaging/client.go[R124-130]

+func (c *Client) doSetup(ctx context.Context, conn *nats.Conn) {
+	if c.stopped.Load() {
+		return
+	}
+	c.setupOnce.Do(func() {
+		c.setupStreamsAndConsume(ctx, conn)
+	})
Evidence
doSetup() executes setup exactly once. setupStreamsAndConsume() has multiple failure branches
that only log and return, meaning a single transient failure consumes the Once and prevents any
later successful initialization.

internal/messaging/client.go[94-107]
internal/messaging/client.go[124-131]
internal/messaging/client.go[133-177]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`doSetup()` uses `sync.Once`, but `setupStreamsAndConsume()` returns early on errors. If setup fails once (permissions, JetStream temporarily unavailable, stream creation conflict, etc.), the client will never attempt setup again, leaving the agent connected but non-consuming.

## Issue Context
- The connect handler calls `doSetup()`, but the reconnect handler does not.
- Even if reconnect did call it, `sync.Once` would prevent any retry after the first failure.

## Fix Focus Areas
- internal/messaging/client.go[94-107]
- internal/messaging/client.go[124-131]
- internal/messaging/client.go[133-177]

## Suggested fix
- Replace `sync.Once` with an explicit state machine / mutex-protected flag that only flips to “setup complete” after successful initialization.
- On setup failure, schedule retry with backoff (or re-attempt on subsequent reconnect events).
- If you allow retries, ensure you don’t leak duplicate consumers/ConsumeContexts (stop previous ones before starting new ones).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Registrar never wakes ✓ Resolved 🐞 Bug ≡ Correctness
Description
cmd/environment-agent/main.go starts dcm.Registrar without wiring any production call to
Registrar.NotifyServiceTypeChange(), so Registrar.run can block indefinitely in its prerequisite
gate when no providers are advertisable at startup. This prevents DCM registration/heartbeats from
ever starting even if providers become advertisable later.
Code

cmd/environment-agent/main.go[R123-148]

+	registrar, err := dcm.NewRegistrar(
+		dcm.RegistrarConfig{
+			AgentName:         cfg.Agent.Name,
+			Environment:       cfg.Agent.Environment,
+			Cost:              cfg.Agent.Cost,
+			TopicName:         topicMain,
+			RegistrationURL:   cfg.DCM.RegistrationURL,
+			InitialBackoff:    cfg.DCM.InitialBackoff,
+			MaxBackoff:        cfg.DCM.MaxBackoff,
+			HeartbeatInterval: cfg.Heartbeat.Interval,
+		},
+		&serviceTypeLister{providerSvc: providerSvc},
+		msgClient,
+		nil,
+		logger,
+	)
+	if err != nil {
+		logger.Error("failed to create DCM registrar", "error", err)
+		return 1
+	}
+	regCtx, regCancel := context.WithCancel(context.Background())
+	registrar.Start(regCtx)
+	defer func() {
+		regCancel()
+		<-registrar.Done()
+	}()
Evidence
The registrar has an explicit prerequisite gate that only progresses when
AdvertisableServiceTypes() returns a non-empty list, otherwise it blocks waiting for notifyCh.
The agent starts the registrar but includes a TODO stating NotifyServiceTypeChange is not wired, so
the gate can remain blocked forever when no advertisable types exist at startup.

cmd/environment-agent/main.go[48-49]
cmd/environment-agent/main.go[122-148]
internal/dcm/registrar.go[122-134]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The registrar blocks until it sees at least one advertisable service type, but nothing in the running agent triggers `NotifyServiceTypeChange()`. As a result, if `Provider.EmbeddedSPs` is empty at startup (default), the agent can never register with DCM even after providers are later registered or become healthy.

## Issue Context
`Registrar.run()` waits for `notifyCh` to re-check service types; without notifications it won’t progress.

## Fix Focus Areas
- cmd/environment-agent/main.go[29-49]
- cmd/environment-agent/main.go[122-148]
- internal/dcm/registrar.go[122-134]

## Suggested fix
- Add real wiring so changes that can affect `AdvertisableServiceTypes()` call `registrar.NotifyServiceTypeChange()`, e.g.:
 - after `providerSvc.RegisterEmbedded(...)` (one-time kick), and
 - on provider registration/unregistration paths and/or health monitor state transitions (Unavailable <-> Unhealthy/Ready).
- Also log and/or propagate errors from `serviceTypeLister.AdvertisableServiceTypes()` instead of returning `nil` silently, because it can also deadlock the prerequisite gate.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended
4. Empty subject tokens allowed 🐞 Bug ≡ Correctness
Description
internal/messaging.ValidateTopicName claims to validate “NATS subject token rules” but only checks a
character whitelist, so it accepts names with empty tokens like ".foo", "foo." or "foo..bar". These
accepted values are then used as NATS/JetStream subjects during stream setup.
Code

internal/messaging/topics.go[R9-43]

+var validTopicNameRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
+
+// TopicNames holds the derived main, retry, and cancel topic names.
+type TopicNames struct {
+	Main   string
+	Retry  string
+	Cancel string
+}
+
+// DeriveTopicNames derives main/retry/cancel topic names from the agent name
+// and an optional override. If topicNameOverride is non-empty, it takes precedence.
+func DeriveTopicNames(agentName, topicNameOverride string) TopicNames {
+	base := agentName
+	if topicNameOverride != "" {
+		base = topicNameOverride
+	}
+	return TopicNames{
+		Main:   base,
+		Retry:  base + ".retry",
+		Cancel: base + ".cancel",
+	}
+}
+
+// ValidateTopicName validates that the given name conforms to NATS subject token rules.
+func ValidateTopicName(name string) error {
+	if name == "" {
+		return errors.New("topic name must not be empty")
+	}
+	if len(name) > 255 {
+		return errors.New("topic name exceeds 255 characters")
+	}
+	if !validTopicNameRe.MatchString(name) {
+		return errors.New("topic name contains invalid characters (allowed: alphanumeric, hyphens, dots, underscores)")
+	}
+	return nil
Evidence
The validator’s regex allows any combination of dots and does not check token boundaries, so empty
tokens are permitted even though the function claims to validate token rules.

internal/messaging/topics.go[9-43]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`ValidateTopicName()` does not actually enforce non-empty dot-separated tokens even though its comment says it validates subject token rules. This allows malformed subjects that are likely to fail later during stream/consumer setup.

## Issue Context
The current validation only checks:
- non-empty string
- length <= 255
- characters in `[A-Za-z0-9._-]`

## Fix Focus Areas
- internal/messaging/topics.go[9-43]

## Suggested fix
- Add token validation, e.g.:
 - reject strings starting/ending with `.`
 - reject `..` anywhere
 - optionally split on `.` and ensure every token is non-empty
- Keep wildcard rejection (already implicit via the regex) as-is.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Results up to commit df8e816 ⚖️ Balanced


🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Action required
1. Cancel always acked 🐞 Bug ≡ Correctness
Description
Both cancel-topic and main-topic consumers acknowledge JetStream messages even when CloudEvent
parsing/JSON decoding fails or handlers fail, meaning invalid or transiently failing requests are
permanently dropped and may proceed without deny-list enforcement. This also allows publishing
acknowledgements with an empty resourceId, breaking deny-list matching and preventing retries.
Code

internal/messaging/client.go[R159-162]

+	cc, err := cancelCons.Consume(func(msg jetstream.Msg) {
+		c.handleCancelMessage(msg)
+		_ = msg.Ack()
+	})
Evidence
The cited code paths show that cancel processing can return early on JSON/CloudEvent parse errors
and also ignores the result of cancelHandler, yet the surrounding JetStream consume callback still
calls Ack() unconditionally, making failed cancels non-retryable and potentially leaving the
deny-list unchanged. Similarly, the main-message handler ignores json.Unmarshal errors, continues
using resourceID := payload.ResourceID even if empty, and proceeds to deny-list operations and
response publication before the message is Ack()ed, which both loses malformed requests permanently
and can emit responses with empty identifiers.

internal/messaging/client.go[157-162]
internal/messaging/client.go[232-250]
internal/messaging/handlers.go[22-41]
internal/messaging/handlers.go[59-66]
internal/messaging/handlers.go[75-82]
internal/messaging/handlers.go[109-114]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
JetStream messages for both cancel-topic and main-topic handling are effectively treated as successfully processed even when CloudEvent parsing/JSON decoding fails, `resourceId` is missing/empty, or downstream handlers fail. Because the consumer still `Ack()`s these messages, invalid events and transient errors are not retried, cancels can be dropped without updating the deny-list, and responses can be published with an empty `resourceId`.

## Issue Context
- `handleCancelMessage` can exit early on JSON/CloudEvent parse errors and ignores the return value from `cancelHandler`, but the consumer callback still acknowledges the message.
- `handleMainMessage` ignores the `json.Unmarshal(event.Data(), &payload)` error and then uses `payload.ResourceID` directly (even if empty) to drive deny-list deletion, invoke `mainHandler`, and publish a response, after which the message is Ack()ed.

## Fix Focus Areas
- internal/messaging/client.go[159-162]
- internal/messaging/client.go[232-250]
- internal/messaging/handlers.go[22-41]
- internal/messaging/handlers.go[59-82]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Qodo Logo

Comment thread cmd/environment-agent/main.go
Comment on lines +169 to +173
func setupMessaging(cfg *config.Config, logger *slog.Logger) (*messaging.Client, string, error) {
topics := messaging.DeriveTopicNames(cfg.Agent.Name, cfg.Messaging.TopicName)
if err := messaging.ValidateTopicName(topics.Main); err != nil {
return nil, "", fmt.Errorf("invalid topic name: %w", err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Derived topics unvalidated 🐞 Bug ≡ Correctness

setupMessaging validates only the base topic (topics.Main), but DeriveTopicNames appends
".retry"/".cancel" which can exceed ValidateTopicName’s 255-character limit. This allows a
configuration that passes validation to later fail JetStream stream initialization at runtime.
Agent Prompt
## Issue description
Only `topics.Main` is validated, but the client uses derived subjects (`.retry`, `.cancel`) when creating streams. A 255-character base topic passes validation but produces derived subjects >255 characters, causing messaging startup to fail later.

## Issue Context
`DeriveTopicNames()` always appends suffixes; `Client.initStreams()` uses those derived values as JetStream subjects.

## Fix Focus Areas
- cmd/environment-agent/main.go[169-179]
- internal/messaging/topics.go[18-29]
- internal/messaging/topics.go[33-43]
- internal/messaging/client.go[183-201]

## Suggested fix
- Validate *all* derived subjects before constructing/starting the client:
  - `ValidateTopicName(topics.Main)`
  - `ValidateTopicName(topics.Retry)`
  - `ValidateTopicName(topics.Cancel)`
- Alternatively, enforce a stricter max length for the base topic so that derived names remain <=255 (but validating each derived value is clearer).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6cf52ed and 8555d06 — added ValidateJetStreamSafeName (REQ-MSG-011), which rejects dots and caps the base name so every derived JetStream stream/consumer name (including the longest suffix, -cancel-consumer) stays within NATS's 255-char server-side limit. Wired into setupMessaging in main.go so an unsafe base name fails startup fast instead of only surfacing later as a stream-init failure.

Comment on lines +124 to +130
func (c *Client) doSetup(ctx context.Context, conn *nats.Conn) {
if c.stopped.Load() {
return
}
c.setupOnce.Do(func() {
c.setupStreamsAndConsume(ctx, conn)
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Jetstream setup never retries 🐞 Bug ☼ Reliability

internal/messaging.Client.doSetup guards setup with sync.Once, but setupStreamsAndConsume logs and
returns on any initialization error. A transient JetStream/stream/consumer failure permanently
disables consumption because later (re)connections can’t rerun setup.
Agent Prompt
## Issue description
`doSetup()` uses `sync.Once`, but `setupStreamsAndConsume()` returns early on errors. If setup fails once (permissions, JetStream temporarily unavailable, stream creation conflict, etc.), the client will never attempt setup again, leaving the agent connected but non-consuming.

## Issue Context
- The connect handler calls `doSetup()`, but the reconnect handler does not.
- Even if reconnect did call it, `sync.Once` would prevent any retry after the first failure.

## Fix Focus Areas
- internal/messaging/client.go[94-107]
- internal/messaging/client.go[124-131]
- internal/messaging/client.go[133-177]

## Suggested fix
- Replace `sync.Once` with an explicit state machine / mutex-protected flag that only flips to “setup complete” after successful initialization.
- On setup failure, schedule retry with backoff (or re-attempt on subsequent reconnect events).
- If you allow retries, ensure you don’t leak duplicate consumers/ConsumeContexts (stop previous ones before starting new ones).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 53d284a — replaced the sync.Once-guarded doSetup with a retryable attemptSetup, so a transient JetStream/stream/consumer failure no longer permanently disables consumption. Same underlying issue as the resolved thread below on client.go:128.

Comment on lines +9 to +43
var validTopicNameRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)

// TopicNames holds the derived main, retry, and cancel topic names.
type TopicNames struct {
Main string
Retry string
Cancel string
}

// DeriveTopicNames derives main/retry/cancel topic names from the agent name
// and an optional override. If topicNameOverride is non-empty, it takes precedence.
func DeriveTopicNames(agentName, topicNameOverride string) TopicNames {
base := agentName
if topicNameOverride != "" {
base = topicNameOverride
}
return TopicNames{
Main: base,
Retry: base + ".retry",
Cancel: base + ".cancel",
}
}

// ValidateTopicName validates that the given name conforms to NATS subject token rules.
func ValidateTopicName(name string) error {
if name == "" {
return errors.New("topic name must not be empty")
}
if len(name) > 255 {
return errors.New("topic name exceeds 255 characters")
}
if !validTopicNameRe.MatchString(name) {
return errors.New("topic name contains invalid characters (allowed: alphanumeric, hyphens, dots, underscores)")
}
return nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

4. Empty subject tokens allowed 🐞 Bug ≡ Correctness

internal/messaging.ValidateTopicName claims to validate “NATS subject token rules” but only checks a
character whitelist, so it accepts names with empty tokens like ".foo", "foo." or "foo..bar". These
accepted values are then used as NATS/JetStream subjects during stream setup.
Agent Prompt
## Issue description
`ValidateTopicName()` does not actually enforce non-empty dot-separated tokens even though its comment says it validates subject token rules. This allows malformed subjects that are likely to fail later during stream/consumer setup.

## Issue Context
The current validation only checks:
- non-empty string
- length <= 255
- characters in `[A-Za-z0-9._-]`

## Fix Focus Areas
- internal/messaging/topics.go[9-43]

## Suggested fix
- Add token validation, e.g.:
  - reject strings starting/ending with `.`
  - reject `..` anywhere
  - optionally split on `.` and ensure every token is non-empty
- Keep wildcard rejection (already implicit via the regex) as-is.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 781ee13ValidateTopicName now rejects leading/trailing dots and .. (empty dot-separated tokens), covered by UT-MSG-047/048.

@gabriel-farache
gabriel-farache force-pushed the feat/topic7-messaging-integration branch 2 times, most recently from 7b6ba0c to df8e816 Compare July 31, 2026 19:21
@gabriel-farache

Copy link
Copy Markdown
Contributor Author

/agentic_review

Comment on lines +159 to +162
cc, err := cancelCons.Consume(func(msg jetstream.Msg) {
c.handleCancelMessage(msg)
_ = msg.Ack()
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Cancel always acked 🐞 Bug ≡ Correctness

Both cancel-topic and main-topic consumers acknowledge JetStream messages even when CloudEvent
parsing/JSON decoding fails or handlers fail, meaning invalid or transiently failing requests are
permanently dropped and may proceed without deny-list enforcement. This also allows publishing
acknowledgements with an empty resourceId, breaking deny-list matching and preventing retries.
Agent Prompt
## Issue description
JetStream messages for both cancel-topic and main-topic handling are effectively treated as successfully processed even when CloudEvent parsing/JSON decoding fails, `resourceId` is missing/empty, or downstream handlers fail. Because the consumer still `Ack()`s these messages, invalid events and transient errors are not retried, cancels can be dropped without updating the deny-list, and responses can be published with an empty `resourceId`.

## Issue Context
- `handleCancelMessage` can exit early on JSON/CloudEvent parse errors and ignores the return value from `cancelHandler`, but the consumer callback still acknowledges the message.
- `handleMainMessage` ignores the `json.Unmarshal(event.Data(), &payload)` error and then uses `payload.ResourceID` directly (even if empty) to drive deny-list deletion, invoke `mainHandler`, and publish a response, after which the message is Ack()ed.

## Fix Focus Areas
- internal/messaging/client.go[159-162]
- internal/messaging/client.go[232-250]
- internal/messaging/handlers.go[22-41]
- internal/messaging/handlers.go[59-82]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 53d284a — both handleMainMessage and handleCancelMessage now Nak/NakWithDelay on parse or handler failure and only Ack after the handler succeeds, so invalid/failed messages are redelivered instead of silently dropped.

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit df8e816

@gabriel-farache
gabriel-farache force-pushed the feat/topic7-messaging-integration branch 2 times, most recently from 18fcc8c to 9103259 Compare August 4, 2026 08:04
Comment thread internal/messaging/handlers.go
Comment thread internal/messaging/client.go
if cons == nil {
return 0
}
return int64(cons.CachedInfo().NumPending)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NumPending is JetStream's undelivered-backlog count, not the unacknowledged-in-flight count. REQ-DCM-150/AC-DCM-090 define consumerLag as "unacknowledged messages," which maps to NumAckPending, not NumPending — they diverge exactly when the agent has pulled/delivered messages but is slow to process them, which is the backpressure scenario this metric exists to catch. AC-090's integration test only exercises a hand-set stubConsumerLagProvider{lag: 5}, so this mismatch against real JetStream semantics isn't caught by any test. Worth confirming which field DCM's consumer actually wants before this ships.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 21e4af3ConsumerLag now sums NumPending + NumAckPending (not-yet-delivered + delivered-but-unacked), matching REQ-DCM-150's definition of unacknowledged rather than just the undelivered backlog.

conn, err := nats.Connect(c.cfg.URL,
nats.RetryOnFailedConnect(true),
nats.MaxReconnects(-1),
nats.ReconnectWait(2*time.Second),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reconnect wait is a fixed 2s (nats.ReconnectWait(2*time.Second) with MaxReconnects(-1)). REQ-MSG-100 wants the same exponential backoff as REQ-DCM-050: min(initial × 2^attempt, max) with full jitter.

DCM already does this via internal/backoff (CalculateBackoff + ApplyJitter); messaging doesn't. Not saying reconnect is broken - it works - but it doesn't match the agreed policy.

Could wire this through nats.CustomReconnectDelay using those same helpers (same initial/max as DCM, or messaging-specific equivalents if that's preferred).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 53d284a — replaced nats.ReconnectWait(2*time.Second) with nats.CustomReconnectDelay(c.reconnectDelay), using the same internal/backoff exponential-with-full-jitter helpers as REQ-DCM-050 (REQ-MSG-100 explicitly cross-references it).

@gabriel-farache
gabriel-farache force-pushed the feat/topic7-messaging-integration branch 2 times, most recently from 6b068ad to a1886fa Compare August 6, 2026 13:56
@gabriel-farache
gabriel-farache marked this pull request as draft August 7, 2026 08:32
@gabriel-farache
gabriel-farache force-pushed the feat/topic7-messaging-integration branch 2 times, most recently from 227e6bc to d065c08 Compare August 11, 2026 07:32
@gabriel-farache
gabriel-farache marked this pull request as ready for review August 11, 2026 07:47
gabriel-farache and others added 2 commits August 11, 2026 09:48
Add UT-MSG-037–041 (whitelist regex edge cases) to unit test plan and
IT-MSG-071–073 (nested payload, delete response, publish error nak) to
integration test plan. Update traceability matrices accordingly.

Assisted by: Claude Code - claude-4.6-opus

Signed-off-by: gabriel-farache <gfarache@redhat.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Add CloudEvent builder (internal/cloudevent) with FormatSource/FormatType
helpers and NewResponseCE constructor. Implement NATS JetStream messaging
client (internal/messaging) with topic subscription, handler dispatch,
response publishing, and cancel-request deny-list management. Wire
messaging setup into main via setupMessaging helper.

Includes whitelist-based topic validation, typed CE payload extraction,
compile-time interface assertions, and separated handler file. Config
extended with NATS and messaging fields. Full unit and integration test
coverage with test suites.

Assisted by: Claude Code - claude-4.6-opus

Signed-off-by: gabriel-farache <gfarache@redhat.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@gabriel-farache
gabriel-farache force-pushed the feat/topic7-messaging-integration branch from d065c08 to 9d99214 Compare August 11, 2026 07:48
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit d065c08

@gciavarrini gciavarrini left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some lines in the PR description do not match the diff.
FormatType and NewResponseCE are not in internal/cloudevent. DeriveTopicNames builds main, retry, and cancel, not a response topic (responses use the fixed dcm.agents.responses subject).
Prefer a shorter summary that matches what this PR adds

Comment thread internal/messaging/client.go
Comment thread internal/messaging/handlers.go
Comment thread internal/cloudevent/builder.go
Comment thread internal/messaging/client.go
@gabriel-farache

Copy link
Copy Markdown
Contributor Author

#19 is covering all of this and as it's the terminal PR, it has some hardening so closing this PR in favour of the terminal one.
This PR can still be used as history buildup

@gabriel-farache
gabriel-farache deleted the feat/topic7-messaging-integration branch September 3, 2026 09:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants