Skip to content

GA 3/10 · bridges: commitment ledger, parity gate, encrypted sent-log, typecheck in CI - #256

Closed
dshakes wants to merge 5 commits into
ga/02-cp-security-costfrom
ga/03-bridges
Closed

dshakes wants to merge 5 commits into
ga/02-cp-security-costfrom
ga/03-bridges

Conversation

@dshakes

@dshakes dshakes commented Oct 23, 2026

Copy link
Copy Markdown
Owner

Part 3 of 10 of the GA next-gen stack (split from #254 so each AI review fits its budget). Base: ga/02-cp-security-cost. Merge in order with a merge commit (not squash) so the stack stays intact.

Full context, verification results and needs-human list: #254 and docs/roadmap/2026-10-nextgen-sandbox-and-harness.md.

Commits in this part:

  • merge: bridge commitment ledger, parity gate, sent-log, typecheck in CI

@github-actions github-actions Bot added the domain:core Core library / business logic label Oct 23, 2026
@compass-sdlc-bot compass-sdlc-bot Bot added the agent:needs-fix Reviewer found Blocking issues -> triggers the auto-fix loop label Oct 23, 2026
@compass-sdlc-bot

Copy link
Copy Markdown

🔒 Reviewer verdict: BLOCKING — noteDraftMeta is set after the burst send loop already fires the sent-log append in both bridges, so sent-log entries never carry the correct tier/voiceDelta (off-by-one-turn or missing), defeating the new quality-measurement feature.

@github-actions

Copy link
Copy Markdown
Contributor

🛡️ Vuln scan — ❌ vulnerable dependency found

      #41: internal/handlers/schedules.go:237:33: handlers.RESTHandler.UpdateSchedule calls time.LoadLocation, which eventually calls trace.Shutdown
      #42: internal/handlers/schedules.go:237:33: handlers.RESTHandler.UpdateSchedule calls time.LoadLocation, which eventually calls trace.Shutdown
      #43: internal/telemetry/tracer.go:92:21: telemetry.InitTracer calls trace.TracerProvider.Shutdown
      #44: internal/handlers/runtime.go:186:50: handlers.NewGRPCSchedulerClient calls otelgrpc.NewClientHandler, which calls trace.TracerProvider.Tracer
      #45: internal/telemetry/tracer.go:79:23: telemetry.InitTracer calls trace.WithBatcher
      #46: internal/telemetry/tracer.go:80:24: telemetry.InitTracer calls trace.WithResource
      #47: internal/telemetry/tracer.go:81:23: telemetry.InitTracer calls trace.WithSampler
      #48: internal/handlers/llm_proxy.go:3935:104: handlers.LlmProxyHandler.Transcribe calls trace.errUnsupportedSampler.Error
      #49: internal/telemetry/tracer.go:17:2: telemetry.init calls trace.init
      #50: internal/handlers/schedules.go:237:33: handlers.RESTHandler.UpdateSchedule calls time.LoadLocation, which eventually calls trace.init
      #51: internal/handlers/schedules.go:237:33: handlers.RESTHandler.UpdateSchedule calls time.LoadLocation, which eventually calls trace.logDropped
      #52: internal/handlers/schedules.go:237:33: handlers.RESTHandler.UpdateSchedule calls time.LoadLocation, which eventually calls trace.logDropped
      #53: internal/handlers/llm_proxy.go:3810:27: handlers.LlmProxyHandler.OCR calls io.ReadAll, which eventually calls trace.nonRecordingSpan.AddEvent
      #54: internal/handlers/runtime.go:1556:2: handlers.RuntimeHandler.Schedule calls trace.nonRecordingSpan.End
      #55: internal/middleware/span.go:34:22: middleware.EnrichSpan calls trace.nonRecordingSpan.IsRecording
      #56: internal/handlers/runtime.go:1564:19: handlers.RuntimeHandler.Schedule calls trace.nonRecordingSpan.RecordError
      #57: internal/handlers/runtime.go:1555:20: handlers.RuntimeHandler.Schedule calls trace.nonRecordingSpan.SetAttributes
      #58: cmd/server/main.go:841:38: server.main calls http.Server.ListenAndServe, which eventually calls trace.nonRecordingSpan.SetName
      #59: internal/handlers/runtime.go:1565:17: handlers.RuntimeHandler.Schedule calls trace.nonRecordingSpan.SetStatus
      #60: internal/handlers/runtime.go:1554:27: handlers.RuntimeHandler.Schedule calls noop.Tracer.Start, which calls trace.nonRecordingSpan.SpanContext
      #61: internal/handlers/llm_proxy.go:3801:24: handlers.LlmProxyHandler.OCR calls http.Client.Do, which eventually calls trace.nonRecordingSpan.TracerProvider
      #62: internal/handlers/llm_proxy.go:3810:27: handlers.LlmProxyHandler.OCR calls io.ReadAll, which eventually calls trace.recordingSpan.AddEvent
      #63: internal/handlers/runtime.go:1556:2: handlers.RuntimeHandler.Schedule calls trace.recordingSpan.End
      #64: internal/middleware/span.go:34:22: middleware.EnrichSpan calls trace.recordingSpan.IsRecording
      #65: internal/handlers/runtime.go:1564:19: handlers.RuntimeHandler.Schedule calls trace.recordingSpan.RecordError
      #66: internal/handlers/runtime.go:1555:20: handlers.RuntimeHandler.Schedule calls trace.recordingSpan.SetAttributes
      #67: cmd/server/main.go:841:38: server.main calls http.Server.ListenAndServe, which eventually calls trace.recordingSpan.SetName
      #68: internal/handlers/runtime.go:1565:17: handlers.RuntimeHandler.Schedule calls trace.recordingSpan.SetStatus
      #69: internal/handlers/runtime.go:1554:27: handlers.RuntimeHandler.Schedule calls noop.Tracer.Start, which calls trace.recordingSpan.SpanContext
      #70: internal/handlers/llm_proxy.go:3801:24: handlers.LlmProxyHandler.OCR calls http.Client.Do, which eventually calls trace.recordingSpan.TracerProvider
      #71: internal/handlers/llm_proxy.go:3935:104: handlers.LlmProxyHandler.Transcribe calls trace.samplerArgParseError.Error
      #72: internal/handlers/connector_health.go:177:15: handlers.PersistConnectorQuarantine calls errors.As, which eventually calls trace.samplerArgParseError.Unwrap
      #73: internal/handlers/runtime.go:1554:27: handlers.RuntimeHandler.Schedule calls trace.tracer.Start
      #74: internal/telemetry/tracer.go:86:27: telemetry.InitTracer calls otel.SetTextMapPropagator, which eventually calls trace.tracerProviderConfig.MarshalLog
      #75: internal/telemetry/tracer.go:79:23: telemetry.InitTracer calls trace.WithBatcher, which eventually calls x.Feature[string].Enabled
      #76: internal/telemetry/tracer.go:16:2: telemetry.init calls resource.init, which calls x.init

Vulnerability #15: GO-2026-6443
    Server panic via missing authority or Host headers in google.golang.org/grpc
  More info: https://pkg.go.dev/vuln/GO-2026-6443
  Module: google.golang.org/grpc
    Found in: google.golang.org/grpc@v1.82.1
    Fixed in: google.golang.org/grpc@v1.82.2
    Example traces found:
      #1: cmd/server/main.go:263:32: server.main calls grpc.Server.Serve, which eventually calls transport.http2Server.HandleStreams

Vulnerability #16: GO-2026-6348
    Heap Memory Exhaustion (OOM) via HTTP/2 DATA Frame Fragmentation in
    google.golang.org/grpc
  More info: https://pkg.go.dev/vuln/GO-2026-6348
  Module: google.golang.org/grpc
    Found in: google.golang.org/grpc@v1.82.1
    Fixed in: google.golang.org/grpc@v1.83.1
    Example traces found:
      #1: internal/handlers/runs.go:447:25: handlers.RunService.StreamRunEvents calls grpc.GenericServerStream[github.com/dshakes/lantern/gen/go/lantern/v1.StreamRunEventsRequest, github.com/dshakes/lantern/gen/go/lantern/v1.StreamEvent].Send, which eventually calls mem.BufferSlice.MaterializeToBuffer
      #2: internal/handlers/runs.go:447:25: handlers.RunService.StreamRunEvents calls grpc.GenericServerStream[github.com/dshakes/lantern/gen/go/lantern/v1.StreamRunEventsRequest, github.com/dshakes/lantern/gen/go/lantern/v1.StreamEvent].Send, which eventually calls mem.IsBelowBufferPoolingThreshold
      #3: internal/handlers/runs.go:447:25: handlers.RunService.StreamRunEvents calls grpc.GenericServerStream[github.com/dshakes/lantern/gen/go/lantern/v1.StreamRunEventsRequest, github.com/dshakes/lantern/gen/go/lantern/v1.StreamEvent].Send, which eventually calls mem.NewBuffer
      #4: internal/handlers/dataplane.go:519:30: handlers.DataPlaneService.RunStream calls grpc.GenericServerStream[github.com/dshakes/lantern/gen/go/lantern/v1.DpRunStreamClientMsg, github.com/dshakes/lantern/gen/go/lantern/v1.DpRunStreamServerMsg].Recv, which eventually calls mem.ReadAll
      #5: internal/handlers/llm_proxy.go:3935:104: handlers.LlmProxyHandler.Transcribe calls multierr.multiError.Error, which eventually calls mem.writer.Write
      #6: internal/handlers/runtime.go:1984:27: handlers.RuntimeHandler.StreamLogs calls grpc.GenericClientStream[github.com/dshakes/lantern/gen/go/lantern/v1.LogsRequest, github.com/dshakes/lantern/gen/go/lantern/v1.RuntimeLogLine].Recv, which eventually calls transport.ClientStream.Close
      #7: internal/handlers/runtime.go:354:28: handlers.pumpExec calls grpc.clientStream.CloseSend, which eventually calls transport.ClientStream.Header
      #8: internal/handlers/dataplane.go:519:30: handlers.DataPlaneService.RunStream calls grpc.GenericServerStream[github.com/dshakes/lantern/gen/go/lantern/v1.DpRunStreamClientMsg, github.com/dshakes/lantern/gen/go/lantern/v1.DpRunStreamServerMsg].Recv, which eventually calls transport.ClientStream.Read
      #9: internal/handlers/dataplane.go:519:30: handlers.DataPlaneService.RunStream calls grpc.GenericServerStream[github.com/dshakes/lantern/gen/go/lantern/v1.DpRunStreamClientMsg, github.com/dshakes/lantern/gen/go/lantern/v1.DpRunStreamServerMsg].Recv, which eventually calls transport.ClientStream.RecvCompress
      #10: internal/handlers/runtime.go:354:28: handlers.pumpExec calls grpc.clientStream.CloseSend, which eventually calls transport.ClientStream.TrailersOnly
      #11: internal/handlers/schedules.go:237:33: handlers.RESTHandler.UpdateSchedule calls time.LoadLocation, which eventually calls transport.NewHTTP2Client
      #12: cmd/server/main.go:263:32: server.main calls grpc.Server.Serve, which eventually calls transport.NewServerTransport
      #13: internal/handlers/dataplane.go:519:30: handlers.DataPlaneService.RunStream calls grpc.GenericServerStream[github.com/dshakes/lantern/gen/go/lantern/v1.DpRunStreamClientMsg, github.com/dshakes/lantern/gen/go/lantern/v1.DpRunStreamServerMsg].Recv, which eventually calls transport.ServerStream.Read
      #14: internal/handlers/dataplane.go:519:30: handlers.DataPlaneService.RunStream calls grpc.GenericServerStream[github.com/dshakes/lantern/gen/go/lantern/v1.DpRunStreamClientMsg, github.com/dshakes/lantern/gen/go/lantern/v1.DpRunStreamServerMsg].Recv, which eventually calls transport.Stream.ReadMessageHeader
      #15: internal/handlers/schedules.go:237:33: handlers.RESTHandler.UpdateSchedule calls time.LoadLocation, which eventually calls transport.http2Client.Close
      #16: internal/handlers/schedules.go:237:33: handlers.RESTHandler.UpdateSchedule calls time.LoadLocation, which eventually calls transport.http2Client.GracefulClose
      #17: internal/handlers/runtime.go:354:28: handlers.pumpExec calls grpc.clientStream.CloseSend, which eventually calls transport.http2Client.NewStream
      #18: cmd/server/main.go:906:25: server.main calls grpc.Server.GracefulStop, which eventually calls transport.http2Server.Close
      #19: cmd/server/main.go:263:32: server.main calls grpc.Server.Serve, which eventually calls transport.http2Server.HandleStreams

Your code is affected by 16 vulnerabilities from 3 modules and the Go standard library.
This scan also found 3 vulnerabilities in packages you import and 19
vulnerabilities in modules you require, but your code doesn't appear to call
these vulnerabilities.
Use '-show verbose' for more details.
make: *** [Makefile:270: audit] Error 3

@github-actions

Copy link
Copy Markdown
Contributor

🔎 Codex cross-audit (agent:audit)

Blocking

  • sent-log.ts: loadOrCreateStateKey(stateDir) is evaluated as a default parameter before the function body’s try/catch. If the state key is corrupt, wrong length, unreadable, or the state dir is not writable, appendSentLog() rejects before the “best-effort” catch runs. Both bridges call it fire-and-forget without .catch() (iMessage, WhatsApp), so a logging failure can become an unhandled rejection and potentially take down the bridge. Move key loading inside the try, or catch at call sites.

Major

  • The sent-log tier/voice metadata is recorded after the send boundary consumes it. appendSentLog() calls takeDraftMeta() during send (sent-log.ts), but iMessage sends the burst at session.ts:8726 and only calls noteDraftMeta() at session.ts:8750; WhatsApp sends at session.ts:9824 and notes at session.ts:9878. Result: the current reply logs without tier/delta, and the stale metadata may attach to the next message to that handle. Move noteDraftMeta before the burst/send, or pass metadata directly to appendSentLog.

Verification note: I tried npx tsc --noEmit in the changed TS packages, but this environment has no local tsc install available and network lookup failed with EAI_AGAIN, so I could not independently confirm the new CI typecheck path.

@github-actions

Copy link
Copy Markdown
Contributor

🔷 Gemini cross-audit (agent:audit-gemini)

Summary & Verdict

Recommendation: BLOCKING CHANGES REQUIRED

While the addition of the commitment ledger follow-through, encrypted sent-reply logging, and parity tests are valuable, there is a critical sequencing bug in both bridges (imessage-bridge and whatsapp-bridge) that completely breaks the sent-reply measurement feature, alongside related edge cases in burst sending and cross-bridge execution.


1. Blocking Issues

[BLOCKING] Inverted Call Order Between Send and noteDraftMeta() Causes Undefined / Stale Sent Logs in Both Bridges

In both services/imessage-bridge/src/session.ts and services/whatsapp-bridge/src/session.ts, noteDraftMeta() is invoked after the message burst has already been sent:

  • imessage-bridge (session.ts):
    • The sending burst loop executes at line 8688:
      for (let i = 0; i < burst.length; i++) {
        ...
        await this.send(sendTarget, piece.text);
      }
    • Inside this.send(), logSentReply() calls appendSentLog(), which invokes takeDraftMeta(e.to, now).
    • But noteDraftMeta() is called at line 8749 (61 lines after send()):
      // Quality loop: remember the final tier + voice delta so the send boundary can log them.
      noteDraftMeta(row.handle, { tier: tier.tier, voiceDelta: scoreVoice(botTellCtx.voiceModel, draft)?.delta });
  • whatsapp-bridge (session.ts):
    • The burst sending loop executes at lines 9781–9830:
      for (let i = 0; i < burst.length; i++) {
        ...
        const sentId = await this.sendMessage(from, msg.text, ...);
      }
    • Inside this.sendMessage(), appendSentLog() is called at line 3618, invoking takeDraftMeta(e.to, now).
    • But noteDraftMeta() is called at line 9878 (after the burst loop completes):
      noteDraftMeta(from, { tier: tier.tier, voiceDelta: scoreVoice(botTellCtx.voiceModel, draft)?.delta });

Impact:

  1. During the actual send, takeDraftMeta() finds no entry in draftMeta for the contact handle. It always returns undefined, resulting in entries in sent-log.jsonl with tier: undefined and voiceDelta: undefined.
  2. The subsequent call to noteDraftMeta() then leaves an orphaned metadata entry in the global draftMeta map. If another message is sent to that contact before the 10-minute TTL expires, that subsequent message will consume and be falsely stamped with the previous message's tier and voiceDelta.

Fix:
Move noteDraftMeta(...) immediately before the burst send loop (before naturalize / this.send(...)), so that draftMeta is populated when send() / sendMessage() executes.


[BLOCKING] Multi-Message Bursts Drop Metadata After the First Bubble

Even when noteDraftMeta() is called before sending, takeDraftMeta() deletes the entry from draftMeta upon first retrieval:

// packages/bridge-core/src/sent-log.ts
export function takeDraftMeta(handle: string, now = Date.now()): { tier: string; voiceDelta?: number } | undefined {
  const k = handleKey(handle);
  const m = draftMeta.get(k);
  draftMeta.delete(k); // <-- DELETES on first bubble
  return m && now - m.at <= META_TTL_MS ? { tier: m.tier, voiceDelta: m.voiceDelta } : undefined;
}

Replies often split into multiple burst bubbles (burst.length > 1). For message 0, takeDraftMeta() consumes and deletes the metadata. For messages 1, 2, ..., takeDraftMeta() returns undefined.

Fix:
Either do not delete on read (rely on META_TTL_MS and clear explicitly per draft/turn), or allow takeDraftMeta to peek without deleting until the turn completes, or pass the draft metadata directly through the send context rather than relying on a global single-read side-channel map.


2. Edge Cases & Non-Blocking Issues

[MEDIUM] Uncoordinated Cross-Bridge Commitment Judging Sweeps

Both bridges now independently register maybeJudgeCommitments() running every ~6 hours:

  • In imessage-bridge: stateFile: join(this.stateDir, "commitment-ledger.json")
  • In whatsapp-bridge: stateFile: join(this.stateDir, "commitment-ledger.json")

Because this.stateDir is tenant- and bridge-specific (services/imessage-bridge/bridge_state/<tenant> vs services/whatsapp-bridge/bridge_state/<tenant>), the state files are separate.

  • Both bridges independently query /v1/commitments for open commitments and run separate LLM calls against owner::commit-judge.
  • If both bridges trigger at roughly the same time (e.g. at the quiet-hours boundary), both can see the same open commitment, judge it as fulfilled, call /done, and send duplicate ✅ closed: messages to the owner on both channels.
  • Recommendation: Colocate commitment-ledger.json under ~/.lantern/ (or coordinate via the control-plane/bridge-parity state) so a judgment run by one bridge is recognized by the other.

[LOW] Robustness of Verbatim Evidence Matching in judgeCommitments

In packages/bridge-core/src/commitments-edge.ts:

const ev = typeof it.evidence === "string" ? it.evidence.replace(/^>\s*/, "").trim() : "";
if (it.state === "done" && ev.length >= 8 && hay.includes(normWs(ev))) {

If an LLM formats the JSON evidence value with surrounding quotation marks (e.g. "evidence": "\"just sent the deck\""), normWs(ev) will contain leading/trailing quotes that do not appear in hay.

  • Recommendation: Strip wrapping quotes: it.evidence.replace(/^>\s*/, "").replace(/^["']|["']$/g, "").trim().

[LOW] Inconsistent CI Dependency Installation in sdlc-lint.yml

In .github/workflows/sdlc-lint.yml:

- name: tsc (imessage-bridge)
  run: cd services/imessage-bridge && npm ci && npm run typecheck

- name: tsc (whatsapp-bridge)
  run: cd services/whatsapp-bridge && npm install --no-audit --no-fund && npm run typecheck

Using npm install instead of npm ci for whatsapp-bridge risks silent lockfile mutation and non-reproducible builds in CI. If whatsapp-bridge/package-lock.json is out of date, sync and commit it rather than bypassing npm ci.

@dshakes

dshakes commented Oct 23, 2026

Copy link
Copy Markdown
Owner Author

Superseded by the rewritten stack #265–#275 (same changes; history rewritten to drop a 43 MB build binary committed by mistake; part 11 #275 fixes the confirmed Codex audit findings raised on this stack).

@dshakes dshakes closed this Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent:needs-fix Reviewer found Blocking issues -> triggers the auto-fix loop domain:core Core library / business logic

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant