Skip to content

feat(relay): routing-envelope wrapper type (#1)#2

Merged
ilmoniemi merged 3 commits into
mainfrom
feature/1
May 8, 2026
Merged

feat(relay): routing-envelope wrapper type (#1)#2
ilmoniemi merged 3 commits into
mainfrom
feature/1

Conversation

@ilmoniemi
Copy link
Copy Markdown
Contributor

What

Adds the canonical Go vocabulary for the relay↔binary routing envelope:

  • Envelope struct with ConnID string and Frame json.RawMessage matching the wire shape {conn_id, frame}.
  • Marshal(connID string, frame []byte) ([]byte, error) — validates non-empty conn id, validates inner-frame JSON syntax via json.Valid, emits the envelope with the inner bytes preserved verbatim (modulo whitespace).
  • Unmarshal(data []byte) (Envelope, error) — rejects malformed JSON, absent/empty/null conn_id, and absent/null frame.
  • Five exported sentinel errors (ErrEmptyConnID, ErrInvalidFrameJSON, ErrMalformedEnvelope, ErrMissingConnID, ErrMissingFrame) so downstream code branches via errors.Is instead of string matching.

The opacity invariant is enforced by json.RawMessage: the relay never deserialises the inner frame on either side.

Issue

Closes #1

Testing

  • internal/relay/envelope_test.go:
    • Round-trip with a non-trivial nested-JSON frame (unicode, null, arrays); asserts bytewise equality after json.Compact to honour the "modulo whitespace" caveat.
    • Marshal rejection table: empty conn id, nil/empty/garbage/truncated frame.
    • Unmarshal rejection table: malformed JSON, non-object payload, missing/empty/null conn_id, missing/null frame.
    • All rejection assertions go through errors.Is.
  • make vet, make test (with -race), make build all clean.

Architecture compliance

  • Lives in internal/relay/ next to doc.go per the spec; no edits to cmd/pyrycode-relay/main.go (binary still builds unchanged).
  • Stdlib only (encoding/json, errors, bytes, fmt).
  • Pure functions, no goroutines, no I/O — matches the "library code" framing in the spec.
  • Marshal validates inner-frame syntax but never semantics; Unmarshal collapses absent/empty/null conn_id and absent/null frame into single sentinels per the spec's algorithm.
  • Out-of-scope items (conn_id format, networking, registry, header validation) deliberately untouched.

🤖 Generated with Claude Code

ilmoniemi and others added 2 commits May 8, 2026 18:58
Adds Envelope plus Marshal/Unmarshal helpers and five sentinel errors in
internal/relay/envelope.go, with exhaustive table-driven tests covering
the round-trip opacity invariant and every documented rejection case.

Frames stay opaque to the relay: json.RawMessage preserves the inner
bytes verbatim (modulo JSON whitespace), and Marshal validates only
syntax via json.Valid. Unmarshal collapses absent/empty/null conn_id
and absent/null frame into ErrMissingConnID / ErrMissingFrame.

Issue: #1
Testing: go test -race ./..., go vet ./..., make build all clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor Author

@ilmoniemi ilmoniemi left a comment

Choose a reason for hiding this comment

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

Code Review: #1

Decision: PASS

Findings

  • [NIT] internal/relay/envelope.go:59-60 — Doc says Unmarshal returns ErrMalformedEnvelope "wrapping the underlying decoder error". The implementation at line 70 uses fmt.Errorf("%w: %v", ErrMalformedEnvelope, err) — the json decoder error is included in the message text only, not wrapped in the unwrap chain. So errors.Is(err, ErrMalformedEnvelope) works, but errors.As(err, &jsonSyntaxErr) would not. Either drop "wrapping" from the doc, or use Go 1.20+ multi-%w (fmt.Errorf("%w: %w", ErrMalformedEnvelope, err)) to make the doc accurate. Non-blocking.
  • [NIT] internal/relay/envelope.go:75 — bytes.Equal(env.Frame, []byte("null")) works; string(env.Frame) == "null" would let you drop the bytes import. Either is fine.

Summary

Faithful, minimal implementation of the spec. Envelope + Marshal + Unmarshal + five sentinel errors, stdlib-only, pure functions, no concurrency or I/O. Doc comments on every exported symbol, each naming the opacity invariant.

Validation hits the right boundaries:

  • Marshal rejects empty connID and invalid-JSON frame (covering nil/empty/garbage/truncated in one json.Valid call).
  • Unmarshal collapses absent/empty/null conn_id into ErrMissingConnID and absent/null frame into ErrMissingFrame — the explicit bytes.Equal(env.Frame, []byte("null")) check is necessary because json.RawMessage retains the literal four bytes for "frame": null and would otherwise pass the length check. Good catch.
  • Inner-frame contents are never parsed on the read path, preserving the opacity invariant.

Tests cover every row of both rejection tables plus the round-trip with nested JSON, unicode, and a null value. Assertions go through errors.Is, not string compare. t.Parallel() used on outer tests and subtests. The opacity assertion uses json.Compact on both sides, which correctly implements the "modulo whitespace" caveat without flaking on encoder formatting. make vet, make test (with -race), and make build all clean.

The defensive error-wrap at envelope.go:49-53 is dead in practice (the only non-static field is a pre-validated json.RawMessage), but the in-line comment names exactly that, so it reads as deliberate rather than cargo-culted. Fine.

Adds knowledge index, feature doc, ADR-0001, project memory,
and lessons covering the routing-envelope wrapper landed in #1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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.

relay: routing-envelope wrapper type — marshal, unmarshal, tests

1 participant