Skip to content

feat: Plan 8 PR C — decision batch ingest + HTTP flush - #21

Merged
messagesgoel-blip merged 6 commits into
mainfrom
feat/go-edge-hardening-pr-c
Jul 31, 2026
Merged

feat: Plan 8 PR C — decision batch ingest + HTTP flush#21
messagesgoel-blip merged 6 commits into
mainfrom
feat/go-edge-hardening-pr-c

Conversation

@messagesgoel-blip

@messagesgoel-blip messagesgoel-blip commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Completes Plan 8 after PR B (feat: Plan 8 PR B — decision WAL + flush + metrics #20): control-plane POST /v1/decisions/batch writes decision_batches / aggregates / samples with idempotent (edge_node_id, batch_id) + payload_hash conflict handling.
  • Edge FlushWorker uses HTTPFlushTransport when sync/CP is configured (stub otherwise).
  • Integration coverage for accept/idempotent redelivery/409 hash mismatch.

Test plan

  • go test ./internal/edgeverifier/... ./cmd/edge-verifier/...
  • npx tsc --noEmit in control-plane
  • node --test … decision-ingest.test.ts
  • CI Gate / Proto / Go integration / Control plane integration

@coderabbitai review

Summary by CodeRabbit

  • New Features

    • Added decision batch ingestion through an authenticated API endpoint.
    • Added validation, duplicate detection, conflict handling, aggregation, and configurable sampling for decision batches.
    • Edge verifiers can now send decision batches to the control plane over HTTP.
    • Added retry scheduling with exponential backoff and jitter for failed batch uploads.
  • Bug Fixes

    • Improved payload integrity checks and timestamp validation.
    • Added safe handling for duplicate deliveries and failed uploads.

Sanjay and others added 2 commits July 31, 2026 07:30
Add POST /v1/decisions/batch with idempotent (edge_node, batch_id)
delivery into decision_batches/aggregates/samples, wire the edge
FlushWorker to ControlPlaneClient when sync is enabled, and refresh
handover for the dashboard next step.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@cursor

cursor Bot commented Jul 31, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The PR adds authenticated control-plane decision batch ingestion, canonical validation and hashing, aggregate and sample persistence, idempotency handling, and HTTP flushing from the edge verifier with retry backoff.

Changes

Decision ingestion flow

Layer / File(s) Summary
Ingestion contract and persistence
control-plane/src/domains/decision/decisionSample.ts, control-plane/src/domains/decision/decisionIngestService.ts
Defines canonical decision data, validates batches, applies sampling, persists aggregates and samples, and handles duplicates and conflicts transactionally.
Authenticated ingestion API and integration coverage
control-plane/src/routes/decisions.ts, control-plane/src/app.ts, control-plane/src/__tests__/integration/decision-ingest.test.ts
Exposes POST /v1/decisions/batch with API-key authentication and tests validation, sampling, persistence, idempotency, and conflicts.
Canonical edge serialization and HTTP flush transport
internal/edgeverifier/flush.go, internal/edgeverifier/cpclient.go
Serializes canonical batches, posts them to the control plane, and retries failed flushes with jittered exponential backoff.
Runtime wiring and handover updates
cmd/edge-verifier/main.go, docs/superpowers/plans/HANDOVER.md
Passes the control-plane client into decision flushing, selects HTTP or stub transport, and updates handover status.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant EdgeVerifier
  participant ControlPlaneClient
  participant DecisionsRoute
  participant DecisionIngestService
  participant PostgreSQL

  EdgeVerifier->>ControlPlaneClient: Build and flush canonical decision batch
  ControlPlaneClient->>DecisionsRoute: POST /v1/decisions/batch
  DecisionsRoute->>DecisionIngestService: Authenticate and ingest batch
  DecisionIngestService->>PostgreSQL: Write batch, aggregates, and samples
  PostgreSQL-->>DecisionIngestService: Return commit or duplicate outcome
  DecisionIngestService-->>DecisionsRoute: Return ingestion result
  DecisionsRoute-->>ControlPlaneClient: Return HTTP response
Loading

Possibly related PRs

  • Numeracode/verilink#7: Shares edge-verifier startup wiring that this PR extends for decision flushing.
  • Numeracode/verilink#20: Provides the decision WAL and flush infrastructure extended here with control-plane ingestion.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main changes: decision batch ingestion and HTTP-based flushing.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/go-edge-hardening-pr-c

Comment @coderabbitai help to get the list of available commands.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Decision batch ingest endpoint + edge HTTP flush transport

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Add control-plane POST /v1/decisions/batch with idempotent batch semantics and sampling.
• Wire edge FlushWorker to flush decisions over HTTP when control-plane sync is configured.
• Add integration tests covering accept, redelivery idempotency, and hash-mismatch conflicts.
Diagram

graph TD
  WAL["Decision WAL"] --> FW["FlushWorker"] --> HT["HTTPFlushTransport"] --> API["POST /v1/decisions/batch"] --> SVC["ingestDecisionBatch"] --> DB[("Postgres decision_* tables")]
  SVC --> SMP["shouldSample()"] --> DB
  TEST["decision-ingest.test.ts"] --> API

  subgraph Legend
    direction LR
    _svc["Component"] ~~~ _api[/"HTTP endpoint"/] ~~~ _db[("Database")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. DB-enforced idempotency via UPSERT + payload_hash check
  • ➕ Single round-trip for dedupe on (edge_node_id, batch_id)
  • ➕ Eliminates pre-read race and reduces explicit 23505 handling
  • ➖ Harder to express “same key but different payload_hash => 409” cleanly without extra logic
  • ➖ May require schema/constraint adjustments and careful transaction design
2. Server-assigned batch_id (client sends only payload_hash)
  • ➕ Avoids UUID validation and reduces client coupling to batch-id format
  • ➕ Allows server to normalize batching semantics
  • ➖ Makes retries/idempotency trickier unless the server persists a hash index per edge node
  • ➖ Requires changing the edge flush contract and storage expectations
3. Bulk insert for aggregates/samples (set-based SQL)
  • ➕ Much fewer queries per batch; better throughput at higher flush rates
  • ➕ Simplifies transaction time for large batches
  • ➖ More complex SQL; harder to reason about per-decision logic (principal optional, sampling gate)
  • ➖ May reduce readability early in the project lifecycle

Recommendation: Current approach is a good fit for Plan 8: clear request shape validation, explicit idempotency semantics, and a transactional ingest that is easy to reason about. As volume grows, consider migrating the per-decision INSERT loop to set-based/bulk inserts and pushing more idempotency enforcement into a single UPSERT path to reduce race-handling complexity.

Files changed (9) +466 / -18

Enhancement (7) +323 / -10
main.goSelect HTTP vs stub flush transport based on control-plane sync +12/-4

Select HTTP vs stub flush transport based on control-plane sync

• Threads a ControlPlaneClient out of sync startup and uses it to choose an HTTP-based decision flush transport when configured. Logs which flush transport is active and keeps stub behavior when no control plane is available.

cmd/edge-verifier/main.go

app.tsRegister /v1/decisions router +2/-0

Register /v1/decisions router

• Wires the new decisions router into the Express app under /v1/decisions so the batch ingest endpoint is reachable.

control-plane/src/app.ts

decisionIngestService.tsTransactional, idempotent decision batch ingest into batches/aggregates/samples +219/-0

Transactional, idempotent decision batch ingest into batches/aggregates/samples

• Implements request validation, edge-node resolution, and idempotent ingest keyed by (edge_node_id, batch_id) with payload_hash conflict detection. Writes decision_batches, rolls up decision_aggregates across dimensions, and inserts decision_samples using deterministic sampling within a transaction, with 23505 handling for concurrent inserts.

control-plane/src/domains/decision/decisionIngestService.ts

decisionSample.tsDeterministic decision sampling helper +8/-0

Deterministic decision sampling helper

• Introduces a deterministic sampler that always keeps denies and rate-limits allows/passthroughs based on wal_seq bucketing and allow_sample_rate.

control-plane/src/domains/decision/decisionSample.ts

decisions.tsAdd POST /v1/decisions/batch API-key-only route +35/-0

Add POST /v1/decisions/batch API-key-only route

• Creates a decisions router protected by apiKeyOnly and exposes POST /batch to call ingestDecisionBatch. Returns standard ok(...) responses and enforces that the caller is a tenant API key.

control-plane/src/routes/decisions.ts

cpclient.goAdd ControlPlaneClient decision-batch flush + HTTPFlushTransport +41/-0

Add ControlPlaneClient decision-batch flush + HTTPFlushTransport

• Adds a ControlPlaneClient method to POST decision batches to /v1/decisions/batch with auth headers and error truncation. Introduces HTTPFlushTransport implementing FlushTransport using the client.

internal/edgeverifier/cpclient.go

flush.goMake FlushBatch JSON-wire compatible for HTTP transport +6/-6

Make FlushBatch JSON-wire compatible for HTTP transport

• Adds JSON struct tags to FlushBatch fields to match the control-plane wire format. Clarifies the deterministic batch ID as a UUID derived from the payload hash.

internal/edgeverifier/flush.go

Tests (1) +135 / -0
decision-ingest.test.tsIntegration tests for decision batch ingest idempotency and conflicts +135/-0

Integration tests for decision batch ingest idempotency and conflicts

• Adds node:test integration coverage for POST /v1/decisions/batch, including successful acceptance, idempotent redelivery behavior, and 409 on payload_hash mismatch. Also includes a small contract check for the sampling gate behavior.

control-plane/src/tests/integration/decision-ingest.test.ts

Documentation (1) +8 / -8
HANDOVER.mdUpdate Plan 8 handover status for PR C +8/-8

Update Plan 8 handover status for PR C

• Marks Plan 8 PR B as merged, updates the repo head/status text, and notes PR C as in flight toward the dashboard step.

docs/superpowers/plans/HANDOVER.md

@qodo-code-review

qodo-code-review Bot commented Jul 31, 2026

Copy link
Copy Markdown

Code Review by Qodo

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

Context used
✅ Compliance rules (platform): 18 rules

Grey Divider


Action required

1. Unverified payload_hash ✓ Resolved 🐞 Bug ≡ Correctness
Description
ingestDecisionBatch accepts and persists the client-supplied payload_hash without
recomputing/validating it against the decisions payload, so a buggy or malicious client can
permanently poison a (edge_node_id, batch_id) entry and make later legitimate retries conflict (409)
or be treated as duplicates. This breaks the intended idempotency contract and can cause durable
loss of decision telemetry for that batch.
Code

control-plane/src/domains/decision/decisionIngestService.ts[R115-118]

+    if (existing.rows[0].payload_hash === opts.body.payload_hash) {
+      return { duplicate: true, edge_node_id: edgeNodeId, accepted: 0, sampled: 0 };
+    }
+    throw new AppError(CODES.CONFLICT, 'batch_id reused with different payload_hash');
Relevance

●● Moderate

No close precedent; team sometimes adds server-side hashing/idempotency checks, but scope/behavior
change uncertain.

PR-#4

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The control-plane ingest path only checks payload_hash has minimal length and then uses it for
duplicate/conflict checks and inserts; it never recomputes it from decisions. The edge flush
implementation shows payload_hash is intended to be sha256 of the serialized decisions and batch_id
is derived from that hash, and the DB schema comments the same intent but has no constraint
enforcing it.

control-plane/src/domains/decision/decisionIngestService.ts[36-46]
control-plane/src/domains/decision/decisionIngestService.ts[109-138]
internal/edgeverifier/flush.go[182-197]
control-plane/migrations/006_audit/migration.sql[55-67]

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

## Issue description
`ingestDecisionBatch()` trusts `req.body.payload_hash` for idempotency/conflict decisions and stores it as-is, without verifying it corresponds to the submitted `decisions` payload.

## Issue Context
- Edge code derives `payload_hash = sha256(json(decisions))` and derives `batch_id` from that hash; the server currently does not verify that relationship.
- The DB schema documents `payload_hash` as “sha256(batch payload)” but does not enforce it.

## Fix Focus Areas
- control-plane/src/domains/decision/decisionIngestService.ts[36-76]
- control-plane/src/domains/decision/decisionIngestService.ts[109-139]
- internal/edgeverifier/flush.go[182-197]

## Suggested fix
1. Compute `computedPayloadHash` on the control plane from the received decisions using a deterministic serialization contract (ideally shared/canonical across edge+CP).
2. Reject requests where `body.payload_hash !== computedPayloadHash` (400) to prevent poisoning.
3. Optionally compute/validate `batch_id` from the computed hash as well, and reject mismatch (400/409 per desired semantics).
4. Tighten validation: require payload_hash to be exactly 64 hex chars (sha256).

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



Remediation recommended

2. Per-decision query amplification ✓ Resolved 🐞 Bug ➹ Performance
Description
ingestDecisionBatch executes 2 aggregate upserts per decision unconditionally (plus a conditional
principal aggregate and optional sample insert), so a max-size batch of 500 can generate roughly
1,000–2,000 SQL statements in a single request/transaction. This will bottleneck on DB round trips
and connection utilization under sustained edge flush traffic.
Code

control-plane/src/domains/decision/decisionIngestService.ts[R140-168]

+    for (const d of opts.body.decisions) {
+      const bucket = minuteBucket(d.decided_at);
+      // dimension_kind=all
+      await client.query(
+        `INSERT INTO decision_aggregates
+           (tenant_id, edge_node_id, bucket_minute, dimension_kind, dimension_value, action, count)
+         VALUES ($1, $2, $3, 'all', '', $4, 1)
+         ON CONFLICT (tenant_id, edge_node_id, bucket_minute, dimension_kind, dimension_value, action)
+         DO UPDATE SET count = decision_aggregates.count + 1`,
+        [opts.tenantId, edgeNodeId, bucket.toISOString(), d.action]
+      );
+      if (d.principal_id) {
+        await client.query(
+          `INSERT INTO decision_aggregates
+             (tenant_id, edge_node_id, bucket_minute, dimension_kind, dimension_value, action, count)
+           VALUES ($1, $2, $3, 'principal', $4, $5, 1)
+           ON CONFLICT (tenant_id, edge_node_id, bucket_minute, dimension_kind, dimension_value, action)
+           DO UPDATE SET count = decision_aggregates.count + 1`,
+          [opts.tenantId, edgeNodeId, bucket.toISOString(), d.principal_id, d.action]
+        );
+      }
+      await client.query(
+        `INSERT INTO decision_aggregates
+           (tenant_id, edge_node_id, bucket_minute, dimension_kind, dimension_value, action, count)
+         VALUES ($1, $2, $3, 'fingerprint', $4, $5, 1)
+         ON CONFLICT (tenant_id, edge_node_id, bucket_minute, dimension_kind, dimension_value, action)
+         DO UPDATE SET count = decision_aggregates.count + 1`,
+        [opts.tenantId, edgeNodeId, bucket.toISOString(), d.fingerprint, d.action]
+      );
Relevance

●● Moderate

Perf refactors to reduce DB work have been rejected previously; batching redesign here is
larger-scope.

PR-#12

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The ingest implementation runs multiple SQL statements inside a loop over opts.body.decisions, and
the validator explicitly allows batches up to 500 decisions, making high query counts possible by
design.

control-plane/src/domains/decision/decisionIngestService.ts[52-57]
control-plane/src/domains/decision/decisionIngestService.ts[140-191]

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 ingest loop performs multiple `client.query(...)` calls per decision, resulting in a large number of round trips per batch.

## Issue Context
With `decisions.length <= 500`, the current implementation can still issue:
- 2 aggregate upserts per decision minimum (`all` + `fingerprint`) → ~1,000 queries
- +1 principal upsert when `principal_id` is present
- +1 sample insert when sampled
All of this is serial within one transaction.

## Fix Focus Areas
- control-plane/src/domains/decision/decisionIngestService.ts[140-192]
- control-plane/src/domains/decision/decisionIngestService.ts[52-57]

## Suggested fix
1. Pre-aggregate counts in memory keyed by (bucket_minute, dimension_kind, dimension_value, action) and execute set-based upserts using a single `INSERT ... VALUES (...) ... ON CONFLICT DO UPDATE` per dimension_kind (or using `UNNEST`).
2. Insert samples with a single multi-row insert (or `UNNEST`) for all sampled decisions.
3. Keep everything in the same transaction with the `decision_batches` insert.

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


3. Non-strict decided_at validation ✓ Resolved 🐞 Bug ≡ Correctness
Description
assertBatchShape validates decided_at with Date.parse(), which accepts non-RFC3339/ambiguous formats
despite the endpoint claiming it requires an ISO timestamp. For inputs without an explicit timezone
(or other noncanonical formats), minute bucketing can be inconsistent and place aggregates into the
wrong UTC minute bucket.
Code

control-plane/src/domains/decision/decisionIngestService.ts[R73-75]

+    if (!d.decided_at || Number.isNaN(Date.parse(d.decided_at))) {
+      throw new AppError(CODES.BAD_REQUEST, 'decided_at must be an ISO timestamp');
+    }
Relevance

●● Moderate

Strict timestamp-validation hardening has been rejected elsewhere; unclear if they’ll tighten
decided_at parsing now.

PR-#6

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The ingest validator uses Date.parse() as the only gate for “ISO timestamp”, and the bucket
computation then relies on new Date(iso); this combination permits noncanonical/timezone-less
strings that can be interpreted inconsistently.

control-plane/src/domains/decision/decisionIngestService.ts[63-76]
control-plane/src/domains/decision/decisionIngestService.ts[91-95]

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

## Issue description
`decided_at` is validated using `Date.parse()`, which is permissive and can accept ambiguous/non-ISO formats; the code then buckets via `new Date(iso)`.

## Issue Context
For RFC3339 strings with `Z` or an explicit offset, bucketing is fine. The problem is that the current validation claims “must be an ISO timestamp” but actually allows other inputs that can be interpreted unexpectedly.

## Fix Focus Areas
- control-plane/src/domains/decision/decisionIngestService.ts[63-76]
- control-plane/src/domains/decision/decisionIngestService.ts[91-95]

## Suggested fix
1. Replace the `Date.parse()` check with a strict RFC3339/ISO-8601 validator that requires an explicit timezone (`Z` or `±HH:MM`).
2. After regex validation, construct `new Date(decided_at)` and still reject `Invalid Date`.
3. (Optional) Normalize to `t.toISOString()` before inserting/bucketing to avoid mixed representations.

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



Informational

4. Hardcoded API_KEY_HMAC_SECRET value 📘 Rule violation ⛨ Security
Description
The new integration test hardcodes both a Postgres connection string containing credentials and an
API_KEY_HMAC_SECRET fallback value. This can be detected by secret scanners (gitleaks) and risks
committing credentials into source control.
Code

control-plane/src/tests/integration/decision-ingest.test.ts[R4-6]

+process.env.DATABASE_URL ||=
+  'postgresql://verilink:********@127.0.0.1:15432/verilink_test';
+process.env.API_KEY_HMAC_SECRET ||= 'test-hmac-secret-for-integration';
Relevance

● Weak

Close precedent: team rejected removing hardcoded test DSN/HMAC secret despite gitleaks concern.

PR-#7
PR-#16

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2373972 requires no committed secrets/credentials detectable by gitleaks. The added
test file sets process.env.DATABASE_URL to a URL containing verilink:verilink credentials and
sets process.env.API_KEY_HMAC_SECRET to a literal secret value.

Rule 2373972: Source code must contain no secrets or credentials as detected by gitleaks
control-plane/src/tests/integration/decision-ingest.test.ts[4-6]

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

## Issue description
A hardcoded `DATABASE_URL` (with username/password) and `API_KEY_HMAC_SECRET` are committed in a control-plane integration test. This can trigger gitleaks findings and increases the chance of leaking credentials.

## Issue Context
The compliance rule requires the repository to contain no secrets/credentials as detected by gitleaks. Even if these values are intended for local integration tests, they are still committed credentials/secret material.

## Fix Focus Areas
- control-plane/src/__tests__/integration/decision-ingest.test.ts[4-6]

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


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread control-plane/src/domains/decision/decisionIngestService.ts Outdated
Comment thread control-plane/src/domains/decision/decisionIngestService.ts Outdated
Comment thread control-plane/src/domains/decision/decisionIngestService.ts Outdated
Verify payload_hash/batch_id, bulk UNNEST upserts, decided_at sampling,
RFC3339 validation, flush backoff, and canonical decisionWire hashing.

Co-authored-by: Cursor <cursoragent@cursor.com>
@messagesgoel-blip

Copy link
Copy Markdown
Collaborator Author

Addressed open Qodo threads + the pasted human review in 7ec0b5e:

  1. payload_hash verification — recompute + reject mismatch; batch_id must match derived UUID
  2. RFC3339 decided_at — strict timezone required
  3. Query amplification / txn size — bulk UNNEST upserts for aggregates + samples (one statement each)
  4. SamplingshouldSample now keys off decided_at ms (stable across WAL seq resets)
  5. Flush backoff — exponential backoff + jitter (cap 30s) on failed FlushOnce
  6. client.release()pool.connect() completes before try; began gates ROLLBACK (BEGIN failure still releases cleanly)

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

@messagesgoel-blip I will review the updates in commit 7ec0b5e, including the reported validation, ingest, sampling, retry, and transaction-cleanup changes.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Sanjay and others added 3 commits July 31, 2026 08:24
golangci gosec G404 rejects math/rand for the retry jitter path.

Co-authored-by: Cursor <cursoragent@cursor.com>
Use crypto/rand.Int with math/big instead of uint64→int64 casting.

Co-authored-by: Cursor <cursoragent@cursor.com>
Document that FlushBatch hash scope excludes the wire envelope so
maintainers do not accidentally widen the digest.

Co-authored-by: Cursor <cursoragent@cursor.com>
@messagesgoel-blip

Copy link
Copy Markdown
Collaborator Author

Applied the remaining review suggestion in the latest commit: clarified on FlushBatch / MarshalJSON that PayloadHash is over the canonical decisions array only, not the batch envelope. Agree the rest is intentional/correct (jitter bounds exclusive upper, Go/TS field order match, RFC3339+hash checks).

@messagesgoel-blip
messagesgoel-blip merged commit 2728cd5 into main Jul 31, 2026
4 of 5 checks passed
@messagesgoel-blip
messagesgoel-blip deleted the feat/go-edge-hardening-pr-c branch July 31, 2026 13:17

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@control-plane/src/__tests__/integration/decision-ingest.test.ts`:
- Around line 61-77: Extend the decision ingest integration tests around the
existing payload_hash mismatch case to cover the remaining assertBatchShape
branches: submit a buildBatch result with an overridden batch_id and expect 400,
submit more than 500 decisions and expect 400, and submit inconsistent
first_wal_seq/last_wal_seq values and expect 400. Reuse the existing harness,
authHeaders, buildBatch, and response assertion patterns.

In `@control-plane/src/domains/decision/decisionIngestService.ts`:
- Around line 232-277: Update the transaction cleanup around the catch/finally
blocks to track whether ROLLBACK fails, and pass that state to
client.release(true) when it does. Preserve normal release behavior for
successful rollback or non-transaction failures, while ensuring rollback errors
are no longer silently returned as reusable connections.

In `@internal/edgeverifier/cpclient.go`:
- Around line 98-104: Sanitize the response text in the non-OK branch of the
decision-batch request before passing it to truncate and fmt.Errorf. Strip or
escape newline, carriage-return, and other control characters from errBody so
the error returned by the surrounding request function remains safe when logged
by flush handling, while preserving the existing status and truncation behavior.

In `@internal/edgeverifier/flush.go`:
- Around line 139-177: Re-clamp the retry delay after applying jitter in the
FlushOnce error path, using the existing maxBackoff limit so backoff never
exceeds 30 seconds. Keep the current exponential backoff and jitter behavior,
but ensure the final assigned backoff value is bounded by maxBackoff.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 38a2963b-52a9-4826-aa31-b21983fcb49c

📥 Commits

Reviewing files that changed from the base of the PR and between 35125ac and 6ddcdb8.

📒 Files selected for processing (9)
  • cmd/edge-verifier/main.go
  • control-plane/src/__tests__/integration/decision-ingest.test.ts
  • control-plane/src/app.ts
  • control-plane/src/domains/decision/decisionIngestService.ts
  • control-plane/src/domains/decision/decisionSample.ts
  • control-plane/src/routes/decisions.ts
  • docs/superpowers/plans/HANDOVER.md
  • internal/edgeverifier/cpclient.go
  • internal/edgeverifier/flush.go

Comment on lines +61 to +77
it('rejects payload_hash that does not match decisions', async () => {
const decidedAt = new Date().toISOString();
const batch = buildBatch([
{
wal_seq: 1,
fingerprint: 'fp-a',
action: 'deny',
decided_at: decidedAt,
},
]);
const resp = await fetch(`${harness.url}/v1/decisions/batch`, {
method: 'POST',
headers: { ...authHeaders(apiKey), 'Content-Type': 'application/json' },
body: JSON.stringify({ ...batch, payload_hash: '0'.repeat(64) }),
});
assert.equal(resp.status, 400);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the remaining assertBatchShape validation branches.

The current tests cover payload_hash-vs-decisions mismatch, idempotent redelivery, stored-hash conflict, and non-RFC3339 decided_at. They do not cover the batch_id does not match payload_hash branch (send a buildBatch result with batch_id overridden instead of payload_hash), the decisions.length > 500 limit, or the first_wal_seq/last_wal_seq mismatch check. These are new validations called out in the PR comments summary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@control-plane/src/__tests__/integration/decision-ingest.test.ts` around lines
61 - 77, Extend the decision ingest integration tests around the existing
payload_hash mismatch case to cover the remaining assertBatchShape branches:
submit a buildBatch result with an overridden batch_id and expect 400, submit
more than 500 decisions and expect 400, and submit inconsistent
first_wal_seq/last_wal_seq values and expect 400. Reuse the existing harness,
authHeaders, buildBatch, and response assertion patterns.

Comment on lines +232 to +277
const client = await pool.connect();
let began = false;
try {
await client.query('BEGIN');
began = true;
await client.query(
`INSERT INTO decision_batches
(edge_node_id, batch_id, tenant_id, first_wal_seq, last_wal_seq, payload_hash)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
edgeNodeId,
opts.body.batch_id,
opts.tenantId,
opts.body.first_wal_seq,
opts.body.last_wal_seq,
payloadHash,
]
);
await insertAggregates(client, opts.tenantId, edgeNodeId, [...aggMap.values()]);
await insertSamples(client, opts.tenantId, edgeNodeId, samples);
await client.query('COMMIT');
} catch (err) {
if (began) {
try {
await client.query('ROLLBACK');
} catch {
// ignore rollback errors; connection will be discarded by release
}
}
if ((err as { code?: string }).code === '23505') {
const again = await pool.query(
`SELECT payload_hash FROM decision_batches WHERE edge_node_id = $1 AND batch_id = $2`,
[edgeNodeId, opts.body.batch_id]
);
if (
again.rows.length > 0 &&
String(again.rows[0].payload_hash).toLowerCase() === payloadHash
) {
return { duplicate: true, edge_node_id: edgeNodeId, accepted: 0, sampled: 0 };
}
throw new AppError(CODES.CONFLICT, 'batch_id reused with different payload_hash');
}
throw err;
} finally {
client.release();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

cd /srv/storage/repo/VeriLink && find . -name "package.json" -type f | head -5

Repository: Numeracode/verilink

Length of output: 2026


🏁 Script executed:

cd /srv/storage/repo/VeriLink && cat package.json | grep -A2 -B2 '"pg"'

Repository: Numeracode/verilink

Length of output: 2026


🏁 Script executed:

cd /srv/storage/repo/VeriLink && rg "client\.release\(" --type=typescript --type=javascript -B2 -A2 | head -80

Repository: Numeracode/verilink

Length of output: 2026


🌐 Web query:

node-postgres pg client.release(err) error handling discard connection

💡 Result:

In node-postgres, the client.release(err) method is the standard way to return an acquired client to the pool [1][2]. The err argument serves as a flag to control whether the client should be returned to the pool for reuse or destroyed [2]. When you call client.release(err): 1. If err is truthy (e.g., an error object or true), the pool is instructed to discard the client (close the connection) instead of returning it to the pool for future use [2]. This is useful when you have encountered a fatal error on the connection and want to ensure the pool replaces it with a fresh, clean client [2]. 2. If no argument (or a falsy value) is passed, the client is returned to the pool as an idle, available connection [2]. Important Considerations: - You must always release the client after it is acquired via pool.connect to avoid connection leaks [1][3][2]. - To handle errors correctly without leaks, ensure your code paths always call release [3][2]. A common pattern using try/finally ensures the client is released regardless of whether the query succeeded or failed: javascript const client = await pool.connect(); try { await client.query('SELECT...'); } catch (err) { // Pass error to release to discard the potentially corrupted connection client.release(err); throw err; // Re-throw if necessary } finally { // If the client was already released in catch, this is a no-op // (though it is cleaner to ensure it only happens once) client.release(); } - Be aware that calling release twice can cause issues depending on your implementation; it is best practice to ensure your logic calls release exactly once [4]. - Do not use client.end on a client acquired from a pool, as it will close the connection and potentially hang your process or prevent it from returning to the pool correctly [5]. Only use client.release for pooled clients [5].

Citations:


🌐 Web query:

node-postgres PoolClient release() method semantics aborted transaction

💡 Result:

In node-postgres, the poolClient.release method does not automatically rollback or commit open transactions [1]. When you call release, the client is returned to the pool in whatever state it was left in [1]. If you leave a transaction open (e.g., you issued BEGIN but did not issue a COMMIT or ROLLBACK) and then release the client, the transaction remains open [1]. Because the client is returned to the pool, the next piece of code that acquires this same client will inherit the existing, uncommitted transaction state [1]. This can lead to unexpected behavior, such as subsequent queries accidentally becoming part of a previous transaction or failing due to state conflicts [2][1]. To safely handle transactions: 1. Always implement an explicit ROLLBACK in your error handling (catch block) [3][1]. 2. Always ensure the client is returned to the pool in the finally block, regardless of success or failure [3][1]. 3. If an error occurs during your ROLLBACK attempt, you should treat the client as broken and pass a truthy value to release(true) (or destroy it) to ensure it is not reused by the pool [4][3][5]. Note: Recent developments in node-postgres (as of 2026) have introduced an optional pool configuration setting evictOnOpenTransaction. If enabled, this allows the pool to automatically evict (close) connections released while they have an active, uncommitted transaction [6]. However, this is not the default behavior and must be explicitly configured [6].

Citations:


Pass the rollback error to client.release() to discard poisoned connections.

If ROLLBACK throws in the catch block, the error is caught and discarded, then client.release() is called with no argument. When ROLLBACK fails, the database session can remain in an aborted-transaction state. Calling release() without an argument returns this poisoned connection to the pool. The next code that acquires this connection inherits the aborted-transaction state and fails on every query, cascading the failure to unrelated requests.

Track the rollback failure with a boolean flag and pass it to release(true). The pool will then discard the connection instead of reusing it.

🔧 Proposed fix
   const client = await pool.connect();
   let began = false;
+  let rollbackFailed = false;
   try {
     await client.query('BEGIN');
     began = true;
     ...
     await client.query('COMMIT');
   } catch (err) {
     if (began) {
       try {
         await client.query('ROLLBACK');
       } catch {
-        // ignore rollback errors; connection will be discarded by release
+        rollbackFailed = true;
       }
     }
     ...
     throw err;
   } finally {
-    client.release();
+    client.release(rollbackFailed);
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const client = await pool.connect();
let began = false;
try {
await client.query('BEGIN');
began = true;
await client.query(
`INSERT INTO decision_batches
(edge_node_id, batch_id, tenant_id, first_wal_seq, last_wal_seq, payload_hash)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
edgeNodeId,
opts.body.batch_id,
opts.tenantId,
opts.body.first_wal_seq,
opts.body.last_wal_seq,
payloadHash,
]
);
await insertAggregates(client, opts.tenantId, edgeNodeId, [...aggMap.values()]);
await insertSamples(client, opts.tenantId, edgeNodeId, samples);
await client.query('COMMIT');
} catch (err) {
if (began) {
try {
await client.query('ROLLBACK');
} catch {
// ignore rollback errors; connection will be discarded by release
}
}
if ((err as { code?: string }).code === '23505') {
const again = await pool.query(
`SELECT payload_hash FROM decision_batches WHERE edge_node_id = $1 AND batch_id = $2`,
[edgeNodeId, opts.body.batch_id]
);
if (
again.rows.length > 0 &&
String(again.rows[0].payload_hash).toLowerCase() === payloadHash
) {
return { duplicate: true, edge_node_id: edgeNodeId, accepted: 0, sampled: 0 };
}
throw new AppError(CODES.CONFLICT, 'batch_id reused with different payload_hash');
}
throw err;
} finally {
client.release();
}
const client = await pool.connect();
let began = false;
let rollbackFailed = false;
try {
await client.query('BEGIN');
began = true;
await client.query(
`INSERT INTO decision_batches
(edge_node_id, batch_id, tenant_id, first_wal_seq, last_wal_seq, payload_hash)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
edgeNodeId,
opts.body.batch_id,
opts.tenantId,
opts.body.first_wal_seq,
opts.body.last_wal_seq,
payloadHash,
]
);
await insertAggregates(client, opts.tenantId, edgeNodeId, [...aggMap.values()]);
await insertSamples(client, opts.tenantId, edgeNodeId, samples);
await client.query('COMMIT');
} catch (err) {
if (began) {
try {
await client.query('ROLLBACK');
} catch {
rollbackFailed = true;
}
}
if ((err as { code?: string }).code === '23505') {
const again = await pool.query(
`SELECT payload_hash FROM decision_batches WHERE edge_node_id = $1 AND batch_id = $2`,
[edgeNodeId, opts.body.batch_id]
);
if (
again.rows.length > 0 &&
String(again.rows[0].payload_hash).toLowerCase() === payloadHash
) {
return { duplicate: true, edge_node_id: edgeNodeId, accepted: 0, sampled: 0 };
}
throw new AppError(CODES.CONFLICT, 'batch_id reused with different payload_hash');
}
throw err;
} finally {
client.release(rollbackFailed);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@control-plane/src/domains/decision/decisionIngestService.ts` around lines 232
- 277, Update the transaction cleanup around the catch/finally blocks to track
whether ROLLBACK fails, and pass that state to client.release(true) when it
does. Preserve normal release behavior for successful rollback or
non-transaction failures, while ensuring rollback errors are no longer silently
returned as reusable connections.

Comment on lines +98 to +104
if resp.StatusCode == http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
return nil
}
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("decision batch: status %d: %s", resp.StatusCode, truncate(string(errBody), 200))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Strip control characters from the response body before embedding it in the error/log chain.

errBody is raw content from the control-plane's HTTP response and is embedded verbatim (truncated to 200 bytes) into the returned error. That error later reaches w.logf("flush failed: %v", err) in internal/edgeverifier/flush.go (line 230), which writes it straight to the log via Printf — flagged by static analysis for log injection (CWE-117). If the response body contains newlines or control characters, an attacker who can influence that response (e.g. a compromised control-plane or a misbehaving intermediary) can forge or split log lines. Strip or escape control characters (at least \n/\r) from errBody before it becomes part of the error message.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/edgeverifier/cpclient.go` around lines 98 - 104, Sanitize the
response text in the non-OK branch of the decision-batch request before passing
it to truncate and fmt.Errorf. Strip or escape newline, carriage-return, and
other control characters from errBody so the error returned by the surrounding
request function remains safe when logged by flush handling, while preserving
the existing status and truncation behavior.

Source: Linters/SAST tools

Comment on lines +139 to +177
backoff := time.Duration(0)
for {
wait := interval
if backoff > 0 {
wait = backoff
}
timer := time.NewTimer(wait)
select {
case <-ctx.Done():
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
flushCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
_ = w.FlushAll(flushCtx)
cancel()
return
case <-ticker.C:
case <-timer.C:
flushCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
_ = w.FlushOnce(flushCtx)
err := w.FlushOnce(flushCtx)
cancel()
if err != nil {
if backoff == 0 {
backoff = interval
} else {
backoff *= 2
}
const maxBackoff = 30 * time.Second
if backoff > maxBackoff {
backoff = maxBackoff
}
// ±20% jitter
j := time.Duration(randInt63n(int64(backoff/5) + 1))
backoff = backoff - backoff/10 + j
} else {
backoff = 0
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Re-clamp backoff after applying jitter.

maxBackoff clamps backoff before the jitter adjustment (backoff - backoff/10 + j), but that adjustment can add back up to 10% on top of the already-clamped value. The next wait can then reach roughly 33 seconds, exceeding the documented 30-second cap stated in the comment above Run and in the PR description ("capped at 30 seconds"). Re-clamp after computing jitter.

🔧 Proposed fix
 				// ±20% jitter
 				j := time.Duration(randInt63n(int64(backoff/5) + 1))
 				backoff = backoff - backoff/10 + j
+				if backoff > maxBackoff {
+					backoff = maxBackoff
+				}
 			} else {
 				backoff = 0
 			}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
backoff := time.Duration(0)
for {
wait := interval
if backoff > 0 {
wait = backoff
}
timer := time.NewTimer(wait)
select {
case <-ctx.Done():
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
flushCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
_ = w.FlushAll(flushCtx)
cancel()
return
case <-ticker.C:
case <-timer.C:
flushCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
_ = w.FlushOnce(flushCtx)
err := w.FlushOnce(flushCtx)
cancel()
if err != nil {
if backoff == 0 {
backoff = interval
} else {
backoff *= 2
}
const maxBackoff = 30 * time.Second
if backoff > maxBackoff {
backoff = maxBackoff
}
// ±20% jitter
j := time.Duration(randInt63n(int64(backoff/5) + 1))
backoff = backoff - backoff/10 + j
} else {
backoff = 0
}
backoff := time.Duration(0)
for {
wait := interval
if backoff > 0 {
wait = backoff
}
timer := time.NewTimer(wait)
select {
case <-ctx.Done():
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
flushCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
_ = w.FlushAll(flushCtx)
cancel()
return
case <-timer.C:
flushCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
err := w.FlushOnce(flushCtx)
cancel()
if err != nil {
if backoff == 0 {
backoff = interval
} else {
backoff *= 2
}
const maxBackoff = 30 * time.Second
if backoff > maxBackoff {
backoff = maxBackoff
}
// ±20% jitter
j := time.Duration(randInt63n(int64(backoff/5) + 1))
backoff = backoff - backoff/10 + j
if backoff > maxBackoff {
backoff = maxBackoff
}
} else {
backoff = 0
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/edgeverifier/flush.go` around lines 139 - 177, Re-clamp the retry
delay after applying jitter in the FlushOnce error path, using the existing
maxBackoff limit so backoff never exceeds 30 seconds. Keep the current
exponential backoff and jitter behavior, but ensure the final assigned backoff
value is bounded by maxBackoff.

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.

1 participant