NATS JetStream Publishing and Redelivery APIs

Claude Code·Opus 4.8[1m]·nodo·3mo ago·1hr 22min·2 Checkpoints·13 file changes·+1091/-112·173.7K tokens

I've oriented myself. Here's where things stand while I finish gathering the reference contracts.

What Phase 2 needs (from COR-984): deepen natsmsg.Publisher and backoff.Policy so they're complete enough to replace the proven production publishers/redelivery loops in Phase 3 — without widening what the shared package owns.

Key facts I've established:

  • natsmsg.Publisher and backoff.Policy are not yet consumed anywhere in production (entiredb/entire-api/mirror-pipeline only use the natsmsg trace-context + DLQ helpers on go-nuts v0.7.0). So I can reshape those two APIs cleanly; the constraint is matching the production envelopes exactly, not source-compat.
  • The central gap is adapters: Publisher today only speaks the modern jetstream.JetStream, but mirror-pipeline's natspub (the primary publishing reference) speaks the legacy nats.JetStreamContext (PublishMsg(m, opts...) + nats.Context). Same split on the redelivery side — backoff only drives jetstream.Msg, but the legacy *nats.Msg has variadic-opt Nak/Term/Metadata signatures that don't fit the same interface.
  • mirror-pipeline's natspub also proves the need for caller-selected operation names (SpanName: "github_meta.publish") and a messaging.operation.type=publish attribute that go-nuts' Publisher doesn't set yet.

I've dispatched three parallel surveys (entiredb, entire-api, mirror-pipeline callers) to pin the exact delay sequences, disposition matrices, span names, and JetStream client types each production caller uses — so the shared API preserves them precisely. Baseline build/tests are green, and I'm on branch nodo/cor-984-....

Once the surveys return I'll bring you a concrete API-shape proposal (how the legacy/modern adapters are exposed) before writing the implementation, since that shape is what Phase 3 will build against.

3mo ago·13m

The user would like to clarify before answering. What questions or context do you have? I'm happy to go deeper on any of the trade-offs — for example the parity-testing implications, how each shape affects the Phase 3 migration of the legacy callers (entiredb repoops/gitjobs publishers, mirror-pipeline's natspub and fanoutengine), the normalized-PubAck decision, or anything else on your mind.

gien my goal is to migrate everything to the modern interface, which approach is most suitable?

3mo ago·49m

Done. Here's the final state.

COR-984 shipped — PR #10 open against main

  • Committed on nodo/cor-984-... (5591f31); the post-commit hook added Entire-Checkpoint: 2e9096fe3f89 automatically.
  • Pushed and PR #10 opened targeting main with a full summary.

What it delivers — the shared publishing (natsmsg.Publisher) and redelivery (backoff.Policy) seams now speak both the modern jetstream.JetStream/jetstream.Msg API (unchanged — the migration destination) and the legacy nats.JetStreamContext/*nats.Msg API via LegacyPublisher / NakOrTermLegacy bridges. Each modern+legacy pair delegates to a single unexported core, so the two are parity-tested against one behavioral contract and the Legacy* symbols delete cleanly when Phase 3 finishes migrating callers. Production envelopes (repoops 5s→30s, permswebhook configurable ceiling, ingest 1s→300s) are pinned, and the full disposition matrix is covered.

All gates green: go test -race ./..., golangci-lint run (0 issues), go build, go mod tidy -diff, git diff --check.

One step remains, gated on merge: cutting the v0.8.0 tag on main for Phase 3 consumers (COR-985). Go-module convention puts that tag on the merged main commit, so once PR #10 merges, tell me and I'll cut it — or you can. That's the only piece of the acceptance criteria not yet closable from here.

A couple of notes for review, since they were judgment calls:

  • The backoff metadata-unavailable fallback is now base NakDelay (entire-api's old backoffFor returned a degenerate 5s). The delay envelope is preserved exactly; only that error-path fallback was unified, and it matches what entiredb already did. Called out in the code docs.
  • DLQPublisher became a type alias of the new LegacyJetStream (they were structurally identical, and iface lint flagged the duplication) — no behavior change.
5591f31feat(natsmsg,backoff): legacy JetStream publish + redelivery bridges Phase 2 of the go-nuts convergence (COR-984): deepen the shared publishing and redelivery seams so they can replace the proven production implementations in Phase 3, without widening what the shared package owns. Publishing (natsmsg): - Publisher gains a caller-selected Operation span name (recorded as messaging.operation.name); StartProducerSpan now sets the standard messaging.operation.type=publish attribute. - New LegacyPublisher + LegacyJetStream bridge the legacy nats.JetStreamContext API, returning its native *nats.PubAck. Both publishers delegate to one unexported publish core, so the only step that differs is the actual PublishMsg call. DLQPublisher is now an alias of LegacyJetStream (identical publish surface). Redelivery (backoff): - Add NakOrTermLegacy / NumDeliveredLegacy / IsFinalDeliveryLegacy over a LegacyMsg (*nats.Msg) interface; the modern jetstream.Msg entry points are unchanged. Both drive one unexported disposer core. - Add UnlimitedMaxDeliver=-1 and document the zero-value/unlimited semantics and the jsconsumer.EffectiveMaxDeliver() bridge. Metadata-unavailable is consolidated to base NakDelay. The modern API is the migration destination and stays pristine; the Legacy* symbols are a transitional bridge that deletes cleanly once no caller remains. Tests: legacy/modern parity against the shared core, the full disposition matrix (first/intermediate/final, metadata failure, overflow, unlimited, Nak/Term errors), production envelope pinning (repoops 5s→30s, permswebhook configurable ceiling, ingest 1s→300s), DLQ-before-disposition, legacy publish integration against an embedded broker, and cross-package MaxDeliver consistency. natsmsgtest gains FakeLegacyMsg and FakeMsg NakErr/TermErr. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014AwxtNg8JzfS82AiMEYnfM Entire-Checkpoint: 2e9096fe3f89+1037/-83

two P2s: 1. [P2] Preserve caller-owned span attributes. natsmsg/publish.go:51 exposes no way to add domain attributes to its internally owned producer span. Phase 3 replacements would lose attributes such as placement and job identity currently passed through mirror-pipeline’s natspub (https://github.com/entirehq/mirror-pipeline/blob/878e8eaf3036f58a2e635a1503bdd3c56278abd4/internal/natspub/natspub.go). Wrapping Publish in another span changes span topology and leaves the actual publish span untagged. Add caller-supplied attributes/options forwarded to StartProducerSpan.

  1. [P2] Don’t store application span names as messaging.operation.name. natsmsg/publish.go:176 records values such as repo.ops.publish in that semantic field. The pinned OTel convention defines it as the system-specific messaging operation—examples include ack, nack, and send (/Users/nodo/go/pkg/mod/go.opentelemetry.io/otel@v1.44.0/semconv/v1.41.0/attribute_group.go:11447). Keep the caller value as the span name, while setting messaging.operation.name to the NATS operation (publish) or omitting it.
3mo ago·5m

Both P2 fixes are committed (ac3f0ac, checkpoint 3087f9932a47) and pushed — PR #10 now includes them.

What changed

P2.1 — caller-owned span attributes preserved. Publisher.Publish and LegacyPublisher.Publish now take a variadic attrs ...attribute.KeyValue, forwarded onto the internally-owned producer span through StartProducerSpan alongside the standard messaging.* set. So a Phase 3 replacement of mirror-pipeline's natspub (or the entiredb publishers) keeps its placement / job-identity tags on the actual publish span — no extra wrapping span, no topology change. The signature stays backward-compatible (existing zero-arg calls still compile).

P2.2 — messaging.operation.name no longer misused. I confirmed against the pinned semconv (v1.41.0): operation.name is the system-specific op (send/ack/nack), and the operation.type enum is create/send/receive/process/settle. The production implementations being replaced set operation.type="publish" (a valid low-cardinality custom value) and no operation.name. So I dropped operation.name entirely — the caller's Operation value is now the span name only, and operation.type=publish continues to carry the operation. This matches the production spans exactly (no added/changed attribute that would break existing dashboards) and stops leaking the app span name into the wrong semantic field.

The parity test now forwards domain attrs and asserts they land on the span while messaging.operation.name stays unset. All gates green again: go test -race ./..., golangci-lint (0 issues), gofmt, go mod tidy -diff, git diff --check.

The v0.8.0 tag on main after merge remains the only open item.