Skip to content

feat(net): WebSocket transport contract — net-ws-api + net-ws-mock (ADR-0032/0033)#65

Merged
NotAProfDev merged 9 commits into
mainfrom
feat/net-ws-contract
Jul 4, 2026
Merged

feat(net): WebSocket transport contract — net-ws-api + net-ws-mock (ADR-0032/0033)#65
NotAProfDev merged 9 commits into
mainfrom
feat/net-ws-contract

Conversation

@NotAProfDev

@NotAProfDev NotAProfDev commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Closes #63.

Implements the WebSocket transport contract slice — the WS twin of the merged HTTP contract (#60) — per ADR-0032 as amended by ADR-0033 §5. Built test-first, one commit per task, each spec+quality reviewed; a final whole-branch review (opus) returned ready to merge, 0 Critical / 0 Important.

New crate oath-adapter-net-ws-api (zero-runtime, zero-I/O)

  • Frame/CloseFrame — RFC 6455 frame vocabulary; untyped duplex channel with is_data/is_control (§1/§3).
  • WsError + HasErrorKind — one concrete transport error (transport/middleware failures only; venue data flows through as Ok frames). Mirrors HttpError; Closed(Option<CloseFrame>)ErrorKind::Connection.
  • WsSink/WsSource — the split owned halves (§2): one-shot RPITIT send + terminal close(self) (post-close send is a compile error); WsSource is a blanket Stream<Item = Result<Frame, WsError>> marker (the HttpClient pattern).
  • ConnState/LifecycleSnapshot/Lifecycle/LifecycleSender — the epoch-stamped, out-of-band lifecycle channel: a last-value watch over runtime-neutral async-watch (§4 + ADR-0033 §5). Never-block producer, level/monotonic-cumulative fields, terminal changed(), lossless coalescing via the epoch delta.
  • WsConnector — the leaf dependency-inversion seam (§7): connect(http::Request<()>)(Sink, Source, Lifecycle).

New dev-only crate oath-adapter-net-ws-mock

MockWsConnector (scriptable frames + injectable connect errors, per-connection lifecycle sender, recorded handshakes/sends/close), MockSink, MockSource. Consumed only via [dev-dependencies] — no dev-dep cycle.

Charter / constraints

net-ws-api stays free of any async runtime — the only new production dep is async-watch, verified to pull only event-listener (no tokio) and to clear cargo deny. No unsafe/unwrap/expect/indexing in non-test code. just ci green (fmt, lint, test, deny, doc, machete, …); 18 crate tests, 50/50 workspace.

Deferred to later slices (correctly absent here)

AuthSource, the ADR-0033 resilience stack (Spawn, reconnect actor, heartbeat, dual-bound buffer, SendRateLimit, stack(), WsConfig, ReconnectingConnector/WsControl), the net-ws-tungstenite leaf, and MockTimer/MockSpawn.

Notes for the resilience slice (from review)

  • ConnState is #[non_exhaustive] — downstream matches carry a wildcard arm (intentional forward-compat).
  • Harden the epoch invariant (ConnState variant epoch == snapshot.epoch) via constructors before the reconnect actor writes snapshots.
  • futures-util (dev-only today) needs explicit minimal features when promoted to a production dep.
  • http::Request<()> is not Clone → the reconnect actor rebuilds the handshake per attempt (aligns with per-reconnect AuthSource re-stamping).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a WebSocket transport contract, including frame vocabulary (data/control + close), a unified WebSocket error type, split connection halves (send/receive), and epoch-based connection lifecycle tracking.
    • Added a WebSocket mock harness to script connection outcomes, record sent frames/close calls, and drive lifecycle changes for verification.
  • Documentation
    • Documented the new WebSocket transport crate in the domain layout and dependency graph.
  • Tests
    • Added unit tests covering the connector seam, frame/error behavior, lifecycle semantics, and mock scripting/recording.

NotAProfDev and others added 7 commits July 3, 2026 06:25
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 46040107-e237-4ac0-9d28-7f7c7c50fcb8

📥 Commits

Reviewing files that changed from the base of the PR and between 8686374 and 7ab6140.

📒 Files selected for processing (1)
  • crates/adapter/net/ws/api/src/error.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/adapter/net/ws/api/src/error.rs

📝 Walkthrough

Walkthrough

This PR adds the WebSocket transport contract crate oath-adapter-net-ws-api, the mock test harness crate oath-adapter-net-ws-mock, workspace wiring, and documentation updates. It also adds a planning document for the contract slice.

Changes

WebSocket Transport Contract

Layer / File(s) Summary
Workspace wiring and manifests
Cargo.toml, crates/adapter/net/ws/api/Cargo.toml, crates/adapter/net/ws/mock/Cargo.toml
Adds new crate members, workspace dependency entries, async-watch, and manifests for both new crates.
Frame vocabulary and WsError type
crates/adapter/net/ws/api/src/frame.rs, crates/adapter/net/ws/api/src/error.rs
Defines Frame/CloseFrame with data/control classification and the WsError enum with constructors, error-kind mapping, and tests.
WsSink, WsSource and lifecycle channel
crates/adapter/net/ws/api/src/sink.rs, crates/adapter/net/ws/api/src/source.rs, crates/adapter/net/ws/api/src/lifecycle.rs
Defines owned send/receive-half traits and the epoch-stamped lifecycle watch channel, with tests.
WsConnector seam and crate entrypoint
crates/adapter/net/ws/api/src/connector.rs, crates/adapter/net/ws/api/src/lib.rs
Defines the WsConnector trait yielding sink/source/lifecycle handles from a handshake, and wires crate re-exports.
MockWsConnector, MockSink, MockSource test harness
crates/adapter/net/ws/mock/src/connector.rs, crates/adapter/net/ws/mock/src/sink.rs, crates/adapter/net/ws/mock/src/source.rs, crates/adapter/net/ws/mock/src/lib.rs
Implements a scriptable mock connector/sink/source with connection recording for tests.
Documentation, changelog and implementation plan
README.md, CHANGELOG.md, docs/superpowers/plans/2026-07-02-net-ws-contract.md
Updates domain layout/dependency graph docs, adds a changelog entry, and adds a detailed implementation plan document.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Possibly related PRs

  • NotAProfDev/oath#62: Implements the same oath-adapter-net-ws-api/oath-adapter-net-ws-mock primitives described by the WS contract slice.

Suggested labels: enhancement

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title follows Conventional Commits and clearly describes the WebSocket transport contract work.
Linked Issues check ✅ Passed The changes implement the required WS API and mock crates, including frames, errors, sink/source, lifecycle, and connector.
Out of Scope Changes check ✅ Passed No unrelated code changes are evident; the added docs and plan align with the linked WebSocket contract slice.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/net-ws-contract

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

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

🧹 Nitpick comments (3)
crates/adapter/net/ws/api/src/error.rs (1)

30-32: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider surfacing close code/reason in the Closed variant's Display message.

WsError::Closed's #[error("connection closed by peer")] message doesn't include the RFC 6455 code/reason carried in CloseFrame, even though the data is captured. Logs formatted via {err} will lose this diagnostic detail.

♻️ Optional: interpolate close details into the error message
-    /// The peer closed the connection (close frame, possibly with code+reason).
-    #[error("connection closed by peer")]
+    /// The peer closed the connection (close frame, possibly with code+reason).
+    #[error("connection closed by peer{}", .0.as_ref().map_or_else(String::new, |c| format!(" (code={}, reason={})", c.code, c.reason)))]
     Closed(Option<CloseFrame>),
🤖 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 `@crates/adapter/net/ws/api/src/error.rs` around lines 30 - 32, The
WsError::Closed display text is too generic and drops the CloseFrame diagnostics
that are already carried in the variant. Update the Closed(Option<CloseFrame>)
error message in error.rs so its Display formatting includes the close code and
reason when a frame is present, while still handling the no-frame case cleanly;
use the WsError::Closed variant and its existing #[error(...)] annotation as the
place to make this change.
crates/adapter/net/ws/api/src/lifecycle.rs (1)

21-59: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Epoch duplicated between ConnState variants and LifecycleSnapshot.

epoch is stored both in ConnState::Connected/Resumed and as LifecycleSnapshot.epoch. Only LifecycleSnapshot::connected() keeps them in sync; nothing at the type level stops a future caller (e.g. the reconnect actor in a later slice) from constructing a snapshot where the two values diverge, which would break the "epoch is canonical" invariant the module doc describes.

Since this is explicitly called out as an intentional design choice in the doc comments (epoch "echoes" the outer field) and is well covered by the current tests, this is a nice-to-have hardening rather than a blocker — e.g. a debug_assert_eq! in a constructor helper, or restructuring so the phase-level epoch is derived rather than duplicated, could be considered in the slice that adds writers other than connected().

🤖 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 `@crates/adapter/net/ws/api/src/lifecycle.rs` around lines 21 - 59, The epoch
value is duplicated between ConnState::Connected/Resumed and
LifecycleSnapshot::epoch, so a future constructor or writer could let them
diverge. Harden the invariant around LifecycleSnapshot::connected() and any
other snapshot builders by either deriving the phase epoch from the outer epoch
or adding a debug_assert_eq! when constructing the phase, ensuring ConnState and
LifecycleSnapshot stay synchronized.
docs/superpowers/plans/2026-07-02-net-ws-contract.md (1)

236-239: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Nested backticks in inline code trip markdownlint (MD038).

The module-doc-line instruction embeds backtick-quoted identifiers inside a single backtick-delimited span, which markdownlint flags as a space-inside-code-span issue. Purely cosmetic in a planning doc.

🤖 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 `@docs/superpowers/plans/2026-07-02-net-ws-contract.md` around lines 236 - 239,
The module-doc-line instruction in the planning doc uses nested backticks inside
a single inline-code span, which triggers markdownlint MD038. Update the wording
around the `lib.rs` module-doc line and the `error` reference so the identifiers
are not wrapped in conflicting backtick spans, while keeping the instruction to
add `pub mod error;`, the `pub use error::{BoxError, WsError};` re-export, and
the `//! - [\`error\`] — \`WsError\` and its \`HasErrorKind\` impl` doc entry
clear and lint-safe.

Source: Linters/SAST tools

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

Nitpick comments:
In `@crates/adapter/net/ws/api/src/error.rs`:
- Around line 30-32: The WsError::Closed display text is too generic and drops
the CloseFrame diagnostics that are already carried in the variant. Update the
Closed(Option<CloseFrame>) error message in error.rs so its Display formatting
includes the close code and reason when a frame is present, while still handling
the no-frame case cleanly; use the WsError::Closed variant and its existing
#[error(...)] annotation as the place to make this change.

In `@crates/adapter/net/ws/api/src/lifecycle.rs`:
- Around line 21-59: The epoch value is duplicated between
ConnState::Connected/Resumed and LifecycleSnapshot::epoch, so a future
constructor or writer could let them diverge. Harden the invariant around
LifecycleSnapshot::connected() and any other snapshot builders by either
deriving the phase epoch from the outer epoch or adding a debug_assert_eq! when
constructing the phase, ensuring ConnState and LifecycleSnapshot stay
synchronized.

In `@docs/superpowers/plans/2026-07-02-net-ws-contract.md`:
- Around line 236-239: The module-doc-line instruction in the planning doc uses
nested backticks inside a single inline-code span, which triggers markdownlint
MD038. Update the wording around the `lib.rs` module-doc line and the `error`
reference so the identifiers are not wrapped in conflicting backtick spans,
while keeping the instruction to add `pub mod error;`, the `pub use
error::{BoxError, WsError};` re-export, and the `//! - [\`error\`] — \`WsError\`
and its \`HasErrorKind\` impl` doc entry clear and lint-safe.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f2f1cdbc-a304-4b9d-9d8c-31371c0a5f89

📥 Commits

Reviewing files that changed from the base of the PR and between 19d4c74 and 8686374.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock, !**/*.lock
📒 Files selected for processing (17)
  • CHANGELOG.md
  • Cargo.toml
  • README.md
  • crates/adapter/net/ws/api/Cargo.toml
  • crates/adapter/net/ws/api/src/connector.rs
  • crates/adapter/net/ws/api/src/error.rs
  • crates/adapter/net/ws/api/src/frame.rs
  • crates/adapter/net/ws/api/src/lib.rs
  • crates/adapter/net/ws/api/src/lifecycle.rs
  • crates/adapter/net/ws/api/src/sink.rs
  • crates/adapter/net/ws/api/src/source.rs
  • crates/adapter/net/ws/mock/Cargo.toml
  • crates/adapter/net/ws/mock/src/connector.rs
  • crates/adapter/net/ws/mock/src/lib.rs
  • crates/adapter/net/ws/mock/src/sink.rs
  • crates/adapter/net/ws/mock/src/source.rs
  • docs/superpowers/plans/2026-07-02-net-ws-contract.md

CodeRabbit nitpick on #65: the Closed variant carried a CloseFrame but its
Display dropped the RFC 6455 code/reason, so {err} logs lost the detail.
Interpolate them when a frame is present; empty-frame case unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@NotAProfDev

Copy link
Copy Markdown
Owner Author

Thanks @coderabbitai — triaged the 3 nitpicks:

  • error.rs WsError::Closed Display drops code/reason — ✅ fixed in 7ab6140. The variant carried a CloseFrame but {err} logs lost it; now interpolates (code …, reason …) when a frame is present, empty-frame case unchanged. Added a Display test for both arms. just ci green.
  • lifecycle.rs epoch duplicated between ConnState and LifecycleSnapshot — ⏭️ deferred, matching your own note ("consider in the slice that adds writers other than connected()") and our pre-PR review. connected() is the sole builder today and keeps them in sync, so a debug_assert_eq! would only assert epoch == epoch. The invariant will be hardened in the ADR-0033 resilience slice when the reconnect actor becomes a second writer.
  • plan doc MD038 nested backticks — ⏭️ skipped. Cosmetic, in a planning doc, and not part of OATH's CI gate (just ci runs no markdownlint); the flagged line is a literal transcription of the //! doc line to add, so touching it is fragile for no functional gain.

@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Tip

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

@NotAProfDev — Thanks for the detailed triage, this all makes sense.

  1. WsError::Closed Display — Glad this is fixed in 7ab6140 with the code/reason interpolation and matching test coverage. That closes the gap where {err} logs silently dropped the close details.
  2. Epoch duplication — Agreed, deferring is reasonable. Since LifecycleSnapshot::connected() is currently the only constructor, the two epoch fields can't drift, so a debug_assert_eq! would indeed be a no-op today. Revisiting this once the reconnect actor becomes a second writer in the ADR-0033 resilience slice is the right call — worth a comment or TODO near ConnState/LifecycleSnapshot referencing that follow-up so it isn't lost.
  3. MD038 nested backticks in plan doc — Fair enough, it's a non-functional cosmetic nit in a planning doc outside the just ci gate. No pushback from me.

Nice work closing out the Display bug quickly.

ᕦ(ò_óˇ)ᕤ

@NotAProfDev NotAProfDev merged commit affa428 into main Jul 4, 2026
5 checks passed
@NotAProfDev NotAProfDev deleted the feat/net-ws-contract branch July 4, 2026 09:02
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.

feat(net): WebSocket transport contract — net-ws-api + net-ws-mock (ADR-0032/0033)

1 participant