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/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/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 81cf982..7e8df1a 100644 --- a/positron-core/src/session.rs +++ b/positron-core/src/session.rs @@ -68,14 +68,19 @@ //! //! ## 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. **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 @@ -157,6 +162,24 @@ 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. + /// + /// **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")] + correlation_id: uuid::Uuid, + /// Human-readable failure reason (consumer-displayable). + error: String, + }, } #[cfg(test)] @@ -194,6 +217,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..13afa96 100644 --- a/positron-core/src/wire.rs +++ b/positron-core/src/wire.rs @@ -26,7 +26,14 @@ 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)] +/// +/// `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, +)] #[serde(rename_all = "snake_case")] #[ts(export)] pub enum StateLayer {