Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ members = [
]

[workspace.package]
version = "0.0.1"
version = "0.1.1"
edition = "2021"
license = "MIT"
authors = ["CambrianTech <joel@cambriantech.com>"]
Expand Down
2 changes: 1 addition & 1 deletion npm/core/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
10 changes: 9 additions & 1 deletion npm/core/src/generated/ServerMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, };
5 changes: 5 additions & 0 deletions npm/core/src/generated/StateLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
7 changes: 4 additions & 3 deletions positron-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
52 changes: 44 additions & 8 deletions positron-core/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -194,6 +217,19 @@ mod tests {
assert_eq!(serde_json::from_str::<ClientMessage>(&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::<ServerMessage>(&json).unwrap(), fail);
}

#[test]
fn server_state_frame_round_trips() {
let state = ServerMessage::State(StateEnvelope {
Expand Down
9 changes: 8 additions & 1 deletion positron-core/src/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading