feat(session_protocol): PR 13a — Wave 2 envelope spec freeze + ref impl

PYTHON_THINNING_PLAN §5 PR 13a (Wave 2 게이트). 4-team SYNTHESIS 합의:
spec drift 방지를 위해 envelope 스펙 + 최소 reference impl을 별도 PR로
분리. PR 13a land *후*에야 PR 16 (PR-A 본체)이 envelope 표준에
정합하게 빚어진다는 보장.

산출물:

- rust/crates/session_protocol/src/envelope.rs 신설:
  - ``Envelope { v, channel, kind, body }`` struct (serde Derive).
  - ``Envelope::new(channel, kind, body)`` — `v` 자동으로
    ``CHANNEL_ENVELOPE_V1`` 으로 stamp (stale version 방지).
  - ``Envelope::is_current_version()`` — forward-compat marker 검증.
  - ``reference_dispatch(&Envelope) -> Envelope`` 최소 channel router:
    - control / echo → echo_response (body reflected)
    - 미지원 channel/kind → channel_kind_unhandled error envelope
    - stale `v` → envelope_version_mismatch error envelope
  - 7 단위 테스트 (round-trip, version reject, control echo, error shape,
    null body, lenient extra-field parse).

- rust/crates/session_protocol/src/lib.rs:
  - ``pub mod envelope`` + ``pub use envelope::{Envelope,
    reference_dispatch}`` re-export.

- rust/crates/session_protocol/tests/envelope_parity.rs 신설 (5 테스트):
  - byte-for-byte NDJSON shape pin (4 field 순서 + value).
  - reference_dispatch round-trip / version reject / unknown channel.
  - cross-crate import 경로 검증 (PR 13b/PR 16에서 같은 경로 사용).

PR 13b 후속 (Wave 2 envelope 완전 구현):
- file / exec_once / lsp:* channel handlers 추가.
- per-request timeout / 취소 / 우선순위 / back-pressure.
- session_helper 측 cancellation hook (현재 lib.rs:215 "not yet implemented").

PR 16 후속 (PR-A 본체):
- ``sessions_orchestrator`` crate가 control 채널을 통해 worker queue
  dispatch. envelope shape 정합 보장은 PR 13a 의 reference_dispatch가
  컴파일 시점에 강제.

테스트: cargo test --workspace 그린 (session_protocol 5 신규 + 기존 64
+ envelope.rs 단위 7 + 다른 crate 그대로). clippy 그린 (테스트 시그니처
``Result<(), serde_json::Error>`` + ``?`` 패턴 — workspace
``unwrap_used / expect_used = "deny"`` 정합).

boundary-claim:
  removes: []
  delete-count: 0
  ban-list: 'Wave 2 envelope spec freeze — PR 16 (PR-A) 게이트'
  rust-additions: ~250 LOC (envelope.rs + parity tests)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 00:33:10 +09:00
parent 1035a75d5b
commit 0d370dee0b
4 changed files with 295 additions and 1 deletions

View File

@@ -16,7 +16,7 @@
> | PR 10 | ✅ | `b47f7eb` | file_state parity tests +26 (총 33 시나리오, amend §D paired) |
> | PR 11 | ✅ | `859c413` | file_state kind_codes 3중 복제 통합 + decision 매핑 lookup table (-85 LOC) |
> | PR 12 | ✅ | `92dd66a` | eager_hydrate parity tests +19 (총 33 시나리오, amend §D paired) |
> | **PR 13a** | Wave 2 게이트 | | envelope 스펙 + ref impl + parity (PR-A 본체 가능) |
> | **PR 13a** | Wave 2 게이트 | (TBD) | envelope 스펙 freeze + reference_dispatch + parity test 5개 |
> | PR 13b | ⏭ Wave 2 | — | envelope 완전 구현 (취소·deadline·우선순위) |
> | PR 14 | ⏭ Wave 2 | — | eager_hydrate BFS → mirror 통합 |
> | PR 14.5 | ⏭ | — | H1 file_open transaction (silent corruption) |

View File

@@ -0,0 +1,199 @@
//! Multiplex envelope (Wave 2 spec freeze — PYTHON_THINNING_PLAN.md §5 PR 13a).
//!
//! The envelope is the on-wire shape that lets a single `local_bridge ↔
//! session_helper` stdio link carry multiple logical channels (file, exec_once,
//! lsp, control, future mirror) without one slow run blocking interactive
//! traffic. Wave 2 builds cancellation, deadlines, and back-pressure on top of
//! this shape; freezing it now (PR 13a) lets PR 16 (PR-A worker loop body)
//! land while PR 13b adds the rest of Wave 2 incrementally.
//!
//! ## Wire shape
//!
//! ```text
//! { "v": "sessions.channel.v1",
//! "channel": "control", // "file" / "exec_once" / "lsp:<id>" / ...
//! "kind": "request", // "lsp_stdio.ping" / etc.
//! "body": { ... } } // channel/kind-specific payload
//! ```
//!
//! `v` is the [`crate::CHANNEL_ENVELOPE_V1`] constant so future revisions can
//! be detected at parse time. `channel` and `kind` are free-form strings; the
//! crate-level `CHANNEL_*` and `CHANNEL_KIND_*` constants define the shapes
//! every helper/bridge implementation must already accept.
//!
//! ## Spec drift guard
//!
//! `Envelope` is the **single source of truth** for the wire shape. Any code
//! that builds or parses these four fields must round-trip through
//! `serde_json::to_value` / `serde_json::from_value` of this struct — see
//! `tests/envelope_parity.rs` for the parity contract that PR 16 (PR-A) will
//! reuse to ensure its supervisor stays envelope-compatible.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::CHANNEL_ENVELOPE_V1;
/// Multiplex envelope wire shape (Wave 2 spec freeze).
///
/// Constructed via [`Envelope::new`] (which fills `v` with the canonical
/// version constant) or directly from raw JSON via `serde_json::from_value`.
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub struct Envelope {
/// Envelope version. Always [`CHANNEL_ENVELOPE_V1`] for the Wave 2 freeze.
pub v: String,
/// Logical channel routing the envelope (e.g. `"file"`, `"control"`,
/// `"lsp:<server-id>"`).
pub channel: String,
/// Channel-specific message kind (e.g. `"request"`, `"lsp_stdio.ping"`).
pub kind: String,
/// Opaque per-(channel, kind) payload. May be any JSON value, including
/// `null` for no-body messages such as control pings.
pub body: Value,
}
impl Envelope {
/// Build an envelope with `v` set to [`CHANNEL_ENVELOPE_V1`].
///
/// Prefer this over a raw struct literal so callers cannot accidentally
/// stamp a stale envelope version onto a new message.
#[must_use]
pub fn new(channel: impl Into<String>, kind: impl Into<String>, body: Value) -> Self {
Self {
v: CHANNEL_ENVELOPE_V1.to_string(),
channel: channel.into(),
kind: kind.into(),
body,
}
}
/// Return whether `self.v` matches [`CHANNEL_ENVELOPE_V1`].
///
/// Wave 2 reference implementations should reject envelopes with an
/// unknown `v` (forward-compat marker for a future rev).
#[must_use]
pub fn is_current_version(&self) -> bool {
self.v == CHANNEL_ENVELOPE_V1
}
}
/// Reference implementation of the Wave 2 envelope router (PR 13a).
///
/// Routes one envelope to its channel handler and returns a response envelope
/// (or an error envelope) on the same channel. The Wave 2 freeze ships exactly
/// one channel handler — `"control"`, which echoes the request body — so the
/// router covers every channel/kind path that the parity test exercises while
/// staying small enough to be reviewed in PR 13a.
///
/// PR 13b extends this with the `file` / `exec_once` / `lsp:*` channels;
/// PR 16 plugs the orchestrator into the `control` channel for queue
/// dispatch. The shape of the function — `Envelope -> Envelope` — is the
/// `compile-time spec drift guard` rust-maximalist asked for: any future
/// channel handler that wants to live on this transport must accept and
/// return [`Envelope`] (not raw JSON).
pub fn reference_dispatch(request: &Envelope) -> Envelope {
if !request.is_current_version() {
return Envelope::new(
request.channel.clone(),
"error",
serde_json::json!({
"code": "envelope_version_mismatch",
"expected": CHANNEL_ENVELOPE_V1,
"received": request.v,
}),
);
}
if request.channel == "control" && request.kind == "echo" {
return Envelope::new("control", "echo_response", request.body.clone());
}
Envelope::new(
request.channel.clone(),
"error",
serde_json::json!({
"code": "channel_kind_unhandled",
"channel": request.channel,
"kind": request.kind,
}),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_stamps_current_version() {
let env = Envelope::new("control", "echo", Value::Null);
assert_eq!(env.v, CHANNEL_ENVELOPE_V1);
assert!(env.is_current_version());
}
#[test]
fn round_trip_through_json_value() -> Result<(), serde_json::Error> {
let env = Envelope::new("control", "echo", serde_json::json!({"x": 1}));
let value = serde_json::to_value(&env)?;
let back: Envelope = serde_json::from_value(value)?;
assert_eq!(env, back);
Ok(())
}
#[test]
fn round_trip_through_ndjson_string() -> Result<(), serde_json::Error> {
let env = Envelope::new("file", "request", serde_json::json!({"path": "/a"}));
let line = serde_json::to_string(&env)?;
let back: Envelope = serde_json::from_str(&line)?;
assert_eq!(env, back);
Ok(())
}
#[test]
fn rejects_unknown_version_in_dispatch() {
let req = Envelope {
v: "sessions.channel.v999".to_string(),
channel: "control".to_string(),
kind: "echo".to_string(),
body: Value::Null,
};
let resp = reference_dispatch(&req);
assert_eq!(resp.kind, "error");
assert_eq!(resp.body["code"], "envelope_version_mismatch");
}
#[test]
fn control_echo_reflects_body() {
let req = Envelope::new("control", "echo", serde_json::json!({"hello": "world"}));
let resp = reference_dispatch(&req);
assert_eq!(resp.kind, "echo_response");
assert_eq!(resp.body, serde_json::json!({"hello": "world"}));
}
#[test]
fn unknown_channel_kind_returns_error() {
let req = Envelope::new("file", "tree/list", Value::Null);
let resp = reference_dispatch(&req);
assert_eq!(resp.kind, "error");
assert_eq!(resp.body["code"], "channel_kind_unhandled");
assert_eq!(resp.body["channel"], "file");
}
#[test]
fn null_body_round_trips_intact() -> Result<(), serde_json::Error> {
let env = Envelope::new("control", "ping", Value::Null);
let line = serde_json::to_string(&env)?;
let back: Envelope = serde_json::from_str(&line)?;
assert_eq!(back.body, Value::Null);
Ok(())
}
#[test]
fn extra_fields_are_rejected_for_strict_freeze() -> Result<(), serde_json::Error> {
// serde_json default for derive(Deserialize) ignores extra fields,
// which is desirable for forward-compat. This test pins that
// contract so PR 16 can rely on lenient parsing of unknown body
// shapes without a proto rev.
let raw = r#"{"v":"sessions.channel.v1","channel":"control","kind":"echo","body":null,"extra":42}"#;
let env: Envelope = serde_json::from_str(raw)?;
assert!(env.is_current_version());
Ok(())
}
}

View File

@@ -44,9 +44,11 @@ use serde_json::Value;
use std::str::Utf8Error;
pub mod compatibility;
pub mod envelope;
pub mod lsp_stdio_framing;
pub use compatibility::{HandshakeCompatibility, normalized_protocol_version};
pub use envelope::{Envelope, reference_dispatch};
pub use lsp_stdio_framing::{read_lsp_message, write_lsp_message};
/// Version string advertised by the first shared Sessions protocol skeleton.

View File

@@ -0,0 +1,93 @@
//! Wave 2 envelope parity test (PR 13a — spec freeze gate).
//!
//! Wire-shape pin for the `v` / `channel` / `kind` / `body` envelope. Any
//! future change to those four field names breaks this fixture by design —
//! Wave 2 implementations (PR 13b channel supervisor, PR 16 PR-A worker
//! body) must round-trip through this exact NDJSON shape, so the freeze
//! lives here in tests rather than buried in implementation files.
//!
//! Internal serde behaviour is covered by `envelope::tests` inside the
//! crate. This integration test exists for the *cross-crate parity*
//! contract — it imports through the public `session_protocol` re-export
//! exactly as `local_bridge` / `session_helper` / `sessions_native` will.
use session_protocol::{CHANNEL_ENVELOPE_V1, Envelope, reference_dispatch};
#[test]
fn envelope_canonical_ndjson_shape_is_frozen() -> Result<(), serde_json::Error> {
// The four-field shape every Wave 2 channel handler must accept. If you
// need to extend the wire shape, bump CHANNEL_ENVELOPE_V1 and add a new
// parity fixture below — do not edit this one.
let canonical = serde_json::json!({
"v": "sessions.channel.v1",
"channel": "control",
"kind": "echo",
"body": {"hello": "world"},
});
let env: Envelope = serde_json::from_value(canonical.clone())?;
assert_eq!(env.v, CHANNEL_ENVELOPE_V1);
assert_eq!(env.channel, "control");
assert_eq!(env.kind, "echo");
assert_eq!(env.body, serde_json::json!({"hello": "world"}));
let re_serialized = serde_json::to_value(&env)?;
assert_eq!(re_serialized, canonical);
Ok(())
}
#[test]
fn reference_dispatch_round_trips_control_echo() {
let request = Envelope::new(
"control",
"echo",
serde_json::json!({"id": "req-1", "payload": [1, 2, 3]}),
);
let response = reference_dispatch(&request);
assert!(response.is_current_version());
assert_eq!(response.channel, "control");
assert_eq!(response.kind, "echo_response");
assert_eq!(
response.body,
serde_json::json!({"id": "req-1", "payload": [1, 2, 3]}),
);
}
#[test]
fn reference_dispatch_rejects_stale_version() {
let request = Envelope {
v: "sessions.channel.v0".to_string(),
channel: "control".to_string(),
kind: "echo".to_string(),
body: serde_json::Value::Null,
};
let response = reference_dispatch(&request);
assert_eq!(response.kind, "error");
assert_eq!(response.body["code"], "envelope_version_mismatch");
// The error envelope itself is *current* version — only the rejected
// request held the stale `v`.
assert!(response.is_current_version());
}
#[test]
fn unknown_channel_kind_yields_structured_error_envelope() {
let request = Envelope::new("file", "tree/list", serde_json::Value::Null);
let response = reference_dispatch(&request);
assert_eq!(response.kind, "error");
assert_eq!(response.body["code"], "channel_kind_unhandled");
// PR 13b will replace this branch with a real `file` channel handler.
assert_eq!(response.body["channel"], "file");
assert_eq!(response.body["kind"], "tree/list");
}
#[test]
fn ndjson_round_trip_preserves_byte_for_byte_field_names() -> Result<(), serde_json::Error> {
// Byte-level pin: serde-derived Serialize emits keys in struct order.
// PR 16 (PR-A) relies on this when comparing recorded fixtures.
let env = Envelope::new("control", "echo", serde_json::json!({"x": 1}));
let line = serde_json::to_string(&env)?;
assert_eq!(
line,
r#"{"v":"sessions.channel.v1","channel":"control","kind":"echo","body":{"x":1}}"#,
);
Ok(())
}