From 308e63999757acfdc68ad6b7f15bed2c8a254a93 Mon Sep 17 00:00:00 2001 From: joelteply Date: Wed, 10 Jun 2026 14:55:49 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(session):=20ServerMessage::CommandFail?= =?UTF-8?q?ed=20+=20StateLayer=20Hash/Ord=20=E2=80=94=20v0.1.1=20(card=201?= =?UTF-8?q?dbcefce)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loud failures, no success-ack: a failed CommandEnvelope previously had NO reporting channel (silent failure, forbidden); a successful command's ack IS the state change it causes, so the unidirectional model stands. CommandFailed{correlation_id, error} is additive (tagged union) — v0.1.0 consumers are unaffected. StateLayer gains Hash/PartialOrd/Ord so substrate Subscription sets can be HashSet/BTreeSet-typed (continuum-positron slice 2A currently sort+dedups a Vec as a workaround). Requested by claude-continuum at the 2B seam (continuum#1601 thread). Co-Authored-By: Claude Fable 5 --- npm/core/src/generated/ServerMessage.ts | 10 +++++- positron-core/src/session.rs | 42 ++++++++++++++++++++----- positron-core/src/wire.rs | 4 ++- 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/npm/core/src/generated/ServerMessage.ts b/npm/core/src/generated/ServerMessage.ts index 2720855..30f77af 100644 --- a/npm/core/src/generated/ServerMessage.ts +++ b/npm/core/src/generated/ServerMessage.ts @@ -4,4 +4,12 @@ import type { StateEnvelope } from "./StateEnvelope"; /** * Substrate → client frames. */ -export type ServerMessage = { "type": "state" } & StateEnvelope; +export type ServerMessage = { "type": "state" } & StateEnvelope | { "type": "command_failed", +/** + * Echo of [`CommandEnvelope::correlation_id`]. + */ +correlation_id: string, +/** + * Human-readable failure reason (consumer-displayable). + */ +error: string, }; diff --git a/positron-core/src/session.rs b/positron-core/src/session.rs index 81cf982..d9fa4b4 100644 --- a/positron-core/src/session.rs +++ b/positron-core/src/session.rs @@ -68,14 +68,14 @@ //! //! ## Deliberate v0 omissions //! -//! There is no ack/error frame in [`ServerMessage`], so -//! `CommandEnvelope::correlation_id` has nothing protocol-level to -//! correlate with yet — command *results* currently surface as new -//! state (the substrate acts, state changes, the change streams -//! down). A `Result`/`Error` frame keyed by `correlation_id` is the -//! expected v0.x addition once a consumer actually needs -//! request-shaped feedback; adding a `ServerMessage` variant is -//! additive and non-breaking for tagged unions. +//! There is no success-ack frame in [`ServerMessage`]: a successful +//! command's acknowledgement IS the state change it causes (the +//! unidirectional model). Failures are different — a failed command +//! with no reporting channel is a silent failure, so +//! [`ServerMessage::CommandFailed`] exists (v0.1.1). A full +//! `Result` frame for request-shaped success feedback remains a +//! v0.x candidate; adding `ServerMessage` variants is additive and +//! non-breaking for tagged unions. //! //! The exact-equality skip has one residual ABA case: a client whose //! `last_seen` revision N came from a pre-restart substrate may meet @@ -157,6 +157,19 @@ pub enum ServerMessage { /// "which phase am I in" bookkeeping. If a client must /// distinguish, the revision diff already tells it. State(StateEnvelope), + /// A command could not be executed. Failures are LOUD — a + /// rejected [`CommandEnvelope`] must never vanish silently. + /// Success deliberately has no ack frame: a successful command's + /// acknowledgement IS the state change it causes, streaming down + /// as `State` (the unidirectional model). Consumers correlate via + /// the `correlation_id` they sent. + CommandFailed { + /// Echo of [`CommandEnvelope::correlation_id`]. + #[ts(type = "string")] + correlation_id: uuid::Uuid, + /// Human-readable failure reason (consumer-displayable). + error: String, + }, } #[cfg(test)] @@ -194,6 +207,19 @@ mod tests { assert_eq!(serde_json::from_str::(&json).unwrap(), cmd); } + /// Failures are loud and the tag is pinned; success has no ack + /// frame by design (the state change is the ack). + #[test] + fn command_failed_round_trips_with_pinned_tag() { + let fail = ServerMessage::CommandFailed { + correlation_id: Uuid::from_u128(7), + error: "chat/send: room not found".into(), + }; + let json = serde_json::to_string(&fail).unwrap(); + assert!(json.starts_with(r#"{"type":"command_failed""#), "{json}"); + assert_eq!(serde_json::from_str::(&json).unwrap(), fail); + } + #[test] fn server_state_frame_round_trips() { let state = ServerMessage::State(StateEnvelope { diff --git a/positron-core/src/wire.rs b/positron-core/src/wire.rs index f377725..cd310e1 100644 --- a/positron-core/src/wire.rs +++ b/positron-core/src/wire.rs @@ -26,7 +26,9 @@ use uuid::Uuid; /// Update-cadence classification for a state change (see `DESIGN.md` /// § "The 4 state layers"). Renderers and observers subscribe at the /// layer their target can sustain; the substrate enforces. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, TS)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Serialize, Deserialize, TS, +)] #[serde(rename_all = "snake_case")] #[ts(export)] pub enum StateLayer { From 25a4dfe0109d2702ae0d4aa5c2151eb1e20b37a6 Mon Sep 17 00:00:00 2001 From: joelteply Date: Wed, 10 Jun 2026 15:00:23 -0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(session):=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20sender-only=20CommandFailed,=20forward-compat=20rul?= =?UTF-8?q?e,=20real=20version=20bump=20(card=201dbcefce)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. CommandFailed delivery scope normative: ONLY to the originating connection, never broadcast — old consumers unaffected AND no failure-detail leakage. The 'unaffected' claim now has a mechanism. 2. Forward-compat rule stated: unknown ServerMessage types are skip-and-log, never connection-fatal — THAT is what makes variant additions non-breaking for deployed clients, not the union shape. Rust-consumer note included (unknown variant = per-frame decode error = same rule). 3. Versions made real: workspace + npm bumped 0.0.1 → 0.1.1 to match the tag lineage the docs claim; lib.rs versioning text updated. 4. StateLayer Ord documented as declaration-order, no semantic meaning — collection use only. Co-Authored-By: Claude Fable 5 --- Cargo.toml | 2 +- npm/core/package.json | 2 +- npm/core/src/generated/StateLayer.ts | 5 +++++ positron-core/src/lib.rs | 7 ++++--- positron-core/src/session.rs | 14 ++++++++++++-- positron-core/src/wire.rs | 5 +++++ 6 files changed, 28 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 015e9c4..1aa4fec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = [ ] [workspace.package] -version = "0.0.1" +version = "0.1.1" edition = "2021" license = "MIT" authors = ["CambrianTech "] diff --git a/npm/core/package.json b/npm/core/package.json index 43c7ae1..6680f4f 100644 --- a/npm/core/package.json +++ b/npm/core/package.json @@ -1,6 +1,6 @@ { "name": "@positron/core", - "version": "0.0.1", + "version": "0.1.1", "description": "Positron wire contract — TypeScript types generated from positron-core's Rust structs (single source of truth).", "license": "MIT", "repository": { diff --git a/npm/core/src/generated/StateLayer.ts b/npm/core/src/generated/StateLayer.ts index 9d95c59..44a29ed 100644 --- a/npm/core/src/generated/StateLayer.ts +++ b/npm/core/src/generated/StateLayer.ts @@ -4,5 +4,10 @@ * Update-cadence classification for a state change (see `DESIGN.md` * § "The 4 state layers"). Renderers and observers subscribe at the * layer their target can sustain; the substrate enforces. + * + * `Ord` exists so layer sets can live in `BTreeSet`/sorted + * collections; the ordering is declaration order and carries NO + * semantic meaning (Semantic is not "more than" Persistent). Do not + * write cadence logic against `<`/`>`. */ export type StateLayer = "ephemeral" | "session" | "persistent" | "semantic"; diff --git a/positron-core/src/lib.rs b/positron-core/src/lib.rs index 625329b..2db4eb7 100644 --- a/positron-core/src/lib.rs +++ b/positron-core/src/lib.rs @@ -34,9 +34,10 @@ //! //! ## Versioning //! -//! v0.0.x — contract design + reference renderers. Breaking changes -//! allowed. v1.0 is the first stable contract; consumers should pin -//! to a major-version range from there. +//! v0.x — contract design + reference renderers; wire-shape changes +//! allowed pre-1.0 but must regenerate the npm types in-commit (CI +//! enforces). v1.0 is the first stable contract; consumers should +//! pin to a major-version range from there. #![forbid(unsafe_code)] #![warn(missing_docs)] diff --git a/positron-core/src/session.rs b/positron-core/src/session.rs index d9fa4b4..7e8df1a 100644 --- a/positron-core/src/session.rs +++ b/positron-core/src/session.rs @@ -74,8 +74,13 @@ //! with no reporting channel is a silent failure, so //! [`ServerMessage::CommandFailed`] exists (v0.1.1). A full //! `Result` frame for request-shaped success feedback remains a -//! v0.x candidate; adding `ServerMessage` variants is additive and -//! non-breaking for tagged unions. +//! v0.x candidate. **Forward compatibility rule:** consumers MUST +//! treat a `ServerMessage` whose `type` they do not recognize as +//! skip-and-log, never connection-fatal — that is what makes variant +//! additions non-breaking for deployed clients, not the union shape +//! alone. (Rust consumers of the typed enum surface unknown variants +//! as a deserialize error on that frame; apply the same rule — log, +//! skip the frame, keep the connection.) //! //! The exact-equality skip has one residual ABA case: a client whose //! `last_seen` revision N came from a pre-restart substrate may meet @@ -163,6 +168,11 @@ pub enum ServerMessage { /// acknowledgement IS the state change it causes, streaming down /// as `State` (the unidirectional model). Consumers correlate via /// the `correlation_id` they sent. + /// + /// **Delivery scope:** the substrate MUST send this frame ONLY to + /// the connection that sent the failing command — never broadcast. + /// Other clients neither need another client's failures nor should + /// see their details. CommandFailed { /// Echo of [`CommandEnvelope::correlation_id`]. #[ts(type = "string")] diff --git a/positron-core/src/wire.rs b/positron-core/src/wire.rs index cd310e1..13afa96 100644 --- a/positron-core/src/wire.rs +++ b/positron-core/src/wire.rs @@ -26,6 +26,11 @@ use uuid::Uuid; /// Update-cadence classification for a state change (see `DESIGN.md` /// § "The 4 state layers"). Renderers and observers subscribe at the /// layer their target can sustain; the substrate enforces. +/// +/// `Ord` exists so layer sets can live in `BTreeSet`/sorted +/// collections; the ordering is declaration order and carries NO +/// semantic meaning (Semantic is not "more than" Persistent). Do not +/// write cadence logic against `<`/`>`. #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Serialize, Deserialize, TS, )]