Skip to content

feat: Plan 8 PR B — decision WAL + flush + metrics - #20

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

feat: Plan 8 PR B — decision WAL + flush + metrics#20
messagesgoel-blip merged 7 commits into
mainfrom
feat/go-edge-hardening-pr-b

Conversation

@messagesgoel-blip

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

Copy link
Copy Markdown
Collaborator

Summary

  • Bounded local decision WAL (wal.go) with drop-oldest + decisions_dropped_total, enterprise no-drop, optional disk persist (VERILINK_WAL_PATH)
  • Flush worker + FlushTransport (stub until CP decision ingest / PR C)
  • Proxy records allow/deny/passthrough decisions; sync runner tracks sync_reconnects_total{reason}
  • Metrics bag: wal_bytes, decisions_dropped_total, snapshot_high_water, sse_bytes_age, reconnect reasons
  • Flags/env: VERILINK_WAL_PATH, VERILINK_WAL_MAX_BYTES, VERILINK_NO_DROP_DECISIONS
  • HANDOVER + shared memory updated for PR B in flight

Test plan

  • go test ./internal/edgeverifier/... ./cmd/edge-verifier/...
  • WAL drop-oldest / no-drop / flush ack / disk round-trip unit tests
  • CI Gate / Proto / integration green

@coderabbitai review

Summary by CodeRabbit

  • New Features

    • Added reliable decision recording with configurable storage limits and recovery across restarts.
    • Added automatic batching and delivery of recorded decisions, including periodic and shutdown-triggered flushing.
    • Added operational metrics for synchronization, storage usage, dropped decisions, and reconnect activity.
    • Added policy controls to prevent decision loss when storage reaches capacity.
    • Improved startup and shutdown coordination across synchronization, proxying, metrics, and decision delivery.
  • Documentation

    • Updated handover documentation with current plan and implementation status.

Add bounded local decision WAL (drop-oldest / no-drop), flush worker
with stub transport, proxy decision recording, and sync reconnect metrics.

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 30, 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 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 2 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9237ab5e-8524-4ee5-b467-c59753f88f85

📥 Commits

Reviewing files that changed from the base of the PR and between dc69d49 and cd0a369.

📒 Files selected for processing (3)
  • cmd/edge-verifier/main.go
  • internal/edgeverifier/wal.go
  • internal/edgeverifier/wal_test.go

Walkthrough

The edge verifier now persists request decisions in a bounded WAL, flushes decision batches, records synchronization metrics, and wires these components into startup and shutdown. Proxy decisions include trust details, while SSE reconnects receive categorized metrics.

Changes

Decision WAL and edge verifier observability

Layer / File(s) Summary
Persistent decision WAL
internal/edgeverifier/wal.go, internal/edgeverifier/wal_test.go
Adds JSONL persistence, recovery, acknowledgements, compaction, capacity limits, no-drop behavior, WAL sizing, and validation tests.
Decision batch flushing
internal/edgeverifier/flush.go, internal/edgeverifier/wal_test.go
Adds periodic and shutdown flushing. Successful transports acknowledge WAL sequences. Batches include deterministic metadata and payload hashes.
Proxy decisions and synchronization metrics
internal/edgeverifier/proxy.go, internal/edgeverifier/policy.go, internal/edgeverifier/metrics.go, internal/edgeverifier/sse.go, internal/edgeverifier/wal_test.go
The proxy records request outcomes and trust data. Metrics track WAL and synchronization state. SSE reconnects are classified, and policy changes update WAL settings.
Runtime startup and shutdown
cmd/edge-verifier/main.go, docs/superpowers/plans/HANDOVER.md
Startup configures the WAL, metrics, synchronization, proxy, workers, servers, and coordinated shutdown. The handover records Plan 8 progress.

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

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant EdgeVerifierProxy
  participant DecisionWAL
  participant FlushWorker
  participant FlushTransport
  Client->>EdgeVerifierProxy: Send request
  EdgeVerifierProxy->>DecisionWAL: Append decision
  FlushWorker->>DecisionWAL: Read pending decisions
  FlushWorker->>FlushTransport: Flush decision batch
  FlushTransport-->>FlushWorker: Return result
  FlushWorker->>DecisionWAL: Acknowledge delivered decisions
Loading

Possibly related PRs

  • Numeracode/verilink#1: Adds benchmark coverage for the modified EdgeVerifierProxy.ServeHTTP path.
  • Numeracode/verilink#14: Adds the SSE edge-sync path extended here with reconnect classification, metrics, and WAL synchronization.
  • Numeracode/verilink#18: Adds related synchronization and proxy code extended here with WAL, metrics, and decision flushing.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% 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 Plan 8 PR B and its main changes: decision WAL, flush processing, and metrics.
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-b

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

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Add bounded decision WAL, stub flush worker, and edge sync/WAL metrics

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

Grey Divider

AI Description

• Add a bounded decision WAL with drop-oldest or no-drop behavior and optional disk persistence.
• Introduce a periodic flush worker + transport interface (stubbed until CP ingest ships).
• Record allow/deny/passthrough outcomes and add edge sync/WAL metrics + new flags/env vars.
Diagram

graph TD
  main["cmd/edge-verifier"] --> proxy["EdgeVerifierProxy"] --> wal["DecisionWAL"] --> flush["FlushWorker"]
  proxy --> store["Snapshot store"] --> sync["SyncRunner (SSE)"] --> cp{{"Control plane"}}
  wal --> walDisk[("WAL file")]
  main --> metrics["Metrics bag"]
  proxy --> metrics
  sync --> metrics
  wal --> metrics
  subgraph Legend
    direction LR
    _svc["Component"] ~~~ _db[("On-disk state")] ~~~ _ext{{"External"}}
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Append-only JSONL WAL instead of rewriting an envelope file
  • ➕ O(1) disk writes per append (no full-file rewrite)
  • ➕ Easier to stream/rotate and recover incrementally
  • ➕ More scalable if decision volume grows
  • ➖ Requires compaction/truncation logic when AckThrough advances
  • ➖ Slightly more complex crash-consistency story (need fsync discipline)
2. Use an embedded KV store (BoltDB/Badger/SQLite) for WAL + ack cursor
  • ➕ Efficient bounded storage with random access and iteration
  • ➕ Natural place to store ack cursor / batch metadata atomically
  • ➕ Better durability semantics for high-throughput workloads
  • ➖ Adds dependency/operational complexity
  • ➖ May be overkill for Plan 8’s initial requirements
3. Plumb flush directly into SyncRunner with a real CP ingest client (skip StubTransport)
  • ➕ Reduces moving parts once CP ingest exists
  • ➕ Single place to manage backoff/retry/metrics
  • ➖ Blocked on PR C / server API availability
  • ➖ Harder to unit test in isolation vs transport interface

Recommendation: For Plan 8 PR B, the current design (bounded in-memory WAL with optional disk persistence + flush interface) is a reasonable incremental step, especially with unit tests around drop/no-drop and ack behavior. The main concern is disk persistence rewriting a full JSON envelope on each append; if decision volume increases, consider switching to an append-only log (with compaction) or an embedded KV store before wiring in real control-plane ingest.

Files changed (8) +1032 / -105

Enhancement (6) +894 / -99
main.goWire decision WAL, flush worker, metrics, and new flags/env parsing +174/-93

Wire decision WAL, flush worker, metrics, and new flags/env parsing

• Refactors startup into a typed config + helpers, adds WAL enablement (path/max bytes/no-drop), and starts a flush worker when WAL is active. Sync runner creation now attaches metrics and can update WAL no-drop policy from synced policy; adds graceful shutdown for sync and flush.

cmd/edge-verifier/main.go

flush.goAdd flush worker and transport abstraction (stubbed) +169/-0

Add flush worker and transport abstraction (stubbed)

• Introduces FlushTransport, FlushBatch (idempotent metadata + hash), and a FlushWorker that periodically drains WAL.Pending() and WAL.AckThrough() on successful delivery. Provides a StubTransport that logs and succeeds until control-plane ingest is implemented.

internal/edgeverifier/flush.go

metrics.goAdd minimal process-local metrics bag for WAL and sync observability +73/-0

Add minimal process-local metrics bag for WAL and sync observability

• Implements atomic counters/gauges for decisions dropped, WAL bytes, snapshot high-water, and SSE bytes age, plus reconnect counters keyed by reason. Exposes ObserveStore() and accessors for use in proxy/sync runner.

internal/edgeverifier/metrics.go

proxy.goRecord trust decisions to WAL and refresh gauges per request +91/-6

Record trust decisions to WAL and refresh gauges per request

• Extends proxy construction to accept optional WAL + metrics and records allow/deny/passthrough outcomes (including invalid signatures and replay) with a generated request fingerprint. annotateTrust now returns principal/score/blacklist info for richer decision records and can update WAL no-drop policy from active synced policy.

internal/edgeverifier/proxy.go

sse.goTrack sync reconnect metrics with coarse reason categorization +34/-0

Track sync reconnect metrics with coarse reason categorization

• Adds optional Metrics to SyncRunner, updates Run() to observe store gauges and increment reconnect counters after session termination. Introduces reconnectReason() to bucket common failure modes (shutdown/backlog/gone/unavailable/canceled/error).

internal/edgeverifier/sse.go

wal.goImplement bounded decision WAL with optional disk persistence and acking +353/-0

Implement bounded decision WAL with optional disk persistence and acking

• Adds Decision and DecisionWAL with max-bytes enforcement, drop-oldest (counted) vs no-drop (ErrWALFull), Pending() for batching, and AckThrough() to truncate acknowledged entries. Supports optional persistence via an on-disk JSON envelope with schema versioning and fsync+rename semantics; includes helpers for enterprise no-drop sizing.

internal/edgeverifier/wal.go

Tests (1) +131 / -0
wal_test.goAdd unit tests for WAL eviction/no-drop, flush ack, and disk round-trip +131/-0

Add unit tests for WAL eviction/no-drop, flush ack, and disk round-trip

• Covers drop-oldest behavior incrementing DecisionsDropped, ErrWALFull under no-drop, flush batching/ack draining via a test transport, and persistence reload correctness. Also validates sizing helper behavior above/below the 8GiB floor.

internal/edgeverifier/wal_test.go

Documentation (1) +7 / -6
HANDOVER.mdUpdate Plan 8 handover status to PR B in progress +7/-6

Update Plan 8 handover status to PR B in progress

• Marks Plan 8 PR A as merged and documents PR B scope (WAL + flush stub + metrics), plus notes a potential PR C for CP ingest.

docs/superpowers/plans/HANDOVER.md

@qodo-code-review

qodo-code-review Bot commented Jul 30, 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. No-drop override lost ✓ Resolved 🐞 Bug ≡ Correctness
Description
EdgeVerifierProxy.recordDecision updates the WAL no-drop mode from tenant policy on every request,
which can disable an operator-provided -no-drop-decisions/VERILINK_NO_DROP_DECISIONS override after
policy is available. This can unexpectedly switch the WAL back to drop-oldest semantics under
pressure and lose decisions despite the explicit local override.
Code

internal/edgeverifier/proxy.go[R256-259]

+	if pol := p.snapshot; pol != nil {
+		if active := pol.ActivePolicy(); active != nil {
+			p.wal.SetNoDrop(active.NoDropDecisions)
+		}
Relevance

●●● Strong

Correctness bug: per-request SetNoDrop can clobber operator override; fix is localized and low risk.

PR-#18

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Bootstrap explicitly ORs tenant policy with the local flag, but later per-request logic overwrites
the setting using policy-only, so a true local override can be turned off after the first recorded
decision once policy is present.

cmd/edge-verifier/main.go[201-203]
internal/edgeverifier/proxy.go[239-260]

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

## Issue description
`recordDecision()` calls `p.wal.SetNoDrop(active.NoDropDecisions)` which can overwrite and disable a locally forced no-drop setting (`-no-drop-decisions` / `VERILINK_NO_DROP_DECISIONS`). This breaks configuration precedence and can re-enable decision dropping.

## Issue Context
- Startup/bootstrap applies `policy || cfg.noDropDecisions`, implying local config should remain effective.
- Later, per-request policy refresh overwrites WAL mode with policy-only.

## Fix Focus Areas
- internal/edgeverifier/proxy.go[256-259]
- internal/edgeverifier/wal.go[87-105]
- cmd/edge-verifier/main.go[201-203]

## Implementation guidance
- Make the local override sticky by construction. Options:
 1) Add a `forcedNoDrop` (or similar) field to `DecisionWAL` initialized from `WALConfig.NoDrop`, and change `SetNoDrop(v)` to set `w.noDrop = w.forcedNoDrop || v`.
 2) Alternatively, keep `DecisionWAL` as-is but store `cfg.noDropDecisions` in the proxy and call `SetNoDrop(active.NoDropDecisions || cfg.noDropDecisions)`.
- Add/adjust a unit test proving that policy `NoDropDecisions=false` cannot disable a locally forced `NoDrop=true`.

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


2. WAL persistence is O(N) ✓ Resolved 🐞 Bug ☼ Reliability
Description
DecisionWAL.persistLocked rewrites the full WAL as a single JSON blob (copy+marshal+fsync+rename+dir
sync) on every Append/AckThrough while holding the WAL mutex, and DecisionWAL.load reads the entire
file into memory via os.ReadFile. Persistence cost scales with current WAL size and can severely
stall request handling and/or OOM as the WAL grows, especially with the 8GiB default no-drop
capacity.
Code

internal/edgeverifier/wal.go[R257-330]

+func (w *DecisionWAL) load() error {
+	data, err := os.ReadFile(w.path)
+	if err != nil {
+		return err
+	}
+	var env walDiskEnvelope
+	if err := json.Unmarshal(data, &env); err != nil {
+		return fmt.Errorf("wal load: %w", err)
+	}
+	if env.SchemaVersion != walSchemaVersion {
+		return fmt.Errorf("unsupported wal schema_version %d", env.SchemaVersion)
+	}
+	w.entries = w.entries[:0]
+	w.bytes = 0
+	w.nextSeq = env.NextSeq
+	if w.nextSeq <= 0 {
+		w.nextSeq = 1
+	}
+	for _, d := range env.Decisions {
+		size := estimateDecisionBytes(d)
+		w.entries = append(w.entries, walEntry{rec: d, size: size})
+		w.bytes += size
+		if d.WalSeq >= w.nextSeq {
+			w.nextSeq = d.WalSeq + 1
+		}
+	}
+	return nil
+}
+
+func (w *DecisionWAL) persistLocked() error {
+	if w.path == "" {
+		return nil
+	}
+	env := walDiskEnvelope{
+		SchemaVersion: walSchemaVersion,
+		NextSeq:       w.nextSeq,
+		Decisions:     make([]Decision, len(w.entries)),
+	}
+	for i, e := range w.entries {
+		env.Decisions[i] = e.rec
+	}
+	data, err := json.Marshal(env)
+	if err != nil {
+		return err
+	}
+	dir := filepath.Dir(w.path)
+	if err := os.MkdirAll(dir, 0o700); err != nil {
+		return err
+	}
+	tmp := w.path + ".tmp"
+	f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
+	if err != nil {
+		return err
+	}
+	if _, err := f.Write(data); err != nil {
+		_ = f.Close()
+		_ = os.Remove(tmp)
+		return err
+	}
+	if err := f.Sync(); err != nil {
+		_ = f.Close()
+		_ = os.Remove(tmp)
+		return err
+	}
+	if err := f.Close(); err != nil {
+		_ = os.Remove(tmp)
+		return err
+	}
+	if err := os.Rename(tmp, w.path); err != nil {
+		_ = os.Remove(tmp)
+		return err
+	}
+	return syncDir(dir)
+}
Relevance

●● Moderate

Large refactor/perf concern; no clear precedent on WAL persistence strategy, could be deferred for
later PR.

PR-#18

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The WAL is configured to allow up to 8GiB in no-drop mode, yet disk load reads the whole file and
persist rewrites/marshals the full decisions slice on every append under lock; this makes
CPU/IO/memory scale with WAL size and blocks concurrent appends and flushes.

internal/edgeverifier/wal.go[13-18]
internal/edgeverifier/wal.go[135-175]
internal/edgeverifier/wal.go[257-330]

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 disk WAL implementation is synchronous and full-rewrite:
- `Append()` calls `persistLocked()` under the WAL mutex.
- `persistLocked()` copies all entries, `json.Marshal`s the entire envelope, writes+fsyncs it, renames, then syncs the directory.
- `load()` uses `os.ReadFile()` then `json.Unmarshal()` on the full blob.
This makes runtime and memory proportional to the current WAL size, which becomes dangerous as WAL grows (notably with the 8GiB no-drop default).

## Issue Context
This WAL runs on the proxy request path (`recordDecision()` -> `wal.Append()`), so persistence latency directly impacts request latency and concurrency.

## Fix Focus Areas
- internal/edgeverifier/wal.go[135-175]
- internal/edgeverifier/wal.go[257-330]

## Implementation guidance
- Change the on-disk representation to an incremental format (e.g., JSONL append-only, or segmented log files).
 - On `Append()`: write only the new record (buffered), not the entire WAL.
 - On `AckThrough()`/drop-oldest: record an ack watermark and compact/truncate periodically (or rotate segments) instead of rewriting on every mutation.
- Make `load()` streaming (e.g., `bufio.Scanner` with explicit size limits, or `json.Decoder`) to avoid `os.ReadFile`-sized allocations.
- Move fsync-heavy durability to a cadence (timer / batch / flush-ack) rather than every request append.
- Add a stress/unit test that appends many decisions with `Path` enabled and asserts append latency doesn’t scale linearly with WAL length and that load does not allocate the full file at once.

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



Informational

3. Outbound proxy requests unsigned 📘 Rule violation ⛨ Security
Description
EdgeVerifierProxy forwards requests to the backend via httputil.ReverseProxy without adding RFC
9421 Signature-Input/Signature headers. This violates the requirement to apply RFC 9421 HTTP
Message Signatures to reverse-proxied outbound requests.
Code

internal/edgeverifier/proxy.go[R169-179]

		log.Printf("SIGNED: method=%s uri=%s", r.Method, r.URL)
		w.Header().Set("X-Verilink-Auth-Status", "signed-verified")
+		p.recordDecision(r, Decision{
+			PrincipalID: principalID,
+			Score:       score,
+			Blacklisted: blacklisted,
+			ScoreReason: reason,
+			Action:      "allow",
+		})
		p.proxy.ServeHTTP(w, r)
		return
Relevance

● Weak

Outbound RFC 9421 signing for ReverseProxy was previously requested and explicitly rejected in
edgeverifier proxy review.

PR-#18

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2383208 requires RFC 9421 HTTP Message Signatures on outbound reverse-proxy
requests. The proxy currently forwards using p.proxy.ServeHTTP(w, r) without adding any RFC 9421
signature headers to the upstream request, and the only Signature-Input usage is reading inbound
headers for verification.

Rule 2383208: Require RFC 9421 HTTP Message Signatures in edge-verifier reverse proxy requests
internal/edgeverifier/proxy.go[66-78]
internal/edgeverifier/proxy.go[99-195]

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

## Issue description
Outbound reverse-proxy requests are forwarded to the backend without RFC 9421 HTTP Message Signatures.

## Issue Context
The compliance rule requires adding RFC 9421-compliant `Signature-Input` and `Signature` headers to outbound requests generated by the edge-verifier reverse proxy.

## Fix Focus Areas
- internal/edgeverifier/proxy.go[49-79]
- internal/edgeverifier/proxy.go[169-195]

ⓘ 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 internal/edgeverifier/proxy.go Outdated
Comment thread internal/edgeverifier/wal.go Outdated

@messagesgoel-blip messagesgoel-blip left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Review: PR #20 — Plan 8 PR B: decision WAL + flush + metrics

Well-structured PR. 1032 additions implementing the bounded decision WAL, flush worker with stub transport, and edge metrics. The modularization of main.go into parseFlags/bootstrapDemoIdentity/mustOpenWAL/mustStartSync/startMockBackend is a big improvement.

What is done well

Area Notes
WAL design Bounded, append-only, drop-oldest vs no-drop, monotonic wal_seq, disk round-trip with schema versioning
Crash-safe disk tmp → fsync → rename → dir sync (consistent with snapshot persistence)
Flush batching FlushBatch with BatchID (UUIDv4), seq range, and sha256 payload hash → idempotent retries
Proxy decision recording Records allow/deny/passthrough on all paths (invalid-sig, replay, denied, unsigned)
Metrics Reconnect counters by reason, WAL bytes, decisions dropped, snapshot HW, SSE age
No-drop sizing SizedNoDropWALMaxBytes (8GiB floor, 1.5× outage buffer) is well thought out
Tests 4 focused tests covering drop-oldest, no-drop, flush ack + disk round-trip, sizing

Issues

1. persistLocked() on EVERY Append — performance (High)

w.entries = append(w.entries, walEntry{rec: d, size: size})
w.bytes += size
w.publishBytesLocked()
return w.persistLocked()  // full JSON serialize + fsync + rename + dir sync

Every single decision triggers a full WAL serialize to disk, f.Sync(), rename, and dir sync. At even 100 req/s through the proxy, this is 100 full-file fsyncs/sec — likely the hottest syscall path in the whole binary. The crash-safety guarantee of per-record fsync is usually unnecessary for a decision log (decisions are re-collectable; only durability of the ack matters for correctness).

Fix: Persist on a cadence (e.g., every N records or T seconds via the flush worker), or use an append-only JSONL file with periodic fsync. Keep the atomic tmp→rename pattern for the final coalesced write.

2. FlushOnce has no context deadline (Medium)

case <-ticker.C:
    _ = w.FlushOnce(ctx)

If a future real transport hangs, FlushOnce blocks and the worker can never observe ctx.Done() — graceful shutdown hangs until the process watchdog kills it. The final-flush path correctly uses a 5s timeout; the periodic path should too. A context.WithTimeout inside FlushOnce (or in Run) would close this.

3. reconnectReason relies on string matching (Minor)

case strings.Contains(msg, "recovered from 429"):
    return "backlog"

Fragile. Prefer sentinel errors (e.g., errBacklog, errGone, errShutdown) wrapped in the returned error so errors.Is works without string scanning. errors is already imported in sse.go.

4. dropOldestLocked re-slicing retains backing array (Minor)

w.entries = w.entries[1:]

Repeated appends/drops can keep a growing backing array alive. Not a correctness issue; a ring buffer would bound memory more predictably. Fine to defer.

Minor observations

  • recordDecision calls wal.SetNoDrop(active.NoDropDecisions) on every append — takes the WAL mutex each time; could set once at policy change.
  • The single-record-larger-than-maxBytes acceptance is documented and intentional — good.
  • StubTransport correctly returns immediately, so the current worker never hangs in practice — issue #2 is a "when real transport lands" concern.

Verdict

Approve with changes requested for #1 (persist-on-every-append is a genuine bottleneck). #2 and #3 are recommended before the real flush transport lands. Otherwise this is a clean, well-tested PR.

Keep VERILINK_NO_DROP_DECISIONS sticky against policy, and switch the
on-disk WAL to JSONL append + periodic compact instead of O(n) rewrite.

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

Copy link
Copy Markdown
Collaborator Author

Addressed Qodo actionable findings in d4b4dd1:

  1. Sticky no-drop local override (forcedNoDrop || policy)
  2. Append-only JSONL WAL + periodic compact (no O(n) full rewrite on the request path)

On the RFC 9421 outbound upstream signing rule: acknowledged / out of Plan 8 PR B scope — this edge verifies inbound client signatures only; re-signing outbound upstream hops remains a separate product decision (same as Plan 8 PR A).

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

@messagesgoel-blip I will review d4b4dd1.

I will treat outbound upstream signing as out of scope for this PR. If needed, I can help create a follow-up issue for that product decision.

✅ 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.

@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: 22

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/edgeverifier/proxy.go (1)

204-237: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep WAL trust fields from one evaluation.

  • Store.Load can return different Snapshot generations for AllowByScore and the following LookupScore. Return Blacklisted from the same evaluation, or evaluate one loaded snapshot for all fields.
  • In the trustStore fallback, assign the fetched TrustScore to the named score result before returning. PrincipalID remains empty because TrustStore exposes no principal ID.
🤖 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/proxy.go` around lines 204 - 237, Update annotateTrust
so AllowByScore and LookupScore use the same loaded Snapshot evaluation,
deriving blacklisted and score fields from one generation rather than separate
Store.Load results. In the trustStore fallback, assign the fetched TrustScore to
the named score return value before returning; keep PrincipalID empty.
🤖 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 `@cmd/edge-verifier/main.go`:
- Around line 39-66: Track the flush worker, sync runner, and shutdown goroutine
with a sync.WaitGroup, adding the required sync import. Update the goroutine
launches around NewFlushWorker, syncRunner, and the signal-driven shutdown
routine to call Add before starting and Done on exit, then wait on the group
before main returns so server shutdown and background cleanup complete.

In `@internal/edgeverifier/flush.go`:
- Around line 20-26: Assess the WAL retention configuration around FlushBatch
and the on-disk decision records, then add an age-based retention bound
alongside the existing maxBytes limit so records cannot remain indefinitely
during prolonged flush outages. Enforce the age limit when retaining or reading
WAL entries, expose the setting through the existing configuration path, and
document the expected retention window for the edge WAL.
- Around line 103-107: Update the cancellation branch in Run to repeatedly call
FlushOnce with the existing timeout context until it returns nil with no pending
work, or until a flush error/context deadline stops progress. Preserve
cancellation independence via context.WithoutCancel, ensure cancel is always
called, and retain the existing return after the drain attempt.
- Line 99: Update Run where it creates the ticker from w.interval to use the
default flush interval whenever w.interval is non-positive, while preserving
configured positive intervals. Ensure time.NewTicker always receives a positive
duration, including when FlushWorker is constructed via a composite literal.
- Around line 165-169: Update FlushWorker.logf so the underlying Logger.Printf
receives a constant format string, passing the edgesync prefix and formatted
message as arguments rather than concatenating format with the prefix. Add the
recognized “logf is a printf wrapper” documentation form so go vet continues
validating logf call sites and preserves formatting of args.
- Around line 123-131: Update buildFlushBatch and its BatchID generation so the
identifier is deterministically derived from the batch content instead of using
a fresh random value from newBatchID. Preserve the existing UUID version and
variant formatting when deriving the value, remove the now-unnecessary
randomness/error path, and ensure retries of identical decisions produce the
same BatchID.

In `@internal/edgeverifier/sse.go`:
- Around line 429-449: The reconnectReason function must stop classifying
control signals by substring matching err.Error(), which allows untrusted
upstream response bodies to spoof reasons. Introduce typed or sentinel errors
for shutdown, recovered 429/410, and unavailable signals at their creation
sites, then update reconnectReason to use errors.Is or errors.As while
preserving cancellation/deadline and generic error handling.

In `@internal/edgeverifier/wal_test.go`:
- Around line 204-206: Extend the WAL tests around flushFunc with coverage for
transport failure: perform a failing FlushOnce, assert wal.Len() is unchanged,
then perform a successful flush and verify the original sequences are delivered.
Add a persistence test using a configured Path and small MaxBytes that triggers
dropOldestLocked, reopen the WAL, and assert the dropped decisions are not
resurrected.
- Around line 155-186: The WAL test’s wall-clock comparison in the append
performance test is flaky under real filesystem and scheduler variability.
Replace the small/large timing assertion with a deterministic check of the
algorithmic behavior of Append, such as instrumenting or measuring entries slice
reallocations/copies as WAL length grows, or move the timing logic into a
non-gating benchmark. Preserve coverage that Append remains O(1) with increasing
WAL length and remove the fragile time-based failure from the test.
- Around line 110-118: Update the reload assertion in the wal3 Pending check to
require the exact expected WalSeq value from the prior stream state, rather than
merely requiring it to be positive. Keep the existing fingerprint and
pending-count validations unchanged, and ensure the assertion verifies the
sequence continues past all previously used sequences, including acknowledged
entries.
- Around line 193-198: Update the SizedNoDropWALMaxBytes test in wal_test.go to
assert directly that the 1<<20 and 900 case equals defaultNoDropWALBytes,
removing the redundant outer condition. Add test cases covering negative
p99BytesPerSec and non-positive outageSeconds, verifying both return the
defaultNoDropWALBytes floor.

In `@internal/edgeverifier/wal.go`:
- Around line 396-408: Update loadStream after rebuilding w.entries and w.bytes
to enforce w.maxBytes before returning: trim the oldest loaded entries until the
total is within the configured limit, or return an explicit startup error naming
both the configured limit and loaded size. Preserve the existing staleBytes
reset and successful return only after capacity enforcement.
- Line 189: Update Append and the writeLineLocked flow to build the walLine
once, marshal that representation once, and use the resulting encoded length for
the entry size instead of calling estimateDecisionBytes. Pass or reuse the
marshaled bytes when writing the line so each decision is encoded only once and
w.bytes matches the on-disk representation.
- Around line 551-555: Update the WAL sizing calculation to use math.Ceil
instead of the manual +0.999999 adjustment. Before converting the ceiling result
to int64, compare it with math.MaxInt64 and clamp values above that limit;
preserve the existing defaultNoDropWALBytes minimum handling.
- Around line 339-341: Update loadStream to scan the WAL twice: first determine
the maximum Through value across dec records, then initialize pending and order
and perform a second scan that retains only entries whose WalSeq exceeds
ackThrough. Apply the filtering during the second pass so acknowledged decisions
are never stored, while preserving existing decision loading and ordering
behavior for live entries.
- Around line 282-283: Update dropOldestLocked so the ack watermark is written
without forceSync, allowing walSyncEveryN to batch durability instead of
fsyncing each dropped entry. Also coalesce multiple drops handled by a single
Append into one ack line, avoiding one WAL record per dropped entry and reducing
staleBytes and compaction pressure.
- Around line 411-425: Update DecisionWAL.writeLineLocked to reject any
JSON-encoded walLine whose complete line length, including the newline, exceeds
walMaxLineBytes before writing. Also enforce appropriate bounds on
request-derived fields in Append so oversized records are rejected there without
surfacing as proxy errors for otherwise valid requests.
- Around line 190-200: In the WAL append capacity logic, consolidate the
duplicated no-drop checks around the visible loop using w.noDrop and
dropOldestLocked: perform one pre-check that returns ErrWALFull when the write
exceeds w.maxBytes, then make the dropping loop unconditional for the remaining
case. Preserve the existing w.nextSeq rollback and behavior when entries are
exhausted.
- Around line 262-264: Update AckThrough around writeLineLocked and
maybeCompactLocked to propagate their errors instead of discarding them, while
preserving the existing acknowledgement and compaction flow. Adjust callers to
handle the returned error, including logging the result in
FlushWorker.FlushOnce.
- Around line 497-513: Update compactLocked to install a deferred recovery step
immediately after closing the live WAL handle and clearing w.file. If os.Rename,
syncDir, or os.OpenFile fails, attempt to reopen w.path with the same
create/read-write/append flags and permissions before returning the original
error; ensure successful compaction keeps the newly opened handle and resets the
existing counters.
- Around line 440-447: Update the compaction decision before compactLocked so it
requires both a minimum absolute staleBytes threshold and staleBytes dominating
the live bytes by the intended ratio; remove the inverted large-WAL condition
that allows small stale ratios to compact. Preserve the drain-to-empty behavior
when bytes is zero and keep compactLocked as the compaction path.
- Around line 344-352: Update loadStream’s JSONL scanning loop to tolerate a
json.Unmarshal failure only when the failed raw line is the final line, treating
it as a torn WAL tail and stopping recovery; retain the existing error for
malformed lines with subsequent content. Restructure the scanner using one-line
lookahead or equivalent buffering so the next-line check does not skip data.

---

Outside diff comments:
In `@internal/edgeverifier/proxy.go`:
- Around line 204-237: Update annotateTrust so AllowByScore and LookupScore use
the same loaded Snapshot evaluation, deriving blacklisted and score fields from
one generation rather than separate Store.Load results. In the trustStore
fallback, assign the fetched TrustScore to the named score return value before
returning; keep PrincipalID empty.
🪄 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: 1ab4318e-d8db-4808-b4ce-e2f395f77fac

📥 Commits

Reviewing files that changed from the base of the PR and between a9d46e4 and d4b4dd1.

📒 Files selected for processing (8)
  • cmd/edge-verifier/main.go
  • docs/superpowers/plans/HANDOVER.md
  • internal/edgeverifier/flush.go
  • internal/edgeverifier/metrics.go
  • internal/edgeverifier/proxy.go
  • internal/edgeverifier/sse.go
  • internal/edgeverifier/wal.go
  • internal/edgeverifier/wal_test.go

Comment thread cmd/edge-verifier/main.go
Comment thread internal/edgeverifier/flush.go
Comment thread internal/edgeverifier/flush.go Outdated
Comment thread internal/edgeverifier/flush.go
Comment thread internal/edgeverifier/flush.go Outdated
Comment thread internal/edgeverifier/wal.go Outdated
Comment thread internal/edgeverifier/wal.go
Comment thread internal/edgeverifier/wal.go Outdated
Comment thread internal/edgeverifier/wal.go Outdated
Comment thread internal/edgeverifier/wal.go Outdated
Single-marshal appends, torn-tail recovery, surfaced ack/drop write
errors, typed SSE reconnect reasons, AllowByScore blacklist reuse,
deterministic batch IDs, flush-all on shutdown, and WaitGroup join.

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

Copy link
Copy Markdown
Collaborator Author

Addressed the requested resilience/perf items in this push, plus overlapping CodeRabbit majors:

  1. Single JSON marshal in Append (size from encoded line)
  2. Torn trailing JSONL tolerated on load (warn + continue)
  3. Ack/drop write errors propagated (AckThrough returns error; drops fail the append)
  4. Typed SSE reconnect errors (errors.Is on sentinels)
  5. No redundant LookupScoreAllowByScore now returns blacklisted

Also: deterministic batch IDs, flush-all on shutdown, WaitGroup before exit, compact reopen-on-failure, maxBytes trim after reload, line-size cap at write, removed flaky wall-clock test, math.Ceil for no-drop sizing.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

@messagesgoel-blip I will review the latest push. I will verify the WAL write and recovery paths, flush shutdown behavior, typed SSE reconnect handling, and trust lookup 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.

Apply tenant no-drop via SyncRunner on snapshot/policy updates instead
of per-request SetNoDrop. Keep the live WAL fd until compacted file is
reopened, bound periodic flush with a timeout, and return trustStore
score from annotateTrust.

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

Copy link
Copy Markdown
Collaborator Author

Addressed the additional review notes (plus remaining open CR items triage):

Actionable (this push)

  1. SetNoDrop off the request path — removed from recordDecision; SyncRunner now has WithDecisionWAL and syncs no-drop on bootstrap / snapshot recovery / policy.replace.
  2. Compaction fd swap — rename + reopen happen before closing the prior handle; w.file is never nil’d mid-compact under the WAL mutex. Added TestWALCompactKeepsAppendWorking.
  3. Periodic FlushOnce timeout — 5s deadline so a hung transport cannot block shutdown forever.
  4. annotateTrust trustStore score — assigns the fetched score into the named return (CodeRabbit outside-diff note).

Reviewed / no code change

  1. SSE 410 — Plan 7 contract: 410 is reserved for pruned/retention cursors (pruning not shipped yet). Live CP currently returns 429 for backlog; normal stream end is the shutdown event / connection close, not 410. Edge errSSEGone path is correct for when retention 410 lands.
  2. Constructor signature growth — acknowledged; NewEdgeVerifierProxyWithSnapshot still delegates.

Still deferred (open CR threads)

  • Age-based WAL retention / PII bound (follow-up with CP ingest)
  • Bounding recovery memory for all historical dec lines (mitigated by maxBytes + line cap + trim)
  • Per-drop fsync batching (perf follow-up)

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== Changed files ==='
git diff --name-status HEAD^ HEAD
printf '%s\n' '=== Go diff stat ==='
git diff --stat HEAD^ HEAD -- '*.go'

printf '%s\n' '=== Source outlines ==='
for f in internal/edgeverifier/wal.go internal/edgeverifier/flush.go internal/edgeverifier/sse.go internal/edgeverifier/proxy.go cmd/edge-verifier/main.go internal/edgeverifier/wal_test.go; do
  if [ -f "$f" ]; then
    echo "--- $f"
    ast-grep outline "$f" --items all 2>/dev/null || true
  fi
done

printf '%s\n' '=== Focused implementation matches ==='
rg -n -C 8 \
  'func \(w \*DecisionWAL\) (Append|AckThrough|compact|Close|SetNoDrop)|func \(.*FlushWorker.*\) (Run|FlushOnce)|WithDecisionWAL|SetNoDrop|annotateTrust|func reconnectReason|context\.WithTimeout|WaitGroup|FlushOnce' \
  internal/edgeverifier cmd/edge-verifier 2>/dev/null || true

printf '%s\n' '=== WAL and flush test references ==='
rg -n -C 5 \
  'TestWALCompactKeepsAppendWorking|FlushOnce|timeout|NoDrop|AckThrough|compact|shutdown|recovery' \
  internal/edgeverifier/*_test.go 2>/dev/null || true

Length of output: 49216


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== Current commit diff ==='
git diff --unified=80 HEAD^ HEAD -- \
  cmd/edge-verifier/main.go \
  internal/edgeverifier/flush.go \
  internal/edgeverifier/proxy.go \
  internal/edgeverifier/sse.go \
  internal/edgeverifier/wal.go \
  internal/edgeverifier/wal_test.go

printf '%s\n' '=== Main startup and shutdown ==='
sed -n '35,125p' cmd/edge-verifier/main.go
sed -n '185,225p' cmd/edge-verifier/main.go

printf '%s\n' '=== WAL append, acknowledgement, and compaction ==='
sed -n '86,325p' internal/edgeverifier/wal.go
sed -n '440,620p' internal/edgeverifier/wal.go

printf '%s\n' '=== Flush worker ==='
sed -n '76,185p' internal/edgeverifier/flush.go

printf '%s\n' '=== Sync policy application and event dispatch ==='
sed -n '98,180p' internal/edgeverifier/sse.go
sed -n '292,375p' internal/edgeverifier/sse.go
sed -n '455,490p' internal/edgeverifier/sse.go

printf '%s\n' '=== Proxy trust annotation and decision recording ==='
sed -n '87,270p' internal/edgeverifier/proxy.go

printf '%s\n' '=== Relevant test bodies ==='
sed -n '202,280p' internal/edgeverifier/wal_test.go

Length of output: 70666


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== Numbered acknowledgement and drop paths ==='
nl -ba internal/edgeverifier/wal.go | sed -n '255,325p'

printf '%s\n' '=== WAL persistence and test hooks ==='
rg -n -C 4 \
  'writeBytesLocked|writeLineLocked|os\.OpenFile|chmod|permission|read-only|AckThrough|dropOldestLocked|write error|Sync\(\)' \
  internal/edgeverifier/*_test.go internal/edgeverifier/wal.go

printf '%s\n' '=== Full test function map ==='
ast-grep outline internal/edgeverifier --items all --type function --match 'Test.*WAL|Test.*Flush' 2>/dev/null || true

Length of output: 971


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== Numbered acknowledgement and drop paths ==='
awk 'NR >= 255 && NR <= 325 { printf "%6d\t%s\n", NR, $0 }' internal/edgeverifier/wal.go

printf '%s\n' '=== Existing WAL failure-path tests ==='
rg -n -C 4 \
  'AckThrough|dropOldestLocked|write.*fail|permission|read-only|chmod|Sync\(\)|os\.OpenFile' \
  internal/edgeverifier/*_test.go || true

printf '%s\n' '=== WAL test declarations ==='
rg -n '^func Test.*(WAL|Flush)' internal/edgeverifier/*_test.go || true

Length of output: 4744


@messagesgoel-blip I found one remaining WAL durability issue.

  • internal/edgeverifier/wal.go Line 278: AckThrough removes entries from memory before it writes the ack record on Line 284. If writeLineLocked fails, FlushOnce returns the error, but the worker has already removed the batch. The running process cannot retry the acknowledgement. The same ordering exists in dropOldestLocked on Lines 297-307. Write the acknowledgement first, then update entries, bytes, staleBytes, and drop metrics only after the write succeeds. Add a failure-injection test for both paths.

The no-drop policy synchronization, compaction ordering, periodic flush timeout, shutdown wait, and trust-score assignment match the stated changes.


✏️ Learnings added
Learnt from: messagesgoel-blip
URL: https://github.com/Numeracode/verilink/pull/20

Timestamp: 2026-07-31T05:58:50.115Z
Learning: For the Go edge verifier SSE sync protocol in Plan 7, HTTP 410 is reserved for pruned or retention cursors. The live control plane returns HTTP 429 for backlog. Normal stream completion uses the `shutdown` SSE event or connection close. `errSSEGone` remains for future retention-based HTTP 410 recovery.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ 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.

@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: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/edgeverifier/wal.go (1)

208-243: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Align reload size accounting with the append-path encoding.

Append now sizes each entry from the encoded walLine (line 218), including the newline. loadStream sizes reloaded entries with estimateDecisionBytes, which marshals the Decision struct instead (line 440). The two encodings differ: walLine uses the short key t, adds Type, and omits empty fields, while Decision emits wal_seq, fingerprint, score, blacklisted, action, and decided_at unconditionally.

After a restart, w.bytes therefore no longer tracks the same unit that maxBytes enforcement and the drop loop assume, so the post-reload trim at lines 343-350 and the first Append decisions run on skewed numbers. Reuse one helper that builds the walLine and returns its encoded length for both paths.

♻️ Proposed fix: share one sizing helper
+func decisionLineBytes(d Decision) (int64, error) {
+	encoded, err := json.Marshal(decisionLine(d))
+	if err != nil {
+		return 0, err
+	}
+	return int64(len(encoded) + 1), nil
+}

Then use decisionLineBytes in loadStream in place of estimateDecisionBytes, and build the walLine in Append through the same decisionLine constructor.

🤖 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/wal.go` around lines 208 - 243, Unify WAL entry size
accounting by adding a shared decisionLine constructor/helper that builds the
walLine and returns its encoded length, including the newline. Update Append to
use this helper instead of constructing and sizing the line separately, and
update loadStream to call decisionLineBytes rather than estimateDecisionBytes so
reload, trimming, and maxBytes enforcement use identical encoding sizes.
🤖 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 `@cmd/edge-verifier/main.go`:
- Line 115: Add the signal shutdown goroutine to the `bg` wait group before
starting it, and ensure the goroutine calls `bg.Done()` on exit. Keep the
existing shutdown sequence, including `server.Shutdown()` and
`backendServer.Shutdown()`, so `bg.Wait()` does not return until signal-driven
shutdown completes.
- Around line 116-118: Update the shutdown cleanup around decisionWAL.Close() to
capture and log any close error, then exit with a non-zero status when
persistence fails. Preserve the nil check and normal successful shutdown
behavior.

In `@internal/edgeverifier/flush.go`:
- Around line 90-92: Add the non-positive interval fallback inside
FlushWorker.Run before the time.NewTicker call, preserving the existing
constructor normalization in NewFlushWorker. Ensure composite-literal workers
use 5 seconds when interval is zero or negative, preventing ticker creation from
panicking.
- Around line 119-135: Update FlushWorker.FlushAll to guard against a nil
receiver, then measure progress using the oldest pending/acked sequence rather
than WAL.Len(), since concurrent DecisionWAL.Append calls can increase the queue
during FlushOnce. Capture the sequence before each FlushOnce and require it to
advance after a successful flush; retain the existing context cancellation,
empty-WAL, and no-progress error behavior.

In `@internal/edgeverifier/sse.go`:
- Around line 104-110: Update SyncRunner.syncWALPolicy so DecisionWAL.SetNoDrop
is always synchronized with the current snapshot: use the active policy’s
NoDropDecisions when present, and explicitly clear it when ActivePolicy returns
nil. Add coverage for transitioning from a policy with NoDropDecisions true to a
snapshot with Policy nil, verifying drop-oldest behavior is restored.

In `@internal/edgeverifier/wal_test.go`:
- Around line 153-184: Add a new test alongside TestWALTornTrailingLineRecovered
that appends a malformed WAL record followed by a valid record, then verifies
NewDecisionWAL returns an error. Reuse the existing WAL setup and cleanup
pattern, and ensure the malformed line is not treated as a recoverable trailing
fragment when another valid line follows it.
- Around line 211-233: Update the compaction test around the payload
construction and AckThrough call to use a printable repeated payload via the
strings package, keeping the encoded line within walMaxLineBytes while matching
the intended size comment. After AckThrough(lastSeq), stat the WAL file and
assert its size reflects compaction before appending the post-compaction
decision, so the test fails if compactLocked does not run.

In `@internal/edgeverifier/wal.go`:
- Line 620: Clamp the value produced by math.Ceil in the WAL sizing calculation
before converting it to int64, ensuring values above math.MaxInt64 use the
maximum representable int64. Update the calculation around calc in the WAL
sizing logic while preserving the existing defaultNoDropWALBytes fallback
behavior for valid in-range results.
- Around line 342-350: Update the reload capacity enforcement around
w.dropOldestLocked so no-drop mode does not silently continue with an oversized
WAL: when w.noDrop is true and w.bytes exceeds w.maxBytes, fail startup with an
explicit error or emit an actionable log containing the configured limit and
loaded size before continuing. Preserve the existing trimming behavior for
drop-enabled mode.

---

Outside diff comments:
In `@internal/edgeverifier/wal.go`:
- Around line 208-243: Unify WAL entry size accounting by adding a shared
decisionLine constructor/helper that builds the walLine and returns its encoded
length, including the newline. Update Append to use this helper instead of
constructing and sizing the line separately, and update loadStream to call
decisionLineBytes rather than estimateDecisionBytes so reload, trimming, and
maxBytes enforcement use identical encoding sizes.
🪄 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: b68b56a8-006f-4d4d-a856-68c7064bf402

📥 Commits

Reviewing files that changed from the base of the PR and between d4b4dd1 and 46a82df.

📒 Files selected for processing (7)
  • cmd/edge-verifier/main.go
  • internal/edgeverifier/flush.go
  • internal/edgeverifier/policy.go
  • internal/edgeverifier/proxy.go
  • internal/edgeverifier/sse.go
  • internal/edgeverifier/wal.go
  • internal/edgeverifier/wal_test.go

Comment thread cmd/edge-verifier/main.go
Comment thread cmd/edge-verifier/main.go
Comment thread internal/edgeverifier/flush.go
Comment thread internal/edgeverifier/flush.go
Comment thread internal/edgeverifier/sse.go
Comment thread internal/edgeverifier/wal_test.go
Comment thread internal/edgeverifier/wal_test.go
Comment thread internal/edgeverifier/wal.go
Comment thread internal/edgeverifier/wal.go Outdated

@messagesgoel-blip messagesgoel-blip left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Re-review: PR #20 (3 new commits: d4b4dd1, 8cd5f86, 46a82df)

Verdict: Approve. All prior findings are addressed, and the WAL rewrite is a genuinely better design.

Prior issues — all resolved

Prior finding Resolution
persist-on-every-append (fsync per decision) WAL rewritten to append-only JSONL (meta/dec/ack lines) with batched fsync every 32 lines; compaction only when stale ≥1 MiB and ≥ half of live bytes
FlushOnce no context deadline context.WithTimeout(ctx, 5s) on both periodic and shutdown flush paths
reconnectReason string matching Sentinel errors (errSSEBacklog, errSSEGone, errSSEUnavailable, errSSEShutdown) + errors.Is
SetNoDrop on request hot path Moved to syncWALPolicy() on bootstrap / snapshot recover / policy.replace; SetNoDrop is now sticky-merge with no-op-when-unchanged

What is done well

  • Crash-safety tradeoffs are correct and reasoned: un-fsynced dec/ack lines at worst cause re-delivery, which is safe because BatchID is now a deterministic hash of the payload (buildFlushBatch) — retries dedupe on identical content. The torn-tail recovery (TestWALTornTrailingLineRecovered) handles partial last-line writes.
  • fd swap in compactLocked keeps the live handle until rename + reopen, so Append never observes a nil file — and TestWALCompactKeepsAppendWorking verifies it. Dir-sync failure is surfaced as an error, not swallowed.
  • Close() on shutdown with bg.Wait() ordering means the final FlushAll drains before the file handle is closed.
  • Forced (CLI/env) no-drop is sticky and can never be cleared by a synced policy — correct precedence.
  • AckThrough returns errors and the flush worker now surfaces ack failures instead of ignoring them.
  • AllowByScore returning blacklisted directly removes the redundant LookupScore call in proxy.

Minor observations (non-blocking)

  • dropOldestLocked still uses w.entries = w.entries[1:] (backing-array retention) while AckThrough uses the proper copy — inconsistent, but bounded and not a correctness issue.
  • On startup loadStream materializes the entire WAL into a map + order slice; fine for steady-state sizes, worth noting for the 8 GiB no-drop case (boot cost).
  • Deterministic BatchID is only collision-free while payloads differ; wal_seq guarantees that in practice.

No further changes required. Ship it — go test and vet both pass locally.

Age-based WAL retention (default 24h), two-pass load for bounded
recovery memory with correct nextSeq watermarks, FlushAll ack-seq
progress, shutdown WaitGroup + WAL close errors, no-drop oversize
startup failure, and related SSE/test hardening.

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

Copy link
Copy Markdown
Collaborator Author

Closed all remaining open review threads in this push (no deferrals):

  1. Age retentionMaxAge default 24h (-wal-max-age / VERILINK_WAL_MAX_AGE); prunes identifiers even under no-drop
  2. Two-pass load — ack watermark first, then live dec only; nextSeq scanned from all lines so post-ack appends stay visible
  3. Shutdown WaitGroup includes signal goroutine
  4. WAL Close error → log + non-zero exit
  5. Run interval guard for composite-literal workers
  6. FlushAll progress by acked wal_seq (concurrent Append safe)
  7. syncWALPolicy clears no-drop when policy is nil
  8. Mid-file corruption test
  9. Compaction test printable payload + size assert
  10. No-drop oversize on load fails startup with actionable error
  11. SizedNoDropWALMaxBytes clamps to MaxInt64

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== Latest changed files ==='
git diff --name-status HEAD^ HEAD

printf '%s\n' '=== Source outlines ==='
for f in internal/edgeverifier/wal.go internal/edgeverifier/flush.go internal/edgeverifier/sse.go cmd/edge-verifier/main.go internal/edgeverifier/wal_test.go; do
  if [ -f "$f" ]; then
    echo "--- $f"
    ast-grep outline "$f" --items all 2>/dev/null || true
  fi
done

printf '%s\n' '=== Focused source matches ==='
rg -n -C 10 \
  'MaxAge|load|recover|AckThrough|dropOldestLocked|writeLineLocked|compact|SizedNoDropWALMaxBytes|FlushAll|FlushOnce|func \(.*FlushWorker.*\) Run|syncWALPolicy|SetNoDrop|WaitGroup|signal|Close\(\)' \
  internal/edgeverifier cmd/edge-verifier 2>/dev/null || true

printf '%s\n' '=== WAL and flush test declarations and relevant assertions ==='
rg -n -C 8 \
  '^func Test.*(WAL|Flush)|MaxAge|corrupt|oversize|compact|AckThrough|dropOldest|FlushAll|no.drop|NoDrop|MaxInt64|Close' \
  internal/edgeverifier/*_test.go 2>/dev/null || true

printf '%s\n' '=== Latest focused diff ==='
git diff --unified=35 HEAD^ HEAD -- \
  internal/edgeverifier/wal.go \
  internal/edgeverifier/flush.go \
  internal/edgeverifier/sse.go \
  cmd/edge-verifier/main.go \
  internal/edgeverifier/wal_test.go

Length of output: 216405


@messagesgoel-blip I found one remaining WAL durability issue.

  • internal/edgeverifier/wal.go Line 302: AckThrough removes entries from memory before it persists the ack record on Line 308. If writeLineLocked fails, FlushOnce returns the error, but the running process has already removed the delivered batch. A retry cannot acknowledge the batch.
  • internal/edgeverifier/wal.go Line 321: dropOldestLocked has the same ordering. It removes the entry and increments the drop metric before it persists the acknowledgement on Line 331.

Persist the ack record first. Update entries, bytes, staleBytes, and drop metrics only after the write succeeds. Add failure-injection tests for both paths.

I verified the reported retention, two-pass recovery, no-drop load limit, FlushAll sequence progress, interval guard, no-drop policy clearing, compaction assertion, and integer clamp changes by static inspection. I did not execute tests in this environment.

You are interacting with an AI system.

@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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/edgeverifier/wal.go (2)

444-459: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use the encoded line length for the reloaded entry size.

Append now sizes each entry from the encoded walLine (size = int64(len(encoded)), line 242). loadStream sizes reloaded entries with estimateDecisionBytes(d), which marshals the Decision struct. Decision uses the long JSON keys (wal_seq, fingerprint, decided_at) while walLine uses short keys with omitempty, so the two values differ for the same record.

The consequences after a restart:

  • w.bytes and the WALBytes metric no longer track the on-disk size.
  • The capacity checks in openAndLoad (lines 388-395) and in Append compare a mixed accounting base against w.maxBytes, so the no-drop startup error at line 390 can fire or stay silent for the wrong loaded size.

The raw line is available in the second pass. Use its length, which matches what Append recorded.

🛠️ Proposed fix
 	for sc.Scan() {
 		raw := sc.Bytes()
 		if len(raw) == 0 {
 			continue
 		}
+		lineSize := int64(len(raw)) + 1 // trailing newline, as written by Append
 		var line walLine
-			size := estimateDecisionBytes(d)
-			w.entries = append(w.entries, walEntry{rec: d, size: size})
-			w.bytes += size
+			w.entries = append(w.entries, walEntry{rec: d, size: lineSize})
+			w.bytes += lineSize
🤖 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/wal.go` around lines 444 - 459, Update loadStream’s
second-pass entry sizing to use the raw walLine’s encoded length rather than
estimateDecisionBytes(d). Preserve the existing Decision reconstruction and
append flow, but set each walEntry size and w.bytes accounting from the
available line length so reloads match Append’s on-disk sizing.

399-443: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Confirm that the watermark pass and the load pass stay consistent.

scanWALWatermarks and loadStream duplicate the scan loop, the buffer limits, and the torn-tail rule. The two loops must agree, otherwise the first pass can accept a file that the second pass rejects, or the reverse. One shared scan helper that yields walLine values would remove the duplication.

Also confirm the reload cost. The WAL file can reach maxBytes (8 GiB in no-drop mode), and startup now reads it twice before the edge verifier serves traffic.

Also applies to: 460-527

🤖 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/wal.go` around lines 399 - 443, Refactor
scanWALWatermarks and loadStream to use one shared WAL scanning helper that owns
scanner buffer limits, JSON decoding, and torn-tail handling, yielding walLine
values consistently to both passes. Update both callers to preserve their
existing watermark and entry-processing behavior, then avoid rereading the full
WAL during startup where possible so a maxBytes-sized file is not scanned twice
before serving traffic.
🤖 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 `@cmd/edge-verifier/main.go`:
- Around line 184-195: Validate cfg.walMaxAge in mustOpenWAL before constructing
the WAL, failing startup when the duration is negative instead of passing it to
WALConfig.MaxAge. Update envDuration to log a clear parse failure when the
configured value is malformed, while retaining the fallback behavior as
appropriate. Ensure both flag and environment-derived values surface invalid
input rather than silently applying the default.

---

Outside diff comments:
In `@internal/edgeverifier/wal.go`:
- Around line 444-459: Update loadStream’s second-pass entry sizing to use the
raw walLine’s encoded length rather than estimateDecisionBytes(d). Preserve the
existing Decision reconstruction and append flow, but set each walEntry size and
w.bytes accounting from the available line length so reloads match Append’s
on-disk sizing.
- Around line 399-443: Refactor scanWALWatermarks and loadStream to use one
shared WAL scanning helper that owns scanner buffer limits, JSON decoding, and
torn-tail handling, yielding walLine values consistently to both passes. Update
both callers to preserve their existing watermark and entry-processing behavior,
then avoid rereading the full WAL during startup where possible so a
maxBytes-sized file is not scanned twice before serving traffic.
🪄 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: 8cdbb1d2-8151-4e4a-82fa-5645ebb1ecdc

📥 Commits

Reviewing files that changed from the base of the PR and between 46a82df and dc69d49.

📒 Files selected for processing (5)
  • cmd/edge-verifier/main.go
  • internal/edgeverifier/flush.go
  • internal/edgeverifier/sse.go
  • internal/edgeverifier/wal.go
  • internal/edgeverifier/wal_test.go

Comment thread cmd/edge-verifier/main.go
Fail fast on -wal-max-age < 0 so identifier retention cannot be disabled
via flag/env, and log malformed VERILINK_WAL_MAX_AGE parse errors.

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

Copy link
Copy Markdown
Collaborator Author

Addressed the remaining finding: reject negative -wal-max-age at startup + log malformed VERILINK_WAL_MAX_AGE.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== Latest changed files ==='
git diff --name-status HEAD^ HEAD

printf '%s\n' '=== Latest relevant diff ==='
git diff --unified=45 HEAD^ HEAD -- \
  cmd/edge-verifier/main.go \
  internal/edgeverifier/wal.go \
  internal/edgeverifier/wal_test.go \
  internal/edgeverifier/flush.go

printf '%s\n' '=== Declaration map ==='
for file in cmd/edge-verifier/main.go internal/edgeverifier/wal.go internal/edgeverifier/wal_test.go; do
  if [ -f "$file" ]; then
    printf '%s\n' "--- $file"
    ast-grep outline "$file" --items all 2>/dev/null || true
  fi
done

printf '%s\n' '=== Configuration and persistence paths ==='
rg -n -C 12 \
  'wal.max.age|WAL_MAX_AGE|parseDuration|ParseDuration|MaxAge|AckThrough|dropOldestLocked|writeLineLocked|writeBytesLocked' \
  cmd/edge-verifier/main.go internal/edgeverifier/wal.go internal/edgeverifier/wal_test.go

Length of output: 54909


@messagesgoel-blip The -wal-max-age validation and malformed VERILINK_WAL_MAX_AGE logging are present.

One WAL durability finding remains.

  • internal/edgeverifier/wal.go Line 302 removes entries and updates byte counters before AckThrough writes its ack line on Line 308.
  • internal/edgeverifier/wal.go Line 321 removes the oldest entry and increments DecisionsDropped before dropOldestLocked writes its ack line on Line 331.

If writeLineLocked fails, the WAL file does not contain the acknowledgement. The in-memory WAL has already discarded the decision. A retry cannot acknowledge or drop that decision.

Write the ack line first. Update entries, bytes, staleBytes, and DecisionsDropped only after the write succeeds. Add failure-injection tests for both paths.

I verified this by static inspection. I did not execute tests.

You are interacting with an AI system.

Drop loops write a single ack watermark instead of one line per drop,
and reallocate the entry slice when head-slicing leaves excess capacity.

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

Copy link
Copy Markdown
Collaborator Author

Closed the last soft-deferred polish items:

  1. Coalesced multi-drop acks — capacity/age drop loops remove in memory then write one ack watermark (no per-drop write/fsync storm)
  2. Entry-slice compact — reallocates when cap >= 64 and cap >= 2*len after head-slicing

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== Latest changed files ==='
git diff --name-status HEAD^ HEAD

printf '%s\n' '=== WAL declaration map ==='
ast-grep outline internal/edgeverifier/wal.go --items all

printf '%s\n' '=== Latest WAL diff ==='
git diff --unified=80 HEAD^ HEAD -- internal/edgeverifier/wal.go internal/edgeverifier/wal_test.go

printf '%s\n' '=== Acknowledgement, retention, and compaction paths ==='
rg -n -C 18 \
  'func \(w \*DecisionWAL\) (Append|AckThrough|dropOldestLocked|pruneExpiredLocked|compactEntriesLocked|load|compact)|writeLineLocked|staleBytes|DecisionsDropped|MaxAge' \
  internal/edgeverifier/wal.go

printf '%s\n' '=== Relevant test coverage ==='
rg -n -C 8 \
  '^func Test.*(WAL|Flush)|drop|Drop|ack|Ack|age|Age|compact|Compact|write.*fail|failure' \
  internal/edgeverifier/wal_test.go

Length of output: 49765


@messagesgoel-blip I found a WAL durability regression.

  • internal/edgeverifier/wal.go Line 292: AckThrough removes entries and changes bytes and staleBytes before it writes the ack record on Line 298.
  • internal/edgeverifier/wal.go Lines 345-355: dropForCapacityLocked calls removeOldestLocked before it writes the coalesced ack record.
  • internal/edgeverifier/wal.go Lines 377-388: pruneExpiredLocked has the same order for age retention.

If writeLineLocked fails, the WAL file does not contain the acknowledgement. The in-memory WAL has already discarded the decisions. A retry cannot flush, acknowledge, or retain those decisions.

Coalescing is valid, but do not mutate the live WAL state until the coalesced ack write succeeds. Calculate the removal count, removed bytes, and highest sequence first. Write one ack record. Then remove the entries, update bytes and staleBytes, compact the entry slice, and increment DecisionsDropped.

Add write-failure injection tests for AckThrough, dropForCapacityLocked, and pruneExpiredLocked. Each test should verify that pending entries, byte counters, and drop metrics remain unchanged after the write error.

You are interacting with an AI system.

@messagesgoel-blip
messagesgoel-blip merged commit 35125ac into main Jul 31, 2026
5 checks passed
@messagesgoel-blip
messagesgoel-blip deleted the feat/go-edge-hardening-pr-b branch July 31, 2026 11:20
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