Deepen Publishing and Redelivery APIs

Claude Code·Opus 4.8[1m]·nodo·1mo 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.

<task-notification> <task-id>a0816d0e126b88e4a</task-id> <tool-use-id>toolu_0143Sqtt3P3w98oAY23NySGR</tool-use-id> <output-file>/private/tmp/claude-501/-Users-nodo-work-tasks-COR-984-go-nuts-go-nuts/033c3992-c4df-4e86-a73c-f74bbec4bd33/tasks/a0816d0e126b88e4a.output</output-file> <status>completed</status> <summary>Agent "Survey mirror-pipeline job publishers" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>I now have everything needed. Here is my report.

natspub callers in mirror-pipeline — findings for the shared Publisher API

The shared core being mirrored

internal/natspub/natspub.go defines the contract every caller uses. Key facts (confirming what you read):

  • Legacy JetStream only. The publisher interface is the classic nats.JetStreamContext shape, and Publish calls PublishMsg with a nats.Context timeout. No modern jetstream.JetStream anywhere on the publish path (see section 3).
    • natspub.go:37-40type JetStreamPublisher interface { PublishMsg(m *nats.Msg, opts ...nats.PubOpt) (*nats.PubAck, error) }
    • natspub.go:109-111pubCtx, cancel := context.WithTimeout(ctx, AckWait); ... ack, err := js.PublishMsg(msg, nats.Context(pubCtx))
  • natspub.go:35const AckWait = 5 * time.Second
  • natspub.go:26const MsgIDHeader = "Nats-Msg-Id"
  • The Publication struct (natspub.go:44-68) has exactly the fields you listed: Tracer, SpanName, ErrKind, LogMsg, Subject, MsgID, Marshal func() ([]byte, error), SpanAttrs []attribute.KeyValue, LogAttrs []slog.Attr.
  • Publish (natspub.go:80-131) prepends a fixed messaging.* triplet to SpanAttrs, starts a SpanKindProducer span on otel.Tracer(pub.Tracer), runs Marshal inside the span, sets the Nats-Msg-Id header, calls otelnats.Inject(ctx, msg), publishes, then stamps messaging.nats.sequence / messaging.nats.duplicate and logs one INFO line.
  • Helper natspub.PlacementAttrs(jurisdiction, cluster) (natspub.go:72-77) returns entire.target_jurisdiction + entire.target_cluster_id.

1. The callers of natspub.Publish / natspub.Publication

There are six call sites of natspub.Publish, all constructing a natspub.Publication literal. Each typed publisher is a thin Publisher{ js JetStreamPublisher } wrapper (JetStreamPublisher = natspub.JetStreamPublisher, NatsMsgIDHeader = natspub.MsgIDHeader), delegating mechanics to natspub and owning only the domain parts.

Package (file:line)TracerSpanName (caller-selected op name)ErrKindLogMsg
pkg/metajobs/publisher.go:31"metajobs""github_meta.publish""meta job""Published github_meta job"
pkg/prjobs/publisher.go:31"prjobs""github_pr.publish""pr job""Published github_pr job"
pkg/pushauthorjobs/publisher.go:34"core-fanout""github_push_author.publish""push-author job""Published github_push_author job"
pkg/donejobs/publisher.go:31"donejobs""mirror.done.publish""done job""Published mirror completion"
internal/mirrorjobs/publisher.go:40"mirrorjobs""mirror.git.publish""git mirror job""Published git mirror job"
pkg/webhookevents/publisher.go:89"webhookevents""webhook.publish""forwarded webhook""Published forwarded webhook"

Note two things the shared API must accommodate:

  • Tracer name is not always the package name. pushauthorjobs sets Tracer: "core-fanout" (the binary/scope name), while its SpanName is "github_push_author.publish". Tracer and SpanName are fully independent, caller-chosen strings.
  • SpanName conventions vary: dotted domain names like github_meta.publish, mirror.done.publish, mirror.git.publish, webhook.publish. All are literal strings passed straight to otel.Tracer(Tracer).Start(ctx, SpanName, ...).

Representative construction details

metajobs (pkg/metajobs/publisher.go:30-42) — the canonical placement-scoped shape:

  • Subject (pkg/metajobs/jobs.go:62-64): SubjectPrefix + jurisdiction + "." + cluster.
  • MsgID (jobs.go:115-117): strconv.FormatInt(githubRepoID,10) + "/" + observedAt.UTC().Format(time.RFC3339Nano) — stable across redeliveries so republishes collapse in the dedup window.
  • Marshal: closure over Marshal(job) which stamps schema version, validates, then json.Marshal.
  • SpanAttrs: PlacementAttrs(...) appended with job.SpanAttrs() (jobs.go:171-177: entire.target_ulid, entire.github_repo_id, entire.source_repo).
  • LogAttrs (jobs.go:180-186): unprefixed counterparts target_ulid, github_repo_id, source_repo.

pushauthorjobs (pkg/pushauthorjobs/publisher.go:33-52) — shows a caller building SpanAttrs/LogAttrs inline rather than from job methods, and mixing attribute types:

Note the attribute value types used across callers: attribute.String, attribute.Int64, attribute.Int (the shared API's SpanAttrs []attribute.KeyValue already covers all of these).

mirrorjobs (internal/mirrorjobs/publisher.go:30-51) — most complex Subject/MsgID derivation; builds a gitjobs.ParsedSubject and composes MsgID inline with fmt.Sprintf:

Takeaway: not every caller uses PlacementAttrs; SpanAttrs is an arbitrary caller-assembled slice.

webhookevents (pkg/webhookevents/publisher.go:81-106) — the one caller that wraps natspub.Publish with its own metrics (counter + latency histogram, recorded in a defer), and builds attrs fully inline (no placement, no job methods):

Subject(f.Event) and MsgID(f.DeliveryID) (events.go:114, :141). This one is worth noting for the shared API: the caller layers outcome/duration metrics around the shared publish call, classifying the returned error (classifyPublishErrorstream_full/other). If the shared Publisher is to support this, it must return the raw publish error unchanged (natspub does: fmt.Errorf("publish %s: %w", ...) preserves the NATS error for strings.Contains(err.Error(), "maximum bytes exceeded")).

donejobs (pkg/donejobs/publisher.go:30-42) mirrors metajobs exactly, with MsgID(job.RepoULID, job.Status) and SpanName: "mirror.done.publish".

Summary of what Publication must express (confirmed by real callers)

  • Independent, free-form Tracer and SpanName strings (tracer ≠ package name in pushauthorjobs; span names use varied dotted conventions).
  • ErrKind and LogMsg as free strings.
  • Subject fully resolved caller-side (placement concatenation, or gitjobs.Subject(parsed), or Subject(event)).
  • MsgID derived caller-side by many strategies: numeric-id/timestamp, a/b concatenation, 5-part fmt.Sprintf, or a bare delivery id.
  • Marshal as a closure (may stamp version + validate before JSON, or plain json.Marshal).
  • SpanAttrs as an arbitrary []attribute.KeyValue (String/Int64/Int seen), sometimes PlacementAttrs(...)-based, sometimes subject-derived, sometimes fully inline. LogAttrs as arbitrary []slog.Attr.
  • The helper PlacementAttrs(jurisdiction, cluster) is shared by the four placement-scoped publishers; the other two don't use it. Worth carrying into the shared API as an optional helper, not a required field.

2. pkg/otelnats vs go-nuts natsmsg

mirror-pipeline pkg/otelnats/nats.go (otelnats.Inject, lines 39-44):

It adapts nats.Header to propagation.TextMapCarrier via HeaderCarrier (Get/Set/Keys). Extract (lines 48-53) does the mirror. This is the only otelnats helper on the publish path — natspub.Publish calls otelnats.Inject(ctx, msg) at natspub.go:106, right after setting the Nats-Msg-Id header. No other otelnats helper is used when publishing (Extract is consumer-side).

go-nuts natsmsg/tracecontext.go (Inject, lines 58-63) is byte-for-byte the same body — identical HeaderCarrier Get/Set/Keys and the same Inject implementation. The go-nuts package is the superset/unification (its doc at tracecontext.go:1-10 says it unifies entire-core's internal/natsmsg and entire-api's internal/otelnats, "itself ported from mirror-pipeline"; tracking COR-929). Differences that matter for the shared API:

  1. natsmsg.HeaderCarrier additionally implements propagation.ValuesGetter via Values(key) (tracecontext.go:38-41, asserted at :51-54). The comment explains why: without it the baggage propagator falls back to Get and reads only the first of multiple same-named headers, truncating multi-header W3C baggage. mirror-pipeline's otelnats.HeaderCarrier has no Values method — so it has that latent baggage-truncation bug the go-nuts version fixes.
  2. natsmsg.Extract delegates to ExtractHeader(ctx, msg.Header) and adds ExtractHeader(ctx, h nats.Header) (:67-79) so it works for both *nats.Msg (msg.Header) and modern jetstream.Msg (msg.Headers()). mirror-pipeline's otelnats.Extract only takes *nats.Msg.
  3. natsmsg bundles extras not in otelnats: StartConsumerSpan(...) (:92-99, collapses Extract+Start with standard messaging attrs) and ClampToInt64 (:103-108, saturating uint64→int64 for sequence/delivery-count attributes). These are consumer-side conveniences; the publish path only needs Inject.

Net: for the publish API, go-nuts natsmsg.Inject is a drop-in replacement for otelnats.Inject (same behavior) and strictly better on the carrier (ValuesGetter). The shared publisher should call natsmsg.Inject.

3. Legacy vs modern JetStream, and consumer-side redelivery/backoff

Publish path is legacy nats.JetStreamContext exclusively. grep for the modern API import (github.com/nats-io/nats.go/jetstream) across the whole repo (excluding vendor) returns nothing. The only occurrence of the token jetstream. is a comment in pkg/fanoutengine/engine.go:12 ("The modern jetstream.Consume shape lands when this graduates to the standalone module"). Every publish uses PublishMsg(m *nats.Msg, opts ...nats.PubOpt) (*nats.PubAck, error):

  • natspub.go:39,111 (the shared core)
  • Direct PublishMsg callers outside natspub: pkg/webhookdlq/publisher.go:127, cmd/rekicker/rekick.go:63,223, cmd/meta-fanout/{lifecycle.go:156,backfill.go:97,backfill_prs.go:85}, cmd/mirror-pipeline-admin/{rekick.go:109,dlq.go:387}. All legacy.
  • webhookdlq and the meta-fanout backfill/lifecycle publishers reuse natspub.AckWait / natspub.MsgIDHeader but hand-roll their own PublishMsg + span rather than going through natspub.Publish (a sign the shared API may want to cover a few more shapes, but they're still legacy PublishMsg).

Consumer-side redelivery/backoff = pkg/fanoutengine/engine.go (this is the COR-762 work). The engine is explicitly kept "on the current legacy nats.JetStreamContext API" (engine.go:11-13); it holds a js nats.JetStreamContext (:216) and subscribes via js.PullSubscribe(...) (:299).

Two distinct backoffs:

  • Subscribe-retry backoff (engine.go:40-45, :307-313): backoffInit = 1s, backoffMax = 30s, doubling backoff = min(backoff*2, backoffMax) between failed PullSubscribe attempts.
  • Per-message redelivery via NakWithDelay(c.cfg.NakDelay) — a flat delay (all three fanout binaries set NakDelay = consumerFailureDelay = 30s, MaxDeliver = 8 = first attempt + 7 retries, AckWait = 30s; cmd/fanout/consumer.go:25,30,35, cmd/meta-fanout/consumer.go, cmd/core-fanout/consumer.go).

The Nak/Term decision tree lives in nakOrExhaust (engine.go:605-640) and the panic path handlePanic (:667-697):

  • Term() immediate, no redelivery, for permanent errors: bad envelope (engine.go:483-487, ReasonBadEnvelope) and *InputError from the resolver (:511-526, ReasonInputError or a caller sub-reason like repo_gone).
  • isFinalDelivery (:717-722): meta.NumDelivered &gt;= uint64(maxDeliver).
  • TermOnExhaustion (Config field, :193-198) is the COR-762 fix: on a work-queue stream a Nak on the final delivery leaves the message un-acked and orphaned until max_age, so instead the engine Term()s on exhaustion (reason max_deliveries) even without a DLQ. In this repo it's enabled only on the meta-fanout backfill consumers (cmd/meta-fanout/backfill_consumer.go:267, backfill_prs_consumer.go:246, both with NakDelay: consumerFailureDelay); the webhook/durable fanout consumers leave it off (plain Nak, or Term + DLQ capture). The panic path honors it too (engine.go:685-692: Term the poison-pill on final delivery to drain the queue).
  • Fan-out publish aggregation: dispatchRoutes publishes all routes, then Naks if any failed (:568-579), with an in-process route memo so already-PubAck'd regions are skipped on redelivery (:537-556) — because mirror_git_v1's 1s server-side dedup window does not cover the ~30s redelivery (:498-507).

The AckableMsg interface the engine narrows to (engine.go:203-209) is the concrete signature surface for the Ack/Nak/Term/delay contract: Ack, Nak, NakWithDelay(delay time.Duration, ...), Term, Metadata() (*nats.MsgMetadata, error) — all legacy nats.AckOpt-based.

Bottom line for the shared API design

  • The shared Publisher/Publication must keep Tracer and SpanName as independent free strings (pushauthorjobs proves they diverge) and SpanAttrs/LogAttrs as fully caller-assembled slices (mirrorjobs and webhookevents don't use PlacementAttrs). All six caller-selected operation names above are just literal SpanName values — the current struct already expresses every one.
  • It must return the underlying publish error unwrapped-enough for callers like webhookevents to string-match (%w wrapping is sufficient).
  • Swap otelnats.Inject → go-nuts natsmsg.Inject (identical body, plus the ValuesGetter baggage fix).
  • Keep it on legacy PublishMsg + nats.Context(timeout) — that's what 100% of the mirror-pipeline publish path uses today; the modern jetstream.JetStream API appears nowhere.</result> <usage><subagent_tokens>68628</subagent_tokens><tool_uses>20</tool_uses><duration_ms>181127</duration_ms></usage>
</task-notification>
1mo ago·13m·8 calls

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?

1mo ago·49m·64 calls

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.
1mo ago·5m·13 calls

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.